Skip to main content

tsift_cli/
lib.rs

1mod cli;
2mod commands;
3mod community_detection;
4mod conflict_matrix;
5mod context_pack;
6mod output;
7mod rewrite;
8mod search_budget;
9mod semantic_edit;
10mod session_review_budget;
11mod token_savings;
12mod workflow;
13
14pub(crate) use community_detection::{
15    CommunityDetectionReport, annotate_community_members_with_context,
16    community_tagpath_cache_part, community_tagpath_cache_part_for_loaded,
17    detect_communities_cached, file_communities_from_callers, graph_effectiveness_blocked,
18    graph_effectiveness_ready, resolve_tagpath_handle_for_callee_edge,
19    update_community_annotation_diagnostics,
20};
21#[allow(unused_imports)]
22pub(crate) use conflict_matrix::{
23    ConflictMatrixCandidate, ConflictMatrixGraphPreparedInputs, ConflictMatrixPreparedInputs,
24    ConflictMatrixReport, ConflictMatrixSemanticRef, ConflictMatrixSharedPreparationSummary,
25    ConflictMatrixWorkerFeedback, ConflictMatrixWorkerPromptPacket, build_conflict_matrix_report,
26    build_conflict_matrix_report_from_prepared_graph, cmd_conflict_matrix,
27    collect_conflict_matrix_evidence_packets, conflict_matrix_candidate_from_evidence,
28    conflict_matrix_graph_index, conflict_matrix_semantic_ref,
29    conflict_matrix_shared_preparation_summary, conflict_matrix_source_handle,
30    conflict_matrix_target_scoped_graph_snapshot, conflict_matrix_worker_feedback,
31    conflict_risk_label, extract_conflict_target_refs, hash_bytes_hex, is_planner_config_path,
32    normalize_conflict_target, prepare_conflict_matrix_graph_orchestration,
33    prepare_conflict_matrix_inputs, resolve_conflict_matrix_targets, sorted_intersection,
34    sorted_set,
35};
36#[allow(unused_imports)]
37pub(crate) use context_pack::{
38    ContextPackReport, ContextPackSummaryRefPreview, build_context_pack_diff_preview,
39    build_context_pack_log_preview, build_context_pack_report,
40    build_context_pack_report_with_profile, build_context_pack_test_preview,
41    context_pack_status_reminders, exploration_ref_id, materialize_context_pack_exploration_packet,
42    print_context_pack_human,
43};
44pub use rewrite::rewrite_command;
45pub(crate) use rewrite::{
46    apply_rewrite_output_format, execute_rewritten_command, no_rewrite_message,
47};
48#[cfg(test)]
49use search_budget::{SearchBudgetReport, search_facet_filters_summary};
50pub(crate) use search_budget::{
51    SearchBudgetReportInput, apply_search_facet_filters, build_search_budget_follow_up,
52    build_search_budget_report, print_search_budget_human,
53};
54pub(crate) use semantic_edit::{
55    AstSpanPreview, EditBatch, EditResult, EditStatus, MarkdownEmbeddedSymbol,
56    MarkdownSpanMetadata, MetricDigestOptions, SemanticEditVerifyOptions,
57    apply_edit_plan_atomically, build_edit_plan, cmd_edit_intents,
58};
59#[allow(unused_imports)]
60pub(crate) use session_review_budget::{
61    SessionReviewBudgetFailurePreview, SessionReviewBudgetReport,
62    SessionReviewNextContextBudgetReport, SessionReviewNextTokenAction,
63    build_session_review_budget_report, build_session_review_next_context_budget_report,
64    print_session_review_budget_human, print_session_review_next_context_budget_human,
65};
66
67#[cfg(test)]
68use rewrite::{
69    OutputCap, apply_output_cap, effective_rewrite_run_command, resolve_digest_context_path,
70    rewrite_output_cap,
71};
72#[cfg(test)]
73use std::io::{BufRead as _, BufReader};
74#[cfg(test)]
75use token_savings::{
76    TokenSavingsFamily, TokenSavingsFixture, TokenSavingsFixtureCase,
77    TokenSavingsMarkdownProjectionInput, TokenSavingsMarkdownProjectionInputs,
78    TokenSavingsRawSymbol, TokenSavingsSourceReadInput, TokenSavingsSourceReadInputs,
79    build_token_savings_report,
80};
81
82use anyhow::{Context, Result, bail};
83use clap::Parser;
84use cli::{
85    AstGrepCommand, Cli, Commands, DispatchTraceFormat, GraphDbQuery, KgCommand, LeaseCommand,
86    LocalModelCommand,
87    SemanticRelatedKind, SourceReadStyle,
88};
89
90#[cfg(test)]
91use cli::{GraphDbBackend, TraverseFormat};
92use commands::digests::{
93    cmd_context_pack, cmd_diff_digest, cmd_log_digest, cmd_metric_digest, cmd_session_cost,
94    cmd_session_digest, cmd_session_review_with_budget, cmd_test_digest,
95};
96#[cfg(test)]
97use commands::graph::cmd_explain;
98use commands::graph::{
99    cmd_analyze, cmd_communities, cmd_explain_with_budget, cmd_graph, cmd_path, cmd_traverse,
100};
101#[cfg(test)]
102use commands::index_search::cmd_search;
103use commands::index_search::{cmd_index, cmd_search_with_budget, cmd_search_worker};
104use commands::infra::{
105    StatusCommandOptions, cmd_convex_sync, cmd_edit, cmd_graph_db, cmd_init, cmd_locks,
106    cmd_rewrite, cmd_route, cmd_sql, cmd_status,
107};
108use commands::memory::cmd_memory;
109use commands::quality::{cmd_audit, cmd_audit_tagpath, cmd_lint};
110use commands::summarize::cmd_summarize;
111use flate2::{Compression, read::GzDecoder, write::GzEncoder};
112#[cfg(test)]
113use output::ResponseBudgetPreset;
114use output::tagpath::{
115    TagpathAnnotationDiagnostic, TagpathSearchOpts, annotate_communities_with_tagpath,
116    annotate_hits_with_tagpath, annotate_path_nodes_with_tagpath,
117    annotate_stored_edges_with_tagpath, annotate_stored_symbols_with_tagpath,
118};
119use output::{
120    OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric, ToolEnvelopeSummary,
121    TranscriptArtifactRef,
122};
123use rusqlite::{Connection, OptionalExtension, Row};
124use serde::{Deserialize, Serialize};
125use sift::{SearchInput, SearchOptions, Sift};
126#[cfg(test)]
127use std::cell::RefCell;
128use std::cmp::Ordering;
129use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
130use std::env;
131use std::fs;
132use std::io::{Read as _, Write as _};
133use std::path::{Path, PathBuf};
134use std::process::{Command, Stdio};
135use std::sync::{Mutex, OnceLock};
136use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
137use substrate::{
138    ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
139    GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
140    GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
141    SqliteGraphStore, SqliteProjectionRefresh, TerseGraphEdge as SubstrateTerseGraphEdge,
142    TerseGraphNode as SubstrateTerseGraphNode,
143};
144use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
145#[cfg(test)]
146use tsift_agent_doc::session_cost;
147use tsift_agent_doc::session_markdown::{self, AgentDocQueueItem, AgentDocSessionDocument};
148#[cfg(test)]
149use tsift_agent_doc::session_review;
150use tsift_cache::cycle_packet_cache;
151use tsift_core::{
152    NeighborhoodScoring, RankedNeighborhoodOptions, SemanticSeededNeighborhoodOptions,
153};
154use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
155use tsift_graph as graph;
156use tsift_index::{config, index, init, multiplicity, walk};
157use tsift_memgraphrag::append_tsift_memory_graph_projection_rows;
158#[cfg(test)]
159use tsift_memory::MemoryEvent;
160use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
161use tsift_resolution as resolution;
162use tsift_search::{impact, sift};
163use tsift_sqlite as substrate;
164use tsift_status::status;
165use tsift_summarize::summarize;
166#[cfg(feature = "backend-surrealdb")]
167use tsift_surrealdb::SurrealdbGraphStore;
168use tsift_tokensave::TokensaveDb;
169
170#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
171pub(crate) enum GraphDbExperimentalBackend {
172    DuckdbDuckpgq,
173    Falkordb,
174    Ladybug,
175    Kuzu,
176    Surrealdb,
177}
178
179#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
180pub(crate) struct SearchFacetFilters {
181    #[serde(skip_serializing_if = "Vec::is_empty", default)]
182    pub(crate) languages: Vec<String>,
183    #[serde(skip_serializing_if = "Vec::is_empty", default)]
184    pub(crate) kinds: Vec<String>,
185    #[serde(skip_serializing_if = "Vec::is_empty", default)]
186    pub(crate) node_kinds: Vec<String>,
187    #[serde(skip_serializing_if = "Vec::is_empty", default)]
188    pub(crate) sections: Vec<String>,
189    #[serde(skip_serializing_if = "Vec::is_empty", default)]
190    pub(crate) parents: Vec<String>,
191    #[serde(skip_serializing_if = "Vec::is_empty", default)]
192    pub(crate) children: Vec<String>,
193    #[serde(skip_serializing_if = "Vec::is_empty", default)]
194    pub(crate) fence_languages: Vec<String>,
195    #[serde(skip_serializing_if = "Vec::is_empty", default)]
196    pub(crate) list_depths: Vec<usize>,
197    #[serde(skip_serializing_if = "Vec::is_empty", default)]
198    pub(crate) heading_levels: Vec<usize>,
199}
200
201impl SearchFacetFilters {
202    pub(crate) fn is_empty(&self) -> bool {
203        self.languages.is_empty()
204            && self.kinds.is_empty()
205            && self.node_kinds.is_empty()
206            && self.sections.is_empty()
207            && self.parents.is_empty()
208            && self.children.is_empty()
209            && self.fence_languages.is_empty()
210            && self.list_depths.is_empty()
211            && self.heading_levels.is_empty()
212    }
213
214    fn needs_ast_context(&self) -> bool {
215        !self.sections.is_empty()
216            || !self.parents.is_empty()
217            || !self.children.is_empty()
218            || !self.fence_languages.is_empty()
219            || !self.list_depths.is_empty()
220            || !self.heading_levels.is_empty()
221    }
222}
223
224#[derive(Serialize)]
225struct GraphDbBackendPromotionGate {
226    status: String,
227    native_adapter_required: bool,
228    required_checks: Vec<String>,
229}
230
231impl GraphDbExperimentalBackend {
232    fn name(self) -> &'static str {
233        match self {
234            Self::DuckdbDuckpgq => "duckdb-duckpgq",
235            Self::Falkordb => "falkordb",
236            Self::Ladybug => "ladybug",
237            Self::Kuzu => "kuzu",
238            Self::Surrealdb => "surrealdb",
239        }
240    }
241
242    fn adapter_label(self) -> &'static str {
243        match self {
244            Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
245            Self::Falkordb => "FalkorDB read-only prototype",
246            Self::Ladybug => "Ladybug read-only prototype",
247            Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
248            Self::Surrealdb => "SurrealDB read-only prototype",
249        }
250    }
251
252    fn projection_load(self) -> &'static str {
253        match self {
254            Self::Falkordb => {
255                "provider-neutral rows loaded into a FalkorDB-shaped read snapshot for parity and timing only; production FalkorDB storage remains behind backend-eval until a real adapter passes the full-projection gate"
256            }
257            Self::Kuzu => {
258                "provider-neutral rows loaded into a Kuzu-compatible in-process read snapshot for parity and performance gates; production Vela-Engineering/kuzu storage remains behind a future optional adapter"
259            }
260            Self::Surrealdb => {
261                "provider-neutral rows loaded into a SurrealDB-compatible read snapshot for parity and timing only; production SurrealDB storage remains behind backend-eval until a real optional adapter passes the full-projection gate"
262            }
263            _ => {
264                "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
265            }
266        }
267    }
268
269    fn lock_behavior(self) -> &'static str {
270        match self {
271            Self::Falkordb => {
272                "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
273            }
274            Self::Kuzu => {
275                "read-only Kuzu prototype snapshot; no SQLite writer lock is taken during benchmarks, and production Vela-Engineering/kuzu promotion must prove concurrent writer semantics before replacing SQLite"
276            }
277            Self::Surrealdb => {
278                "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
279            }
280            _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
281        }
282    }
283
284    fn install_portability(self) -> &'static str {
285        match self {
286            Self::Falkordb => {
287                "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
288            }
289            Self::Kuzu => {
290                "prototype is dependency-free in this binary; production Vela-Engineering/kuzu integration must stay optional so cargo build/install works without a native Kuzu toolchain"
291            }
292            Self::Surrealdb => {
293                "prototype is dependency-free in this binary; production SurrealDB integration must stay optional so cargo build/install works without pulling SurrealDB into the default build"
294            }
295            _ => {
296                "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
297            }
298        }
299    }
300
301    fn prototype_hold_reason(self) -> Option<&'static str> {
302        match self {
303            Self::DuckdbDuckpgq => Some(
304                "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
305            ),
306            Self::Falkordb => Some(
307                "FalkorDB remains behind backend-eval until a production adapter beats SQLite on full_projection conflict-matrix, evidence, dispatch-trace, path tiers, install portability, and lock behavior",
308            ),
309            Self::Ladybug => Some(
310                "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
311            ),
312            Self::Kuzu => Some(
313                "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
314            ),
315            Self::Surrealdb => Some(
316                "SurrealDB remains behind backend-eval until a feature-gated optional adapter proves provider-neutral projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
317            ),
318        }
319    }
320
321    fn promotion_gate(self) -> GraphDbBackendPromotionGate {
322        match self {
323            Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
324                status: "hold_native_adapter_required".to_string(),
325                native_adapter_required: true,
326                required_checks: vec![
327                    "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
328                        .to_string(),
329                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
330                        .to_string(),
331                    "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
332                    "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
333                        .to_string(),
334                ],
335            },
336            Self::Falkordb => GraphDbBackendPromotionGate {
337                status: "hold_native_adapter_required".to_string(),
338                native_adapter_required: true,
339                required_checks: vec![
340                    "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
341                        .to_string(),
342                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
343                        .to_string(),
344                    "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
345                        .to_string(),
346                    "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
347                        .to_string(),
348                ],
349            },
350            Self::Ladybug => GraphDbBackendPromotionGate {
351                status: "hold_native_adapter_required".to_string(),
352                native_adapter_required: true,
353                required_checks: vec![
354                    "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
355                        .to_string(),
356                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
357                        .to_string(),
358                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
359                        .to_string(),
360                    "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
361                        .to_string(),
362                ],
363            },
364            Self::Kuzu => GraphDbBackendPromotionGate {
365                status: "hold_native_adapter_required".to_string(),
366                native_adapter_required: true,
367                required_checks: vec![
368                    "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
369                        .to_string(),
370                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
371                        .to_string(),
372                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
373                        .to_string(),
374                    "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
375                        .to_string(),
376                ],
377            },
378            Self::Surrealdb => GraphDbBackendPromotionGate {
379                status: "hold_native_adapter_required".to_string(),
380                native_adapter_required: true,
381                required_checks: vec![
382                    "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
383                        .to_string(),
384                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
385                        .to_string(),
386                    "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
387                        .to_string(),
388                    "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
389                        .to_string(),
390                ],
391            },
392        }
393    }
394
395    fn parse(raw: &str) -> Result<Self> {
396        match raw {
397            "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
398            "falkordb" | "falkor" => Ok(Self::Falkordb),
399            "ladybug" => Ok(Self::Ladybug),
400            "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
401            "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
402            _ => {
403                bail!(
404                    "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
405                )
406            }
407        }
408    }
409}
410
411pub fn run() -> Result<()> {
412    let cli = Cli::parse();
413    let compact = cli.compact;
414    let pretty = cli.pretty;
415    let terse = cli.terse || cli.ultra_terse;
416    let ultra_terse = cli.ultra_terse;
417    let absolute = cli.absolute;
418    let tabular = cli.tabular;
419    let schema = cli.schema;
420    let envelope = cli.envelope;
421    match cli.command {
422        Some(Commands::Search {
423            query,
424            path,
425            limit,
426            strategy,
427            exact,
428            scope,
429            federated,
430            lang,
431            kind,
432            node_kind,
433            section,
434            parent,
435            child,
436            fence_language,
437            list_depth,
438            heading_level,
439            json,
440            autoindex,
441            no_autoindex,
442            timeout,
443            max_items,
444            max_bytes,
445            budget,
446            no_tagpath,
447            tagpath_strict,
448        }) => cmd_search_with_budget(
449            query,
450            path,
451            limit,
452            if exact {
453                Some("exact".to_string())
454            } else {
455                strategy
456            },
457            scope,
458            federated,
459            json || terse || schema || envelope,
460            autoindex || !no_autoindex,
461            timeout,
462            compact,
463            pretty,
464            terse,
465            ultra_terse,
466            absolute,
467            tabular,
468            schema,
469            envelope,
470            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
471            TagpathSearchOpts {
472                no_tagpath,
473                strict: tagpath_strict,
474            },
475            SearchFacetFilters {
476                languages: lang,
477                kinds: kind,
478                node_kinds: node_kind,
479                sections: section,
480                parents: parent,
481                children: child,
482                fence_languages: fence_language,
483                list_depths: list_depth,
484                heading_levels: heading_level,
485            },
486        ),
487        Some(Commands::SearchWorker {
488            path,
489            cache_dir,
490            query,
491            limit,
492            strategy,
493            output,
494            fts_index_fresh,
495        }) => cmd_search_worker(
496            &path,
497            &cache_dir,
498            &query,
499            limit,
500            &strategy,
501            &output,
502            fts_index_fresh,
503        ),
504        Some(Commands::DigestRunner {
505            kind,
506            path,
507            runner,
508            shell_command,
509            json,
510        }) => cmd_digest_runner(
511            &kind,
512            &path,
513            runner.as_deref(),
514            &shell_command,
515            OutputFormat {
516                json_output: json || terse || schema || envelope,
517                compact,
518                pretty,
519                terse,
520                ultra_terse,
521                schema,
522                envelope,
523            },
524        ),
525        Some(Commands::AstGrep { command }) => match command {
526            AstGrepCommand::Search {
527                pattern,
528                paths,
529                lang,
530                no_ignore,
531                json,
532                max_items,
533                max_bytes,
534                budget,
535            } => commands::astgrep::cmd_ast_grep_search(
536                &pattern,
537                paths,
538                lang.as_deref(),
539                no_ignore,
540                OutputFormat {
541                    json_output: json || terse || schema || envelope,
542                    compact,
543                    pretty,
544                    terse,
545                    ultra_terse,
546                    schema,
547                    envelope,
548                },
549                ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
550            ),
551            AstGrepCommand::Rewrite {
552                pattern,
553                rewrite,
554                paths,
555                lang,
556                no_ignore,
557                apply,
558                json,
559                max_items,
560                max_bytes,
561                budget,
562            } => commands::astgrep::cmd_ast_grep_rewrite(
563                &pattern,
564                &rewrite,
565                paths,
566                lang.as_deref(),
567                no_ignore,
568                apply,
569                OutputFormat {
570                    json_output: json || terse || schema || envelope,
571                    compact,
572                    pretty,
573                    terse,
574                    ultra_terse,
575                    schema,
576                    envelope,
577                },
578                ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
579            ),
580            AstGrepCommand::Languages { json } => {
581                commands::astgrep::cmd_ast_grep_languages(OutputFormat {
582                    json_output: json || terse || schema || envelope,
583                    compact,
584                    pretty,
585                    terse,
586                    ultra_terse,
587                    schema,
588                    envelope,
589                })
590            }
591        },
592        Some(Commands::Edit { dry_run, file }) => {
593            cmd_edit(dry_run, file, compact, pretty, terse, schema)
594        }
595        Some(Commands::EditIntents {
596            path,
597            scope,
598            file,
599            json,
600            apply,
601            verify,
602            verify_command,
603            max_items,
604            max_bytes,
605            budget,
606        }) => cmd_edit_intents(
607            &path,
608            scope.as_deref(),
609            file,
610            apply,
611            SemanticEditVerifyOptions {
612                enabled: verify,
613                command: verify_command.as_deref(),
614            },
615            OutputFormat {
616                json_output: json || terse || schema || envelope,
617                compact,
618                pretty,
619                terse,
620                ultra_terse,
621                schema,
622                envelope,
623            },
624            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
625        ),
626        Some(Commands::Index {
627            path,
628            rebuild,
629            check,
630            exit_code,
631            prune,
632            quiet,
633            workspace,
634            submodule,
635            json,
636        }) => cmd_index(
637            &path,
638            rebuild,
639            check,
640            exit_code,
641            prune,
642            quiet,
643            workspace,
644            submodule.as_deref(),
645            json || terse || schema || envelope,
646            compact,
647            pretty,
648            terse,
649            absolute,
650            schema,
651        ),
652        Some(Commands::Rewrite { command, run }) => cmd_rewrite(
653            &command,
654            run,
655            OutputFormat {
656                json_output: terse || schema || envelope,
657                compact,
658                pretty,
659                terse,
660                ultra_terse,
661                schema,
662                envelope,
663            },
664        ),
665        Some(Commands::Route { task, id }) => cmd_route(&task, id),
666        Some(Commands::Memory { command }) => {
667            let json = command.json_output();
668            cmd_memory(
669                command,
670                OutputFormat {
671                    json_output: json || terse || schema || envelope,
672                    compact,
673                    pretty,
674                    terse,
675                    ultra_terse,
676                    schema,
677                    envelope,
678                },
679            )
680        }
681        Some(Commands::LocalModel { command }) => {
682            let json = command.json_output();
683            cmd_local_model(
684                command,
685                OutputFormat {
686                    json_output: json || terse || schema || envelope,
687                    compact,
688                    pretty,
689                    terse,
690                    ultra_terse,
691                    schema,
692                    envelope,
693                },
694            )
695        }
696        Some(Commands::Kg { command }) => match command {
697            KgCommand::Extract {
698                profile,
699                model,
700                host,
701                input,
702                source_ref,
703                graph_db,
704                no_lease,
705                idle_ttl_seconds,
706                keep_loaded,
707                lease_file,
708                no_context,
709                json,
710            } => commands::kg::cmd_kg_extract(commands::kg::KgExtractArgs {
711                profile,
712                model,
713                host,
714                input,
715                source_ref,
716                graph_db,
717                no_lease,
718                idle_ttl_seconds,
719                keep_loaded,
720                lease_file,
721                no_context,
722                json: json || terse || schema || envelope,
723            }),
724            KgCommand::Status { graph_db, json } => {
725                commands::kg::cmd_kg_status(graph_db, json || terse || schema || envelope)
726            }
727            KgCommand::Refresh {
728                graph_db,
729                apply,
730                profile,
731                model,
732                host,
733                no_lease,
734                idle_ttl_seconds,
735                keep_loaded,
736                lease_file,
737                no_context,
738                json,
739            } => commands::kg::cmd_kg_refresh(commands::kg::KgRefreshArgs {
740                graph_db,
741                json: json || terse || schema || envelope,
742                apply,
743                profile,
744                model,
745                host,
746                no_lease,
747                idle_ttl_seconds,
748                keep_loaded,
749                lease_file,
750                no_context,
751            }),
752            KgCommand::Evidence {
753                symbol,
754                kind,
755                limit,
756                graph_db,
757                json,
758            } => commands::kg::cmd_kg_evidence(
759                symbol,
760                kind,
761                limit,
762                graph_db,
763                json || terse || schema || envelope,
764            ),
765            KgCommand::Unload {
766                profile,
767                model,
768                host,
769                json,
770            } => commands::kg::cmd_kg_unload(
771                profile,
772                model,
773                host,
774                json || terse || schema || envelope,
775            ),
776            KgCommand::Smoke {
777                profile,
778                model,
779                host,
780                unload,
781                json,
782            } => commands::kg::cmd_kg_smoke(
783                profile,
784                model,
785                host,
786                unload,
787                json || terse || schema || envelope,
788            ),
789        },
790        Some(Commands::Finding { command }) => match command {
791            cli::FindingCommand::Add {
792                path,
793                kind,
794                title,
795                body,
796                about,
797                confidence,
798                status,
799                relates,
800                scope,
801                json,
802            } => commands::finding::cmd_finding_add(
803                &path,
804                &kind,
805                &title,
806                &body,
807                &about,
808                confidence,
809                &status,
810                relates.as_deref(),
811                scope.as_deref(),
812                json || terse || schema || envelope,
813                pretty,
814            ),
815            cli::FindingCommand::List {
816                path,
817                about,
818                kind,
819                status,
820                include_stale,
821                scope,
822                json,
823            } => commands::finding::cmd_finding_list(
824                &path,
825                about.as_deref(),
826                kind.as_deref(),
827                status.as_deref(),
828                include_stale,
829                scope.as_deref(),
830                json || terse || schema || envelope,
831                pretty,
832            ),
833            cli::FindingCommand::Harvest { path, scope, json } => {
834                commands::finding::cmd_finding_harvest(
835                    &path,
836                    scope.as_deref(),
837                    json || terse || schema || envelope,
838                    pretty,
839                )
840            }
841            cli::FindingCommand::Promote { id, path, json } => {
842                commands::finding::cmd_finding_promote(
843                    &path,
844                    &id,
845                    json || terse || schema || envelope,
846                    pretty,
847                )
848            }
849        },
850        Some(Commands::Graph {
851            symbol,
852            path,
853            callers,
854            callees,
855            scope,
856            limit,
857            json,
858            no_tagpath,
859            tagpath_strict,
860        }) => cmd_graph(
861            &symbol,
862            &path,
863            callers,
864            callees,
865            scope.as_deref(),
866            limit,
867            json || terse || schema || envelope,
868            compact,
869            pretty,
870            terse,
871            absolute,
872            tabular,
873            schema,
874            TagpathSearchOpts {
875                no_tagpath,
876                strict: tagpath_strict,
877            },
878        ),
879        Some(Commands::Sql {
880            db,
881            query,
882            table,
883            json,
884        }) => cmd_sql(
885            &db,
886            query,
887            table,
888            json || terse || schema || envelope,
889            compact,
890            pretty,
891            terse,
892            schema,
893        ),
894        Some(Commands::Communities {
895            path,
896            scope,
897            min_size,
898            limit,
899            json,
900            no_tagpath,
901            tagpath_strict,
902        }) => cmd_communities(
903            &path,
904            scope.as_deref(),
905            min_size,
906            limit,
907            json || terse || schema || envelope,
908            compact,
909            pretty,
910            terse,
911            tabular,
912            schema,
913            TagpathSearchOpts {
914                no_tagpath,
915                strict: tagpath_strict,
916            },
917        ),
918        Some(Commands::Analyze {
919            path,
920            scope,
921            entry_points,
922            limit,
923            json,
924        }) => cmd_analyze(
925            &path,
926            scope.as_deref(),
927            &entry_points,
928            limit,
929            OutputFormat {
930                json_output: json || terse || schema || envelope,
931                compact,
932                pretty,
933                terse,
934                ultra_terse,
935                schema,
936                envelope,
937            },
938        ),
939        Some(Commands::Path {
940            from,
941            to,
942            path,
943            scope,
944            json,
945            no_tagpath,
946            tagpath_strict,
947        }) => cmd_path(
948            &from,
949            &to,
950            &path,
951            scope.as_deref(),
952            json || terse || schema || envelope,
953            compact,
954            pretty,
955            terse,
956            schema,
957            TagpathSearchOpts {
958                no_tagpath,
959                strict: tagpath_strict,
960            },
961        ),
962        Some(Commands::Explain {
963            symbol,
964            path,
965            scope,
966            limit,
967            json,
968            max_items,
969            max_bytes,
970            budget,
971            no_tagpath,
972            tagpath_strict,
973        }) => cmd_explain_with_budget(
974            &symbol,
975            &path,
976            scope.as_deref(),
977            limit,
978            json || terse || schema || envelope,
979            compact,
980            pretty,
981            terse,
982            ultra_terse,
983            absolute,
984            tabular,
985            schema,
986            envelope,
987            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
988            TagpathSearchOpts {
989                no_tagpath,
990                strict: tagpath_strict,
991            },
992        ),
993        Some(Commands::Traverse {
994            node,
995            to,
996            path,
997            scope,
998            depth,
999            limit,
1000            format,
1001            convex_snapshot,
1002        }) => cmd_traverse(
1003            node.as_deref(),
1004            to.as_deref(),
1005            &path,
1006            scope.as_deref(),
1007            depth,
1008            limit,
1009            format,
1010            pretty,
1011            terse,
1012            schema,
1013            convex_snapshot.as_deref(),
1014        ),
1015        Some(Commands::ConvexSync {
1016            path,
1017            scope,
1018            snapshot,
1019            chunk_size,
1020            remote_snapshot,
1021            apply,
1022            endpoint,
1023            auth_token_env,
1024            json,
1025        }) => cmd_convex_sync(
1026            ConvexSyncOptions {
1027                path: &path,
1028                scope: scope.as_deref(),
1029                snapshot: snapshot.as_deref(),
1030                chunk_size,
1031                remote_snapshot,
1032                apply,
1033                endpoint: endpoint.as_deref(),
1034                auth_token_env: &auth_token_env,
1035            },
1036            OutputFormat {
1037                json_output: json || terse || schema || envelope,
1038                compact,
1039                pretty,
1040                terse,
1041                ultra_terse,
1042                schema,
1043                envelope,
1044            },
1045        ),
1046        Some(Commands::GraphDb {
1047            path,
1048            scope,
1049            backend,
1050            convex_snapshot,
1051            json,
1052            query,
1053        }) => cmd_graph_db(
1054            &path,
1055            scope.as_deref(),
1056            backend,
1057            convex_snapshot.as_deref(),
1058            query,
1059            OutputFormat {
1060                json_output: json || terse || schema || envelope,
1061                compact,
1062                pretty,
1063                terse,
1064                ultra_terse,
1065                schema,
1066                envelope,
1067            },
1068        ),
1069        Some(Commands::SourceRead {
1070            file,
1071            path,
1072            style,
1073            start,
1074            lines,
1075            end,
1076            scope,
1077            json,
1078            max_items,
1079            max_bytes,
1080            budget,
1081        }) => cmd_source_read(
1082            &file,
1083            &path,
1084            style,
1085            start,
1086            lines,
1087            end,
1088            scope.as_deref(),
1089            OutputFormat {
1090                json_output: json || terse || schema || envelope,
1091                compact,
1092                pretty,
1093                terse,
1094                ultra_terse,
1095                schema,
1096                envelope,
1097            },
1098            absolute,
1099            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1100        ),
1101        Some(Commands::MarkdownAst {
1102            file,
1103            path,
1104            node,
1105            json,
1106            max_items,
1107            max_bytes,
1108            budget,
1109        }) => cmd_markdown_ast(
1110            &file,
1111            &path,
1112            node.as_deref(),
1113            OutputFormat {
1114                json_output: json || terse || schema || envelope,
1115                compact,
1116                pretty,
1117                terse,
1118                ultra_terse,
1119                schema,
1120                envelope,
1121            },
1122            absolute,
1123            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1124        ),
1125        Some(Commands::SymbolRead {
1126            symbol,
1127            file,
1128            path,
1129            scope,
1130            json,
1131            max_items,
1132            max_bytes,
1133            budget,
1134        }) => cmd_symbol_read(
1135            &symbol,
1136            file.as_deref(),
1137            &path,
1138            scope.as_deref(),
1139            OutputFormat {
1140                json_output: json || terse || schema || envelope,
1141                compact,
1142                pretty,
1143                terse,
1144                ultra_terse,
1145                schema,
1146                envelope,
1147            },
1148            absolute,
1149            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1150        ),
1151        Some(Commands::Audit {
1152            skills_dir,
1153            manifest,
1154            usage,
1155            cleanup,
1156            report,
1157            json,
1158        }) => cmd_audit(
1159            &skills_dir,
1160            manifest,
1161            usage,
1162            cleanup,
1163            report,
1164            json || terse || schema || envelope,
1165            compact,
1166            pretty,
1167            terse,
1168            schema,
1169        ),
1170        Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
1171            &path,
1172            scope.as_deref(),
1173            json || terse || schema || envelope,
1174            pretty,
1175            terse,
1176            schema,
1177        ),
1178        Some(Commands::Init {
1179            path,
1180            codex,
1181            opencode,
1182            workspace,
1183        }) => cmd_init(&path, codex, opencode, workspace),
1184        Some(Commands::Lint {
1185            file,
1186            index,
1187            entities_from,
1188            json,
1189        }) => cmd_lint(
1190            &file,
1191            index,
1192            entities_from,
1193            json || terse || schema || envelope,
1194            compact,
1195            pretty,
1196            terse,
1197            schema,
1198        ),
1199        Some(Commands::Summarize {
1200            symbol,
1201            file,
1202            extract,
1203            diff,
1204            stats,
1205            path,
1206            profile,
1207            json,
1208        }) => cmd_summarize(
1209            symbol,
1210            file,
1211            extract,
1212            diff,
1213            stats,
1214            &path,
1215            json || terse || schema || envelope,
1216            compact,
1217            pretty,
1218            terse,
1219            schema,
1220            profile,
1221        ),
1222        Some(Commands::Semantic {
1223            query,
1224            path,
1225            scope,
1226            limit,
1227            kind,
1228            profile,
1229            json,
1230        }) => cmd_semantic_related(
1231            &query,
1232            &path,
1233            scope.as_deref(),
1234            limit,
1235            kind,
1236            json || terse || schema || envelope,
1237            compact,
1238            pretty,
1239            terse,
1240            schema,
1241            profile,
1242        ),
1243        Some(Commands::DiffDigest {
1244            path,
1245            cached,
1246            revision,
1247            max_parsed_files,
1248            json,
1249        }) => cmd_diff_digest(
1250            &path,
1251            cached,
1252            revision.as_deref(),
1253            max_parsed_files,
1254            OutputFormat {
1255                json_output: json || terse || schema || envelope,
1256                compact,
1257                pretty,
1258                terse,
1259                ultra_terse,
1260                schema,
1261                envelope,
1262            },
1263        ),
1264        Some(Commands::Impact {
1265            path,
1266            cached,
1267            revision,
1268            scope,
1269            limit,
1270            json,
1271        }) => cmd_impact(
1272            &path,
1273            cached,
1274            revision.as_deref(),
1275            scope.as_deref(),
1276            limit,
1277            OutputFormat {
1278                json_output: json || terse || schema || envelope,
1279                compact,
1280                pretty,
1281                terse,
1282                ultra_terse,
1283                schema,
1284                envelope,
1285            },
1286        ),
1287        Some(Commands::TestDigest {
1288            path,
1289            input,
1290            runner,
1291            json,
1292        }) => cmd_test_digest(
1293            &path,
1294            input.as_deref(),
1295            runner.as_deref(),
1296            OutputFormat {
1297                json_output: json || terse || schema || envelope,
1298                compact,
1299                pretty,
1300                terse,
1301                ultra_terse,
1302                schema,
1303                envelope,
1304            },
1305        ),
1306        Some(Commands::LogDigest {
1307            path,
1308            input,
1309            fixture,
1310            fail_under,
1311            json,
1312        }) => cmd_log_digest(
1313            &path,
1314            input.as_deref(),
1315            fixture.as_deref(),
1316            fail_under,
1317            OutputFormat {
1318                json_output: json || terse || schema || envelope,
1319                compact,
1320                pretty,
1321                terse,
1322                ultra_terse,
1323                schema,
1324                envelope,
1325            },
1326        ),
1327        Some(Commands::ContextPack {
1328            path,
1329            test_input,
1330            runner,
1331            log_input,
1332            json,
1333            max_items,
1334            max_bytes,
1335            budget,
1336            convex_snapshot,
1337        }) => cmd_context_pack(
1338            &path,
1339            test_input.as_deref(),
1340            runner.as_deref(),
1341            log_input.as_deref(),
1342            OutputFormat {
1343                json_output: json || terse || schema || envelope,
1344                compact,
1345                pretty,
1346                terse,
1347                ultra_terse,
1348                schema,
1349                envelope,
1350            },
1351            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1352            convex_snapshot.as_deref(),
1353        ),
1354        Some(Commands::ConflictMatrix {
1355            targets,
1356            path,
1357            scope,
1358            depth,
1359            limit,
1360            impact_limit,
1361            json,
1362        }) => cmd_conflict_matrix(
1363            &path,
1364            scope.as_deref(),
1365            &targets,
1366            depth,
1367            limit,
1368            impact_limit,
1369            OutputFormat {
1370                json_output: json || terse || schema || envelope,
1371                compact,
1372                pretty,
1373                terse,
1374                ultra_terse,
1375                schema,
1376                envelope,
1377            },
1378        ),
1379        Some(Commands::DispatchTrace {
1380            targets,
1381            path,
1382            scope,
1383            depth,
1384            limit,
1385            impact_limit,
1386            format,
1387            json,
1388        }) => cmd_dispatch_trace(
1389            DispatchTraceOptions {
1390                path: &path,
1391                scope: scope.as_deref(),
1392                raw_targets: &targets,
1393                depth,
1394                limit,
1395                impact_limit,
1396                trace_format: if json {
1397                    DispatchTraceFormat::Json
1398                } else {
1399                    format
1400                },
1401            },
1402            OutputFormat {
1403                json_output: json || terse || schema || envelope,
1404                compact,
1405                pretty,
1406                terse,
1407                ultra_terse,
1408                schema,
1409                envelope,
1410            },
1411        ),
1412        Some(Commands::DependencyDag {
1413            targets,
1414            path,
1415            scope,
1416            depth,
1417            limit,
1418            json,
1419        }) => cmd_dependency_dag(
1420            &path,
1421            scope.as_deref(),
1422            &targets,
1423            depth,
1424            limit,
1425            OutputFormat {
1426                json_output: json || terse || schema || envelope,
1427                compact,
1428                pretty,
1429                terse,
1430                ultra_terse,
1431                schema,
1432                envelope,
1433            },
1434        ),
1435        Some(Commands::TokenSavings {
1436            fixture,
1437            fail_under,
1438            json,
1439        }) => token_savings::cmd_token_savings(
1440            &fixture,
1441            fail_under,
1442            OutputFormat {
1443                json_output: json || terse || schema || envelope,
1444                compact,
1445                pretty,
1446                terse,
1447                ultra_terse,
1448                schema,
1449                envelope,
1450            },
1451        ),
1452        Some(Commands::MetricDigest {
1453            input,
1454            baseline,
1455            metrics,
1456            lower_is_better,
1457            higher_is_better,
1458            history,
1459            top,
1460            json,
1461        }) => cmd_metric_digest(
1462            MetricDigestOptions {
1463                input_path: input.as_deref(),
1464                baseline_path: baseline.as_deref(),
1465                metrics: &metrics,
1466                lower_is_better: &lower_is_better,
1467                higher_is_better: &higher_is_better,
1468                history,
1469                top,
1470            },
1471            OutputFormat {
1472                json_output: json || terse || schema || envelope,
1473                compact,
1474                pretty,
1475                terse,
1476                ultra_terse,
1477                schema,
1478                envelope,
1479            },
1480        ),
1481        Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1482            &fixture,
1483            OutputFormat {
1484                json_output: json || terse || schema || envelope,
1485                compact,
1486                pretty,
1487                terse,
1488                ultra_terse,
1489                schema,
1490                envelope,
1491            },
1492        ),
1493        Some(Commands::TokenGate { command }) => {
1494            cmd_token_gate(
1495                command,
1496                OutputFormat {
1497                    json_output: true,
1498                    compact,
1499                    pretty,
1500                    terse,
1501                    ultra_terse,
1502                    schema,
1503                    envelope,
1504                },
1505            )?;
1506            Ok(())
1507        }
1508        Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1509            &topic,
1510            OutputFormat {
1511                json_output: json || terse || schema || envelope,
1512                compact,
1513                pretty,
1514                terse,
1515                ultra_terse,
1516                schema,
1517                envelope,
1518            },
1519        ),
1520        Some(Commands::SessionDigest {
1521            path,
1522            input,
1523            source,
1524            json,
1525        }) => cmd_session_digest(
1526            &path,
1527            input.as_deref(),
1528            source.as_deref(),
1529            OutputFormat {
1530                json_output: json || terse || schema || envelope,
1531                compact,
1532                pretty,
1533                terse,
1534                ultra_terse,
1535                schema,
1536                envelope,
1537            },
1538        ),
1539        Some(Commands::SessionCost {
1540            input,
1541            fixture,
1542            fail_under,
1543            source,
1544            json,
1545        }) => cmd_session_cost(
1546            input.as_deref(),
1547            fixture.as_deref(),
1548            fail_under,
1549            source.as_deref(),
1550            OutputFormat {
1551                json_output: json || terse || schema || envelope,
1552                compact,
1553                pretty,
1554                terse,
1555                ultra_terse,
1556                schema,
1557                envelope,
1558            },
1559        ),
1560        Some(Commands::SessionReview {
1561            path,
1562            next_context,
1563            json,
1564            max_items,
1565            max_bytes,
1566            budget,
1567        }) => cmd_session_review_with_budget(
1568            &path,
1569            next_context,
1570            OutputFormat {
1571                json_output: json || terse || schema || envelope,
1572                compact,
1573                pretty,
1574                terse,
1575                ultra_terse,
1576                schema,
1577                envelope,
1578            },
1579            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1580        ),
1581        Some(Commands::Status {
1582            path,
1583            fix,
1584            no_fix,
1585            json,
1586        }) => cmd_status(
1587            &path,
1588            StatusCommandOptions {
1589                fix,
1590                no_fix,
1591                json_output: json || terse || schema || envelope,
1592                compact,
1593                pretty,
1594                terse,
1595                schema,
1596            },
1597        ),
1598        Some(Commands::Locks { path, scope, json }) => cmd_locks(
1599            &path,
1600            scope.as_deref(),
1601            json || terse || schema || envelope,
1602            compact,
1603            pretty,
1604            terse,
1605            schema,
1606        ),
1607        None => {
1608            println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1609            println!("Run `tsift --help` for usage.");
1610            Ok(())
1611        }
1612    }
1613}
1614
1615fn cmd_local_model(command: LocalModelCommand, output: OutputFormat) -> Result<()> {
1616    match command {
1617        LocalModelCommand::Status { no_probe, .. } => {
1618            let report = tsift_local_model::build_status_report(!no_probe);
1619            if output.json_output {
1620                if output.pretty {
1621                    println!("{}", serde_json::to_string_pretty(&report)?);
1622                } else {
1623                    println!("{}", serde_json::to_string(&report)?);
1624                }
1625            } else {
1626                print!("{}", tsift_local_model::format_status_human(&report));
1627            }
1628            Ok(())
1629        }
1630        LocalModelCommand::Unload {
1631            profile,
1632            provider_endpoint,
1633            provider_pid,
1634            idle_ttl_seconds,
1635            no_probe,
1636            pre_used_mib,
1637            post_used_mib,
1638            tolerance_mib,
1639            strict,
1640            ..
1641        } => {
1642            let profile = tsift_local_model::profile_by_id(&profile)
1643                .with_context(|| format!("unknown local model profile {profile:?}"))?;
1644            let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1645            let post_probe =
1646                lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1647            let report = tsift_local_model::build_lifecycle_report(
1648                profile,
1649                pre_probe,
1650                post_probe,
1651                provider_endpoint,
1652                provider_pid,
1653                idle_ttl_seconds,
1654                tolerance_mib,
1655            );
1656            if output.json_output {
1657                if output.pretty {
1658                    println!("{}", serde_json::to_string_pretty(&report)?);
1659                } else {
1660                    println!("{}", serde_json::to_string(&report)?);
1661                }
1662            } else {
1663                print!("{}", tsift_local_model::format_lifecycle_human(&report));
1664            }
1665            if strict && !report.cleanup.cleanup_proven {
1666                bail!(
1667                    "local model VRAM cleanup was not proven: {}",
1668                    report.cleanup.reason
1669                );
1670            }
1671            Ok(())
1672        }
1673        LocalModelCommand::Lease { command } => cmd_local_model_lease(command, output),
1674        LocalModelCommand::Resolve {
1675            profile,
1676            role,
1677            no_probe,
1678            ..
1679        } => {
1680            let preference_value = profile.as_deref();
1681            let preference = tsift_local_model::ProfilePreference::from_cli(preference_value);
1682            let probe = if no_probe {
1683                tsift_local_model::GpuProbe::unavailable("gpu probe skipped")
1684            } else {
1685                tsift_local_model::probe_nvidia_smi()
1686            };
1687            let resolution = tsift_local_model::resolve_profile_preference(
1688                &preference,
1689                role.to_model_role(),
1690                &probe,
1691            );
1692            if output.json_output {
1693                if output.pretty {
1694                    println!("{}", serde_json::to_string_pretty(&resolution)?);
1695                } else {
1696                    println!("{}", serde_json::to_string(&resolution)?);
1697                }
1698            } else {
1699                println!(
1700                    "preference: {} | role: {:?}",
1701                    preference.describe(),
1702                    role.to_model_role()
1703                );
1704                println!(
1705                    "selected: {} ({})",
1706                    resolution.profile.id, resolution.profile.label
1707                );
1708                println!("selectable: {}", resolution.selectable);
1709                println!("source: {:?}", resolution.source);
1710                println!("reason: {}", resolution.reason);
1711            }
1712            Ok(())
1713        }
1714        LocalModelCommand::Swap {
1715            from,
1716            to,
1717            provider_endpoint,
1718            provider_pid,
1719            idle_ttl_seconds,
1720            no_probe,
1721            pre_used_mib,
1722            post_used_mib,
1723            tolerance_mib,
1724            strict,
1725            ..
1726        } => {
1727            let from_profile = tsift_local_model::profile_by_id(&from)
1728                .with_context(|| format!("unknown source local model profile {from:?}"))?;
1729            let to_profile = tsift_local_model::profile_by_id(&to)
1730                .with_context(|| format!("unknown target local model profile {to:?}"))?;
1731            let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1732            let post_probe =
1733                lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1734            let report = tsift_local_model::build_swap_report(
1735                from_profile,
1736                to_profile,
1737                pre_probe,
1738                post_probe,
1739                provider_endpoint,
1740                provider_pid,
1741                idle_ttl_seconds,
1742                tolerance_mib,
1743            );
1744            if output.json_output {
1745                if output.pretty {
1746                    println!("{}", serde_json::to_string_pretty(&report)?);
1747                } else {
1748                    println!("{}", serde_json::to_string(&report)?);
1749                }
1750            } else {
1751                println!(
1752                    "swap: {} -> {} | status: {:?}",
1753                    report.from_profile_id, report.to_profile_id, report.swap_status
1754                );
1755                println!(
1756                    "unload cleanup: {:?} ({})",
1757                    report.unload.cleanup.status, report.unload.cleanup.reason
1758                );
1759                println!(
1760                    "target resolution: {:?} -> {} (selectable: {})",
1761                    report.target_resolution.source,
1762                    report.target_resolution.profile.id,
1763                    report.target_resolution.selectable
1764                );
1765                for note in &report.notes {
1766                    println!("note: {note}");
1767                }
1768            }
1769            if strict {
1770                match report.swap_status {
1771                    tsift_local_model::SwapStatus::UnloadNotProven => {
1772                        bail!(
1773                            "swap blocked: source unload cleanup was not proven ({})",
1774                            report.unload.cleanup.reason
1775                        );
1776                    }
1777                    tsift_local_model::SwapStatus::UnloadProvenTargetUnselectable => {
1778                        bail!(
1779                            "swap blocked: target {} is not selectable on the post-unload probe",
1780                            report.to_profile_id
1781                        );
1782                    }
1783                    _ => {}
1784                }
1785            }
1786            Ok(())
1787        }
1788    }
1789}
1790
1791/// Resolve a profile's Ollama model tag and POST a `keep_alive:0` unload.
1792///
1793/// Used by the reference-counted lease paths (`release --unload-on-last-release`,
1794/// `reap --unload-empty`) once a profile's live holder count reaches zero. An
1795/// unknown profile or unreachable provider degrades to a non-fatal
1796/// `UnloadActionResult` so reaping never fails on unload.
1797fn unload_profile_model(
1798    profile_id: &str,
1799    host: Option<&str>,
1800) -> tsift_local_model::UnloadActionResult {
1801    let endpoint = tsift_local_model::resolve_provider_endpoint(
1802        &tsift_local_model::UnloadStrategy::OllamaKeepAliveZero,
1803        host,
1804    );
1805    match tsift_local_model::profile_by_id(profile_id) {
1806        Some(profile) => tsift_local_model::unload_model_at(&endpoint, profile.model_ref),
1807        None => tsift_local_model::unload_model_at(&endpoint, profile_id),
1808    }
1809}
1810
1811fn cmd_local_model_lease(command: LeaseCommand, output: OutputFormat) -> Result<()> {
1812    use tsift_local_model::{
1813        acquire_lease, current_unix_seconds, format_lease_show_human, lease_mode_for_profile,
1814        profile_by_id, reap_leases, release_lease, renew_lease, resolve_lease_file, show_registry,
1815    };
1816    let now = current_unix_seconds();
1817    match command {
1818        LeaseCommand::Acquire {
1819            profile,
1820            holder_pid,
1821            holder_command,
1822            idle_ttl_seconds,
1823            vram_baseline_mib,
1824            no_probe,
1825            lease_file,
1826            strict,
1827            ..
1828        } => {
1829            let profile_lookup = profile_by_id(&profile)
1830                .with_context(|| format!("unknown local model profile {profile:?}"))?;
1831            // CpuOrHash profiles bypass the registry; confirm that upfront.
1832            let _ = lease_mode_for_profile(&profile_lookup);
1833            let pid = holder_pid.unwrap_or_else(std::process::id);
1834            let baseline = match vram_baseline_mib {
1835                Some(value) => value,
1836                None => {
1837                    if no_probe {
1838                        0
1839                    } else {
1840                        let probe = tsift_local_model::probe_nvidia_smi();
1841                        probe.used_vram_mib.unwrap_or(0)
1842                    }
1843                }
1844            };
1845            let path = resolve_lease_file(lease_file.as_deref());
1846            let acquisition = acquire_lease(
1847                &profile,
1848                pid,
1849                &holder_command,
1850                baseline,
1851                idle_ttl_seconds,
1852                now,
1853                &path,
1854            )?;
1855            if output.json_output {
1856                if output.pretty {
1857                    println!("{}", serde_json::to_string_pretty(&acquisition)?);
1858                } else {
1859                    println!("{}", serde_json::to_string(&acquisition)?);
1860                }
1861            } else {
1862                println!(
1863                    "lease {} for {} (pid={}): {:?}",
1864                    match acquisition.status {
1865                        tsift_local_model::GpuLeaseAcquisitionStatus::Acquired => "acquired",
1866                        tsift_local_model::GpuLeaseAcquisitionStatus::Refreshed => "refreshed",
1867                        tsift_local_model::GpuLeaseAcquisitionStatus::ReclaimedStale => {
1868                            "reclaimed-stale"
1869                        }
1870                        tsift_local_model::GpuLeaseAcquisitionStatus::CpuOrHashBypass => {
1871                            "bypass-cpu-or-hash"
1872                        }
1873                        tsift_local_model::GpuLeaseAcquisitionStatus::Conflict => "conflicted",
1874                    },
1875                    acquisition.profile_id,
1876                    acquisition.holder_pid,
1877                    acquisition.status
1878                );
1879                if let Some(conflict) = &acquisition.conflict {
1880                    println!(
1881                        "held by pid={} cmd={} acquired {}s ago",
1882                        conflict.holder_pid,
1883                        conflict.holder_command,
1884                        now.saturating_sub(conflict.acquired_at_unix_seconds)
1885                    );
1886                }
1887                println!("registry: {}", path.display());
1888            }
1889            if strict
1890                && acquisition.status == tsift_local_model::GpuLeaseAcquisitionStatus::Conflict
1891            {
1892                bail!(
1893                    "gpu lease for {profile:?} is held by pid={}",
1894                    acquisition
1895                        .conflict
1896                        .map(|conflict| conflict.holder_pid.to_string())
1897                        .unwrap_or_else(|| "unknown".to_string())
1898                );
1899            }
1900            Ok(())
1901        }
1902        LeaseCommand::Release {
1903            profile,
1904            holder_pid,
1905            lease_file,
1906            unload_on_last_release,
1907            host,
1908            ..
1909        } => {
1910            let pid = holder_pid.unwrap_or_else(std::process::id);
1911            let path = resolve_lease_file(lease_file.as_deref());
1912            let release = release_lease(&profile, pid, now, &path)?;
1913            // Reference-counted unload: when this release drops the live holder
1914            // count to zero, the model is no longer referenced by any session.
1915            let unloaded = if unload_on_last_release
1916                && release.outcome == tsift_local_model::GpuLeaseReleaseOutcome::Released
1917                && release.remaining_holders == 0
1918            {
1919                Some(unload_profile_model(&profile, host.as_deref()))
1920            } else {
1921                None
1922            };
1923            if output.json_output {
1924                let payload = serde_json::json!({
1925                    "release": release,
1926                    "unloaded": unloaded,
1927                });
1928                if output.pretty {
1929                    println!("{}", serde_json::to_string_pretty(&payload)?);
1930                } else {
1931                    println!("{}", serde_json::to_string(&payload)?);
1932                }
1933            } else {
1934                println!(
1935                    "release {} for {} (pid={}): {:?} (remaining holders: {})",
1936                    match release.outcome {
1937                        tsift_local_model::GpuLeaseReleaseOutcome::Released => "ok",
1938                        tsift_local_model::GpuLeaseReleaseOutcome::NotHeld => "not-held",
1939                        tsift_local_model::GpuLeaseReleaseOutcome::ProfileAbsent => "absent",
1940                    },
1941                    release.profile_id,
1942                    release.holder_pid,
1943                    release.outcome,
1944                    release.remaining_holders
1945                );
1946                if let Some(result) = &unloaded {
1947                    println!("unloaded {} (last reference released): {}", profile, result.outcome);
1948                }
1949                println!("registry: {}", path.display());
1950            }
1951            Ok(())
1952        }
1953        LeaseCommand::Renew {
1954            profile,
1955            holder_pid,
1956            lease_file,
1957            ..
1958        } => {
1959            let pid = holder_pid.unwrap_or_else(std::process::id);
1960            let path = resolve_lease_file(lease_file.as_deref());
1961            let renew = renew_lease(&profile, pid, now, &path)?;
1962            if output.json_output {
1963                if output.pretty {
1964                    println!("{}", serde_json::to_string_pretty(&renew)?);
1965                } else {
1966                    println!("{}", serde_json::to_string(&renew)?);
1967                }
1968            } else {
1969                println!(
1970                    "renew {} (pid={}): {:?}",
1971                    renew.profile_id, renew.holder_pid, renew.outcome
1972                );
1973                println!("registry: {}", path.display());
1974            }
1975            Ok(())
1976        }
1977        LeaseCommand::Reap {
1978            lease_file,
1979            unload_empty,
1980            host,
1981            ..
1982        } => {
1983            let path = resolve_lease_file(lease_file.as_deref());
1984            let reap = reap_leases(now, &path)?;
1985            // Reference-counted unload for every profile whose last reference
1986            // was reclaimed (a crashed session left a dead-pid holder).
1987            let unloaded: Vec<_> = if unload_empty {
1988                reap.emptied_profiles
1989                    .iter()
1990                    .filter_map(|profile_id| {
1991                        profile_by_id(profile_id).map(|profile| {
1992                            serde_json::json!({
1993                                "profile": profile_id,
1994                                "outcome": unload_profile_model(profile_id, host.as_deref()).outcome,
1995                                "model": profile.model_ref,
1996                            })
1997                        })
1998                    })
1999                    .collect()
2000            } else {
2001                Vec::new()
2002            };
2003            if output.json_output {
2004                let payload = serde_json::json!({
2005                    "reap": reap,
2006                    "unloaded": unloaded,
2007                });
2008                if output.pretty {
2009                    println!("{}", serde_json::to_string_pretty(&payload)?);
2010                } else {
2011                    println!("{}", serde_json::to_string(&payload)?);
2012                }
2013            } else {
2014                println!(
2015                    "reaped {} stale holder(s); {} profile(s) dropped to zero references",
2016                    reap.reclaimed.len(),
2017                    reap.emptied_profiles.len()
2018                );
2019                for profile_id in &reap.emptied_profiles {
2020                    println!("  emptied: {profile_id}");
2021                }
2022                if !unloaded.is_empty() {
2023                    println!("unloaded {} unreferenced model(s)", unloaded.len());
2024                }
2025                println!("registry: {}", path.display());
2026            }
2027            Ok(())
2028        }
2029        LeaseCommand::Show {
2030            lease_file,
2031            include_stale,
2032            ..
2033        } => {
2034            let path = resolve_lease_file(lease_file.as_deref());
2035            let registry = show_registry(&path, now, include_stale)?;
2036            if output.json_output {
2037                if output.pretty {
2038                    println!("{}", serde_json::to_string_pretty(&registry)?);
2039                } else {
2040                    println!("{}", serde_json::to_string(&registry)?);
2041                }
2042            } else {
2043                print!("{}", format_lease_show_human(&registry, now));
2044            }
2045            println!("registry: {}", path.display());
2046            Ok(())
2047        }
2048    }
2049}
2050
2051fn lifecycle_probe(
2052    no_probe: bool,
2053    synthetic_used_mib: Option<u64>,
2054    skipped_reason: &str,
2055) -> tsift_local_model::GpuProbe {
2056    if let Some(used_mib) = synthetic_used_mib {
2057        return tsift_local_model::GpuProbe::synthetic_vram(used_mib);
2058    }
2059    if no_probe {
2060        return tsift_local_model::GpuProbe::unavailable(skipped_reason);
2061    }
2062    tsift_local_model::probe_nvidia_smi()
2063}
2064
2065/// Classify a task description into a model tier.
2066/// Returns (tier_name, model_id).
2067pub fn classify_task(task: &str) -> (&'static str, &'static str) {
2068    let lower = task.to_lowercase();
2069    // Architecture/design signals → opus
2070    for signal in &[
2071        "architect",
2072        "architecture",
2073        "design",
2074        "plan",
2075        "strateg",
2076        "analy",
2077        "review",
2078        "evaluate",
2079        "assess",
2080    ] {
2081        if lower.contains(signal) {
2082            return ("opus", "claude-opus-4-6");
2083        }
2084    }
2085    // Edit/write signals → sonnet
2086    for signal in &[
2087        "edit",
2088        "write",
2089        "fix",
2090        "change",
2091        "update",
2092        "create",
2093        "add ",
2094        "remove",
2095        "delete",
2096        "modify",
2097        "refactor",
2098        "implement",
2099        "build",
2100    ] {
2101        if lower.contains(signal) {
2102            return ("sonnet", "claude-sonnet-4-6");
2103        }
2104    }
2105    // Default: search/lookup → haiku
2106    ("haiku", "claude-haiku-4-5-20251001")
2107}
2108
2109#[cfg(test)]
2110fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
2111    to_json_schema(val, pretty, terse, false, false)
2112}
2113
2114/// Add top-level `tagpath_index_stale: true` + `tagpath_stale_reason: <reason>`
2115/// fields to a JSON response when the tagpath adapter reported any helper
2116/// going stale. JSON consumers (`tsift --envelope` / `--json` callers) can
2117/// then act on the same condition the stderr `tagpath_index_stale: …` log
2118/// already surfaces without parsing logs. No-op when `stale=false` or when
2119/// `value` is not a JSON object.
2120pub(crate) fn inject_tagpath_stale_into_json(
2121    value: &mut serde_json::Value,
2122    stale: bool,
2123    reason: Option<&str>,
2124) {
2125    if !stale {
2126        return;
2127    }
2128    if let Some(obj) = value.as_object_mut() {
2129        obj.insert(
2130            "tagpath_index_stale".to_string(),
2131            serde_json::Value::Bool(true),
2132        );
2133        if let Some(reason) = reason {
2134            obj.insert(
2135                "tagpath_stale_reason".to_string(),
2136                serde_json::Value::String(reason.to_string()),
2137            );
2138        }
2139    }
2140}
2141
2142pub(crate) fn to_json_schema<T: serde::Serialize>(
2143    val: &T,
2144    pretty: bool,
2145    terse: bool,
2146    ultra_terse: bool,
2147    schema: bool,
2148) -> anyhow::Result<String> {
2149    if terse || schema {
2150        let value = serde_json::to_value(val)?;
2151        let mut transformed = if terse { terse_transform(value) } else { value };
2152        if ultra_terse {
2153            transformed = ultra_terse_transform(transformed);
2154            transformed = edge_index_transform(transformed);
2155        }
2156        if schema {
2157            transformed = schema_transform(transformed);
2158        }
2159        if terse {
2160            let terse_schema = terse_schema_for(&transformed);
2161            let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
2162            if pretty {
2163                Ok(serde_json::to_string_pretty(&wrapped)?)
2164            } else {
2165                Ok(serde_json::to_string(&wrapped)?)
2166            }
2167        } else if pretty {
2168            Ok(serde_json::to_string_pretty(&transformed)?)
2169        } else {
2170            Ok(serde_json::to_string(&transformed)?)
2171        }
2172    } else if pretty {
2173        Ok(serde_json::to_string_pretty(val)?)
2174    } else {
2175        Ok(serde_json::to_string(val)?)
2176    }
2177}
2178
2179pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
2180    ToolEnvelopeMetric {
2181        label: label.to_string(),
2182        value: value.to_string(),
2183    }
2184}
2185
2186pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
2187    let mut seen = HashSet::new();
2188    let mut deduped = Vec::new();
2189    for value in values {
2190        if seen.insert(value.clone()) {
2191            deduped.push(value);
2192        }
2193    }
2194    deduped
2195}
2196
2197pub(crate) fn print_json_or_envelope<T: Serialize>(
2198    report: &T,
2199    format: &OutputFormat,
2200    tool: &str,
2201    view: &str,
2202    summary: ToolEnvelopeSummary,
2203    truncated: bool,
2204    follow_up: Vec<String>,
2205) -> Result<()> {
2206    if format.envelope {
2207        let schema = format.schema || tool == "source-read";
2208        let envelope = ToolEnvelope {
2209            tool,
2210            view,
2211            summary,
2212            truncated,
2213            follow_up: dedupe_preserve_order(follow_up),
2214            report,
2215        };
2216        println!(
2217            "{}",
2218            to_json_schema(
2219                &envelope,
2220                format.pretty,
2221                format.terse,
2222                format.ultra_terse,
2223                schema
2224            )?
2225        );
2226    } else {
2227        println!(
2228            "{}",
2229            to_json_schema(
2230                report,
2231                format.pretty,
2232                format.terse,
2233                format.ultra_terse,
2234                format.schema
2235            )?
2236        );
2237    }
2238    Ok(())
2239}
2240
2241pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
2242    bytes.div_ceil(4)
2243}
2244
2245fn cmd_token_gate(command: cli::TokenGateCommand, format: OutputFormat) -> Result<()> {
2246    match command {
2247        cli::TokenGateCommand::Sample {
2248            surface,
2249            path,
2250            scope,
2251            target,
2252            depth,
2253            sample_index,
2254            json: _,
2255        } => cmd_token_gate_sample(
2256            &surface,
2257            &path,
2258            scope.as_deref(),
2259            target.as_deref(),
2260            depth,
2261            sample_index,
2262        ),
2263        cli::TokenGateCommand::Evaluate {
2264            history,
2265            allowed_regression_percent,
2266            json: _,
2267        } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
2268    }
2269}
2270
2271fn cmd_token_gate_sample(
2272    surface: &str,
2273    path: &Path,
2274    scope: Option<&str>,
2275    target: Option<&str>,
2276    depth: usize,
2277    sample_index: usize,
2278) -> Result<()> {
2279    if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
2280        bail!(
2281            "unknown surface `{}`; expected one of: {}",
2282            surface,
2283            token_gate::TOKEN_GATE_SURFACES.join(", ")
2284        );
2285    }
2286
2287    let path_str = path.to_string_lossy().to_string();
2288    let tsift_bin = std::env::current_exe()?;
2289
2290    let args: Vec<String> = match surface {
2291        "context_pack" => vec!["context-pack".to_string(), "--json".to_string(), path_str],
2292        "session_review_next_context" => vec![
2293            "session-review".to_string(),
2294            "--json".to_string(),
2295            "--next-context".to_string(),
2296            path_str,
2297        ],
2298        "graph_db_evidence" => {
2299            let tgt = target.unwrap_or("default").to_string();
2300            vec![
2301                "graph-db".to_string(),
2302                "--json".to_string(),
2303                "--path".to_string(),
2304                path_str,
2305                "evidence".to_string(),
2306                tgt,
2307                "--depth".to_string(),
2308                depth.to_string(),
2309            ]
2310        }
2311        "conflict_matrix" => {
2312            let tgt = target.unwrap_or("default").to_string();
2313            let mut a = vec![
2314                "conflict-matrix".to_string(),
2315                "--json".to_string(),
2316                "--path".to_string(),
2317                path_str,
2318                "--depth".to_string(),
2319                depth.to_string(),
2320            ];
2321            if let Some(s) = scope {
2322                a.push("--scope".to_string());
2323                a.push(s.to_string());
2324            }
2325            a.push(tgt);
2326            a
2327        }
2328        "dispatch_trace" => {
2329            let tgt = target.unwrap_or("default").to_string();
2330            vec![
2331                "dispatch-trace".to_string(),
2332                "--json".to_string(),
2333                "--path".to_string(),
2334                path_str,
2335                tgt,
2336            ]
2337        }
2338        _ => bail!("unhandled surface: {}", surface),
2339    };
2340
2341    let start = Instant::now();
2342    let child = Command::new(&tsift_bin)
2343        .args(&args)
2344        .stdout(Stdio::piped())
2345        .stderr(Stdio::piped())
2346        .env("TSIFT_QUIET", "1")
2347        .spawn();
2348    let output = match child {
2349        Ok(c) => c.wait_with_output()?,
2350        Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
2351    };
2352    let runtime_micros = start.elapsed().as_micros() as f64;
2353
2354    let stdout = String::from_utf8_lossy(&output.stdout);
2355    let envelope_bytes = stdout.trim().len() as f64;
2356    let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
2357
2358    let cache_hit_rate_percent = 0.0;
2359    let raw_read_avoidance = 0.0;
2360    let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
2361
2362    let timestamp = iso_timestamp_now();
2363    let id = format!(
2364        "{surface}-baseline-{}-sample-{sample_index}",
2365        &timestamp[..10]
2366    );
2367    let label = format!(
2368        "token-gate baseline {surface} sample {sample_index} for {}",
2369        path.display()
2370    );
2371
2372    let mut metrics = BTreeMap::new();
2373    metrics.insert("prompt_tokens".to_string(), prompt_tokens);
2374    metrics.insert("envelope_bytes".to_string(), envelope_bytes);
2375    metrics.insert("runtime_micros".to_string(), runtime_micros);
2376    metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
2377    metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
2378    metrics.insert("useful_hit_density".to_string(), useful_hit_density);
2379
2380    let sample = token_gate::TokenGateSample {
2381        label,
2382        id,
2383        timestamp: Some(timestamp),
2384        surface: surface.to_string(),
2385        metrics,
2386    };
2387
2388    println!("{}", serde_json::to_string_pretty(&sample)?);
2389    Ok(())
2390}
2391
2392fn cmd_token_gate_evaluate(
2393    history_path: Option<&Path>,
2394    allowed_regression_percent: f64,
2395    format: &OutputFormat,
2396) -> Result<()> {
2397    let history_path = history_path.map(PathBuf::from).unwrap_or_else(|| {
2398        let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2399        p.push("../../fixtures/token-gate-history.json");
2400        p
2401    });
2402
2403    let raw = std::fs::read_to_string(&history_path).with_context(|| {
2404        format!(
2405            "failed to read token gate history: {}",
2406            history_path.display()
2407        )
2408    })?;
2409    let samples = token_gate::parse_token_history(&raw)?;
2410    let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
2411
2412    if format.json_output {
2413        println!(
2414            "{}",
2415            to_json_schema(&report, format.pretty, format.terse, false, format.schema)?
2416        );
2417    } else {
2418        println!("Token Gate Report");
2419        println!("  min_samples: {}", report.min_samples);
2420        println!(
2421            "  allowed_regression: {:.1}%",
2422            report.allowed_regression_percent
2423        );
2424        println!("  decision: {:?}", report.decision);
2425        for eval in &report.surface_evaluations {
2426            println!(
2427                "  {} ({} samples): {:?}",
2428                eval.display_name, eval.sample_count, eval.verdict
2429            );
2430            for me in &eval.metric_evaluations {
2431                println!("    {} ({:?}): {}", me.metric, me.direction, me.diagnostic);
2432            }
2433        }
2434        for d in &report.diagnostics {
2435            println!("  ! {}", d);
2436        }
2437    }
2438    Ok(())
2439}
2440
2441fn iso_timestamp_now() -> String {
2442    let dur = SystemTime::now()
2443        .duration_since(UNIX_EPOCH)
2444        .unwrap_or_default();
2445    let total_secs = dur.as_secs();
2446    let days_since_epoch = total_secs / 86400;
2447    let (year, month, day) = days_to_ymd(days_since_epoch);
2448    let time_of_day = total_secs % 86400;
2449    let hour = (time_of_day / 3600) as u8;
2450    let minute = ((time_of_day % 3600) / 60) as u8;
2451    let second = (time_of_day % 60) as u8;
2452    format!(
2453        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
2454        year, month, day, hour, minute, second
2455    )
2456}
2457
2458fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
2459    let mut year = 1970u64;
2460    loop {
2461        let days_in_year = if is_leap(year) { 366 } else { 365 };
2462        if days < days_in_year {
2463            break;
2464        }
2465        days -= days_in_year;
2466        year += 1;
2467    }
2468    let leap = is_leap(year);
2469    let month_days: [u8; 12] = if leap {
2470        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2471    } else {
2472        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2473    };
2474    let mut month: u8 = 1;
2475    for &md in &month_days {
2476        if days < md as u64 {
2477            break;
2478        }
2479        days -= md as u64;
2480        month += 1;
2481    }
2482    let day = days as u8 + 1;
2483    (year, month, day)
2484}
2485
2486fn is_leap(year: u64) -> bool {
2487    year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
2488}
2489
2490fn persist_transcript_artifact(
2491    root: &Path,
2492    prefix: &str,
2493    suffix: &str,
2494    key: &str,
2495    body: &str,
2496    expand: String,
2497) -> Result<TranscriptArtifactRef> {
2498    let handle = stable_handle(prefix, key);
2499    let artifacts_dir = root.join(".tsift/artifacts");
2500    fs::create_dir_all(&artifacts_dir).with_context(|| {
2501        format!(
2502            "creating transcript artifacts dir: {}",
2503            artifacts_dir.display()
2504        )
2505    })?;
2506    let file_name = format!("{handle}.{suffix}");
2507    let artifact_path = artifacts_dir.join(file_name);
2508    fs::write(&artifact_path, body)
2509        .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
2510    let rel_path = relativize_pathbuf(&artifact_path, root);
2511    Ok(TranscriptArtifactRef {
2512        handle,
2513        path: rel_path.display().to_string(),
2514        bytes: body.len(),
2515        lines: body.lines().count(),
2516        expand,
2517    })
2518}
2519
2520fn terse_key(key: &str) -> &str {
2521    match key {
2522        "name" => "n",
2523        "kind" => "k",
2524        "file" => "f",
2525        "line" => "l",
2526        "path" => "p",
2527        "from" => "fr",
2528        "type" => "ty",
2529        "text" => "tx",
2530        "new" => "nw",
2531        "run" => "r",
2532        "use" => "u",
2533        "score" => "sc",
2534        "language" => "la",
2535        "status" => "st",
2536        "state" => "stt",
2537        "error" => "err",
2538        "errors" => "ers",
2539        "hops" => "hp",
2540        "tags" => "tg",
2541        "model" => "ml",
2542        "skill" => "sk",
2543        "count" => "ct",
2544        "total" => "tot",
2545        "column" => "col",
2546        "description" => "dsc",
2547        "end_line" => "el",
2548        "signature" => "sig",
2549        "parent_module" => "pm",
2550        "visibility" => "vis",
2551        "match_type" => "mt",
2552        "caller_file" => "cf",
2553        "caller_name" => "cn",
2554        "caller_line" => "cl",
2555        "callee_name" => "en",
2556        "call_site_line" => "csl",
2557        "members" => "m",
2558        "refs" => "refs",
2559        "role" => "rl",
2560        "peer" => "pr",
2561        "modularity" => "q",
2562        "modularity_contribution" => "mc",
2563        "iterations" => "it",
2564        "node_count" => "nc",
2565        "edge_count" => "ec",
2566        "community_count" => "cc",
2567        "communities" => "cms",
2568        "community" => "cm",
2569        "community_diagnostics" => "cd",
2570        "cache_hit" => "cah",
2571        "tagpath_state" => "tps",
2572        "tagpath_stale_reason" => "tsr",
2573        "annotated_community_count" => "acc",
2574        "annotated_member_count" => "amc",
2575        "ambiguous_member_count" => "ambc",
2576        "ambiguous_members" => "amb",
2577        "candidate_count" => "cand",
2578        "tagpath_candidate_count" => "tcand",
2579        "evidence" => "ev",
2580        "chosen_file" => "chf",
2581        "symbol" => "s",
2582        "symbols" => "sy",
2583        "definitions" => "df",
2584        "callers" => "crs",
2585        "callees" => "ces",
2586        "total_tracked" => "tt",
2587        "modified" => "md",
2588        "deleted" => "dl",
2589        "unchanged" => "uc",
2590        "changes" => "ch",
2591        "prune_stats" => "ps",
2592        "hits" => "h",
2593        "rank" => "rk",
2594        "snippet" => "sn",
2595        "confidence" => "co",
2596        "index" => "ix",
2597        "summaries" => "sms",
2598        "recommendations" => "rec",
2599        "total_files" => "tf",
2600        "stale_files" => "sf",
2601        "last_indexed_secs_ago" => "age",
2602        "cached_files" => "caf",
2603        "total_indexed_files" => "tif",
2604        "coverage_pct" => "cov",
2605        "symbol_name" => "syn",
2606        "file_path" => "fp",
2607        "content_hash" => "hsh",
2608        "summary" => "sum",
2609        "tool" => "tl",
2610        "view" => "vw",
2611        "truncated" => "tr",
2612        "follow_up" => "fu",
2613        "report" => "rp",
2614        "metrics" => "ms",
2615        "label" => "lb",
2616        "value" => "v",
2617        "command" => "cmd",
2618        "exit_code" => "xc",
2619        "success" => "ok",
2620        "artifact" => "art",
2621        "digest" => "dg",
2622        "bytes" => "bt",
2623        "lines" => "lns",
2624        "expand" => "xp",
2625        "entities" => "ent",
2626        "relationships" => "rel",
2627        "concept_labels" => "cls",
2628        "extracted_at" => "at",
2629        "tokens_input" => "ti",
2630        "tokens_output" => "tout",
2631        "total_summaries" => "ts",
2632        "stale_count" => "stc",
2633        "total_tokens_input" => "tti",
2634        "total_tokens_output" => "tto",
2635        "estimated_tokens_saved" => "ets",
2636        "files_processed" => "fps",
2637        "symbols_extracted" => "se",
2638        "skills_dir" => "sd",
2639        "healthy" => "ok",
2640        "broken" => "brk",
2641        "skills" => "sks",
2642        "manifest_diffs" => "mdf",
2643        "similar_pairs" => "sim",
2644        "usage" => "usg",
2645        "cleanup" => "cln",
2646        "has_skill_md" => "hsm",
2647        "is_symlink" => "isl",
2648        "issues" => "iss",
2649        "invocation_count" => "inv",
2650        "reasons" => "rsn",
2651        "token_estimate" => "te",
2652        "skill_a" => "sa",
2653        "skill_b" => "sb",
2654        "desc_a" => "da",
2655        "desc_b" => "db",
2656        "annotations" => "ann",
2657        "entity" => "ety",
2658        "suggestion" => "sug",
2659        "columns" => "cols",
2660        "row_count" => "rc",
2661        "notnull" => "nn",
2662        "default_value" => "dv",
2663        "replace_all" => "ra",
2664        other => other,
2665    }
2666}
2667
2668fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2669    match val {
2670        serde_json::Value::Object(map) => {
2671            let mut new_map = serde_json::Map::new();
2672            for (k, v) in map {
2673                new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2674            }
2675            serde_json::Value::Object(new_map)
2676        }
2677        serde_json::Value::Array(arr) => {
2678            serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2679        }
2680        other => other,
2681    }
2682}
2683
2684fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2685    match val {
2686        serde_json::Value::Object(mut map) => {
2687            let is_graph_node =
2688                map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2689            let is_graph_edge =
2690                map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2691            if is_graph_node || is_graph_edge {
2692                map.remove("properties");
2693                map.remove("provenance");
2694                map.remove("freshness");
2695            }
2696            if is_graph_edge && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2697                *s = abbreviate_edge_kind(s).to_string();
2698            }
2699            let is_coverage = map.contains_key("mode")
2700                && (map.contains_key("total_sector_count")
2701                    || map.contains_key("dirty_sector_count"));
2702            if is_coverage {
2703                map.remove("active_rebuild");
2704                map.remove("completed_dirty_sector_count");
2705                map.remove("mounted_sector_count");
2706                map.remove("rebuilding_sector_count");
2707                map.remove("resumed_sector_count");
2708                map.remove("reused_sector_count");
2709            }
2710            if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2711                *s = truncate_for_ultra_terse(s, 80);
2712            }
2713            if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2714                *s = truncate_for_ultra_terse(s, 80);
2715            }
2716            let new_map: serde_json::Map<String, serde_json::Value> = map
2717                .into_iter()
2718                .map(|(k, v)| (k, ultra_terse_transform(v)))
2719                .collect();
2720            serde_json::Value::Object(new_map)
2721        }
2722        serde_json::Value::Array(arr) => {
2723            serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2724        }
2725        other => other,
2726    }
2727}
2728
2729fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2730    match val {
2731        serde_json::Value::Object(mut map) => {
2732            let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2733                nodes.as_array().map(|arr| {
2734                    arr.iter()
2735                        .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2736                        .collect()
2737                })
2738            });
2739            if let Some(ref ids) = node_ids {
2740                let id_map: std::collections::HashMap<&str, usize> = ids
2741                    .iter()
2742                    .enumerate()
2743                    .map(|(i, id)| (id.as_str(), i))
2744                    .collect();
2745                if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2746                    for edge in edges.iter_mut() {
2747                        if let serde_json::Value::Object(edge_map) = edge {
2748                            if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id")
2749                            {
2750                                if let Some(&idx) = id_map.get(fid.as_str()) {
2751                                    edge_map.insert(
2752                                        "from".to_string(),
2753                                        serde_json::Value::Number(idx.into()),
2754                                    );
2755                                } else {
2756                                    edge_map.insert(
2757                                        "from_id".to_string(),
2758                                        serde_json::Value::String(fid),
2759                                    );
2760                                }
2761                            }
2762                            if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2763                                if let Some(&idx) = id_map.get(tid.as_str()) {
2764                                    edge_map.insert(
2765                                        "to".to_string(),
2766                                        serde_json::Value::Number(idx.into()),
2767                                    );
2768                                } else {
2769                                    edge_map.insert(
2770                                        "to_id".to_string(),
2771                                        serde_json::Value::String(tid),
2772                                    );
2773                                }
2774                            }
2775                        }
2776                    }
2777                }
2778            }
2779            let new_map: serde_json::Map<String, serde_json::Value> = map
2780                .into_iter()
2781                .map(|(k, v)| (k, edge_index_transform(v)))
2782                .collect();
2783            serde_json::Value::Object(new_map)
2784        }
2785        serde_json::Value::Array(arr) => {
2786            serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2787        }
2788        other => other,
2789    }
2790}
2791
2792fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2793    if s.len() <= max_len {
2794        s.to_string()
2795    } else {
2796        let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2797        format!("{truncated}...")
2798    }
2799}
2800
2801fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2802    let mut keys = HashSet::new();
2803    collect_terse_keys(val, &mut keys);
2804    let mut schema = serde_json::Map::new();
2805    for (long, short) in TERSE_PAIRS {
2806        if keys.contains(*short) {
2807            schema.insert(
2808                short.to_string(),
2809                serde_json::Value::String(long.to_string()),
2810            );
2811        }
2812    }
2813    serde_json::Value::Object(schema)
2814}
2815
2816fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2817    match val {
2818        serde_json::Value::Object(map) => {
2819            for (k, v) in map {
2820                keys.insert(k.clone());
2821                collect_terse_keys(v, keys);
2822            }
2823        }
2824        serde_json::Value::Array(arr) => {
2825            for v in arr {
2826                collect_terse_keys(v, keys);
2827            }
2828        }
2829        _ => {}
2830    }
2831}
2832
2833fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2834    match val {
2835        serde_json::Value::Array(arr) if arr.len() >= 2 => {
2836            if let Some(cols) = homogeneous_keys(&arr) {
2837                let rows: Vec<serde_json::Value> = arr
2838                    .into_iter()
2839                    .map(|item| {
2840                        if let serde_json::Value::Object(map) = item {
2841                            let vals: Vec<serde_json::Value> = cols
2842                                .iter()
2843                                .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2844                                .collect();
2845                            serde_json::Value::Array(vals)
2846                        } else {
2847                            item
2848                        }
2849                    })
2850                    .collect();
2851                let col_vals: Vec<serde_json::Value> =
2852                    cols.into_iter().map(serde_json::Value::String).collect();
2853                serde_json::json!({"_c": col_vals, "_r": rows})
2854            } else {
2855                serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2856            }
2857        }
2858        serde_json::Value::Array(arr) => {
2859            serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2860        }
2861        serde_json::Value::Object(map) => {
2862            let new_map: serde_json::Map<String, serde_json::Value> = map
2863                .into_iter()
2864                .map(|(k, v)| (k, schema_transform(v)))
2865                .collect();
2866            serde_json::Value::Object(new_map)
2867        }
2868        other => other,
2869    }
2870}
2871
2872fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2873    let first = arr.first()?.as_object()?;
2874    let keys: Vec<String> = first.keys().cloned().collect();
2875    for item in &arr[1..] {
2876        let obj = item.as_object()?;
2877        if obj.len() != keys.len() {
2878            return None;
2879        }
2880        for k in &keys {
2881            if !obj.contains_key(k) {
2882                return None;
2883            }
2884        }
2885    }
2886    Some(keys)
2887}
2888
2889const TERSE_PAIRS: &[(&str, &str)] = &[
2890    ("name", "n"),
2891    ("kind", "k"),
2892    ("file", "f"),
2893    ("line", "l"),
2894    ("path", "p"),
2895    ("from", "fr"),
2896    ("type", "ty"),
2897    ("text", "tx"),
2898    ("new", "nw"),
2899    ("run", "r"),
2900    ("use", "u"),
2901    ("score", "sc"),
2902    ("language", "la"),
2903    ("status", "st"),
2904    ("state", "stt"),
2905    ("error", "err"),
2906    ("errors", "ers"),
2907    ("hops", "hp"),
2908    ("tags", "tg"),
2909    ("model", "ml"),
2910    ("skill", "sk"),
2911    ("count", "ct"),
2912    ("total", "tot"),
2913    ("column", "col"),
2914    ("description", "dsc"),
2915    ("end_line", "el"),
2916    ("signature", "sig"),
2917    ("parent_module", "pm"),
2918    ("visibility", "vis"),
2919    ("match_type", "mt"),
2920    ("caller_file", "cf"),
2921    ("caller_name", "cn"),
2922    ("caller_line", "cl"),
2923    ("callee_name", "en"),
2924    ("call_site_line", "csl"),
2925    ("members", "m"),
2926    ("refs", "refs"),
2927    ("role", "rl"),
2928    ("peer", "pr"),
2929    ("modularity", "q"),
2930    ("modularity_contribution", "mc"),
2931    ("iterations", "it"),
2932    ("node_count", "nc"),
2933    ("edge_count", "ec"),
2934    ("community_count", "cc"),
2935    ("communities", "cms"),
2936    ("community", "cm"),
2937    ("community_diagnostics", "cd"),
2938    ("cache_hit", "cah"),
2939    ("tagpath_state", "tps"),
2940    ("tagpath_stale_reason", "tsr"),
2941    ("annotated_community_count", "acc"),
2942    ("annotated_member_count", "amc"),
2943    ("ambiguous_member_count", "ambc"),
2944    ("ambiguous_members", "amb"),
2945    ("candidate_count", "cand"),
2946    ("tagpath_candidate_count", "tcand"),
2947    ("evidence", "ev"),
2948    ("chosen_file", "chf"),
2949    ("symbol", "s"),
2950    ("symbols", "sy"),
2951    ("definitions", "df"),
2952    ("callers", "crs"),
2953    ("callees", "ces"),
2954    ("total_tracked", "tt"),
2955    ("modified", "md"),
2956    ("deleted", "dl"),
2957    ("unchanged", "uc"),
2958    ("changes", "ch"),
2959    ("prune_stats", "ps"),
2960    ("hits", "h"),
2961    ("rank", "rk"),
2962    ("snippet", "sn"),
2963    ("confidence", "co"),
2964    ("index", "ix"),
2965    ("summaries", "sms"),
2966    ("recommendations", "rec"),
2967    ("total_files", "tf"),
2968    ("stale_files", "sf"),
2969    ("last_indexed_secs_ago", "age"),
2970    ("cached_files", "caf"),
2971    ("total_indexed_files", "tif"),
2972    ("coverage_pct", "cov"),
2973    ("symbol_name", "syn"),
2974    ("file_path", "fp"),
2975    ("content_hash", "hsh"),
2976    ("summary", "sum"),
2977    ("tool", "tl"),
2978    ("view", "vw"),
2979    ("truncated", "tr"),
2980    ("follow_up", "fu"),
2981    ("report", "rp"),
2982    ("metrics", "ms"),
2983    ("label", "lb"),
2984    ("value", "v"),
2985    ("command", "cmd"),
2986    ("exit_code", "xc"),
2987    ("success", "ok"),
2988    ("artifact", "art"),
2989    ("digest", "dg"),
2990    ("bytes", "bt"),
2991    ("lines", "lns"),
2992    ("expand", "xp"),
2993    ("entities", "ent"),
2994    ("relationships", "rel"),
2995    ("concept_labels", "cls"),
2996    ("extracted_at", "at"),
2997    ("tokens_input", "ti"),
2998    ("tokens_output", "tout"),
2999    ("total_summaries", "ts"),
3000    ("stale_count", "stc"),
3001    ("total_tokens_input", "tti"),
3002    ("total_tokens_output", "tto"),
3003    ("estimated_tokens_saved", "ets"),
3004    ("files_processed", "fps"),
3005    ("symbols_extracted", "se"),
3006    ("skills_dir", "sd"),
3007    ("healthy", "ok"),
3008    ("broken", "brk"),
3009    ("skills", "sks"),
3010    ("manifest_diffs", "mdf"),
3011    ("similar_pairs", "sim"),
3012    ("usage", "usg"),
3013    ("cleanup", "cln"),
3014    ("has_skill_md", "hsm"),
3015    ("is_symlink", "isl"),
3016    ("issues", "iss"),
3017    ("invocation_count", "inv"),
3018    ("reasons", "rsn"),
3019    ("token_estimate", "te"),
3020    ("skill_a", "sa"),
3021    ("skill_b", "sb"),
3022    ("desc_a", "da"),
3023    ("desc_b", "db"),
3024    ("annotations", "ann"),
3025    ("entity", "ety"),
3026    ("suggestion", "sug"),
3027    ("columns", "cols"),
3028    ("row_count", "rc"),
3029    ("notnull", "nn"),
3030    ("default_value", "dv"),
3031    ("replace_all", "ra"),
3032];
3033
3034pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
3035    let root_str = root.to_string_lossy();
3036    let prefix = format!("{}/", root_str.trim_end_matches('/'));
3037    path.strip_prefix(&prefix).unwrap_or(path).to_string()
3038}
3039
3040fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
3041    let canonical = path
3042        .canonicalize()
3043        .with_context(|| format!("canonicalizing {}", path.display()))?;
3044    let start = if canonical.is_dir() {
3045        canonical.clone()
3046    } else {
3047        canonical
3048            .parent()
3049            .map(Path::to_path_buf)
3050            .unwrap_or_else(|| canonical.clone())
3051    };
3052
3053    for ancestor in start.ancestors() {
3054        if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
3055            return Ok(ancestor.to_path_buf());
3056        }
3057    }
3058
3059    Ok(start)
3060}
3061
3062pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
3063    path.strip_prefix(root)
3064        .map(|p| p.to_path_buf())
3065        .unwrap_or_else(|_| path.to_path_buf())
3066}
3067
3068pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
3069    for edge in edges {
3070        edge.caller_file = relativize(&edge.caller_file, root);
3071    }
3072}
3073
3074pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
3075    for sym in symbols {
3076        sym.file = relativize(&sym.file, root);
3077    }
3078}
3079
3080pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
3081    for hit in hits {
3082        hit.file = relativize(&hit.file, root);
3083    }
3084}
3085
3086/// Which endpoint of a `StoredEdge` is the row's primary symbol — caller
3087/// (caller list) or callee (callee list).
3088#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3089pub enum EdgeSide {
3090    Caller,
3091    Callee,
3092}
3093
3094const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
3095
3096pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
3097    let root_str = root.to_string_lossy();
3098    let prefix = format!("{}/", root_str.trim_end_matches('/'));
3099    relativize_json_inner(val, &prefix);
3100}
3101
3102fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
3103    match val {
3104        serde_json::Value::Array(arr) => {
3105            for v in arr {
3106                relativize_json_inner(v, prefix);
3107            }
3108        }
3109        serde_json::Value::Object(map) => {
3110            for (k, v) in map.iter_mut() {
3111                if JSON_PATH_KEYS.contains(&k.as_str())
3112                    && let serde_json::Value::String(s) = v
3113                    && let Some(rest) = s.strip_prefix(prefix)
3114                {
3115                    *s = rest.to_string();
3116                }
3117                relativize_json_inner(v, prefix);
3118            }
3119        }
3120        _ => {}
3121    }
3122}
3123
3124pub(crate) fn format_score(score: f64, compact: bool) -> String {
3125    if compact {
3126        format!("{score:.2}")
3127    } else {
3128        format!("{score:.4}")
3129    }
3130}
3131
3132pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
3133    let trimmed = input.trim();
3134    let count = trimmed.chars().count();
3135    if count <= max_chars {
3136        return trimmed.to_string();
3137    }
3138    let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
3139    format!("{prefix}...")
3140}
3141
3142pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
3143    snippet
3144        .lines()
3145        .find(|line| !line.trim().is_empty())
3146        .map(|line| truncate_for_compact(line, 100))
3147}
3148
3149pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
3150    let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
3151    if names.len() <= limit {
3152        return names.join(", ");
3153    }
3154    format!(
3155        "{} (+{} more)",
3156        names[..limit].join(", "),
3157        names.len() - limit
3158    )
3159}
3160
3161pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
3162    let mut hasher = blake3::Hasher::new();
3163    hasher.update(prefix.as_bytes());
3164    hasher.update(&[0]);
3165    hasher.update(key.as_bytes());
3166    let hex = hasher.finalize().to_hex();
3167    format!("{prefix}-{}", &hex[..10])
3168}
3169
3170#[derive(Clone, Debug, PartialEq, Eq)]
3171struct CanonicalTagFamily {
3172    canonical: String,
3173    tag_alias: String,
3174}
3175
3176fn canonical_family_from_tagpath_family(
3177    family: tagpath_family::TagFamily,
3178) -> Option<CanonicalTagFamily> {
3179    let tag_alias = if family.dimensions.is_empty() {
3180        family.tags.join("/")
3181    } else {
3182        family
3183            .dimensions
3184            .iter()
3185            .filter(|dimension| !dimension.tags.is_empty())
3186            .map(|dimension| dimension.tags.join("."))
3187            .collect::<Vec<_>>()
3188            .join("/")
3189    };
3190
3191    if tag_alias.is_empty() {
3192        None
3193    } else {
3194        Some(CanonicalTagFamily {
3195            canonical: family.canonical,
3196            tag_alias,
3197        })
3198    }
3199}
3200
3201fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
3202    let trimmed = name.trim();
3203    if trimmed.is_empty() {
3204        return None;
3205    }
3206
3207    canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
3208}
3209
3210fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
3211    let canonical = tags
3212        .split(',')
3213        .map(str::trim)
3214        .filter(|tag| !tag.is_empty())
3215        .collect::<Vec<_>>()
3216        .join("_");
3217    if canonical.is_empty() {
3218        None
3219    } else {
3220        canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
3221    }
3222}
3223
3224pub(crate) fn canonical_tag_family_from_symbol(
3225    name: &str,
3226    tags: Option<&str>,
3227) -> Option<CanonicalTagFamily> {
3228    tags.and_then(canonical_tag_family_from_tags)
3229        .or_else(|| canonical_tag_family_from_name(name))
3230}
3231
3232fn tag_alias_from_name(name: &str) -> Option<String> {
3233    canonical_tag_family_from_name(name).map(|family| family.tag_alias)
3234}
3235
3236fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
3237    canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
3238}
3239
3240pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
3241    let query = tag_alias
3242        .split(['/', '.'])
3243        .map(str::trim)
3244        .filter(|part| !part.is_empty())
3245        .collect::<Vec<_>>()
3246        .join(" ");
3247    if query.is_empty() { None } else { Some(query) }
3248}
3249
3250#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3251struct CompactOntologyRefPreview {
3252    handle: String,
3253    tag: String,
3254    path: String,
3255    #[serde(skip_serializing_if = "Option::is_none")]
3256    title: Option<String>,
3257    #[serde(skip_serializing_if = "Option::is_none")]
3258    domain: Option<String>,
3259}
3260
3261#[derive(Clone, Debug)]
3262struct TagOntologyPreviewContext {
3263    project_root: PathBuf,
3264    tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
3265}
3266
3267#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3268struct CompactSymbolRefPreview {
3269    handle: String,
3270    name: String,
3271    #[serde(skip_serializing_if = "Option::is_none")]
3272    tag_alias: Option<String>,
3273    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3274    ontology_refs: Vec<CompactOntologyRefPreview>,
3275}
3276
3277fn build_compact_symbol_ref(
3278    prefix: &str,
3279    key: &str,
3280    name: &str,
3281    tags: Option<&str>,
3282    max_bytes: usize,
3283) -> CompactSymbolRefPreview {
3284    build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
3285}
3286
3287fn build_compact_symbol_ref_with_ontology(
3288    prefix: &str,
3289    key: &str,
3290    name: &str,
3291    tags: Option<&str>,
3292    max_bytes: usize,
3293    ontology: Option<&TagOntologyPreviewContext>,
3294) -> CompactSymbolRefPreview {
3295    let tag_alias = tag_alias_from_tags(name, tags);
3296    let ontology_refs = tag_alias
3297        .as_deref()
3298        .map(|alias| ontology_refs_for_alias(ontology, alias))
3299        .unwrap_or_default();
3300    CompactSymbolRefPreview {
3301        handle: stable_handle(prefix, key),
3302        name: truncate_for_budget(name, max_bytes),
3303        tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
3304        ontology_refs,
3305    }
3306}
3307
3308fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
3309    let report = tagpath_ontology::load_project(root).ok()?;
3310    if report.tags.is_empty() {
3311        return None;
3312    }
3313    Some(TagOntologyPreviewContext {
3314        project_root: report.project_path,
3315        tags: report
3316            .tags
3317            .into_iter()
3318            .map(|tag| (tag.tag.clone(), tag))
3319            .collect(),
3320    })
3321}
3322
3323fn ontology_refs_for_alias(
3324    ontology: Option<&TagOntologyPreviewContext>,
3325    alias: &str,
3326) -> Vec<CompactOntologyRefPreview> {
3327    let Some(ontology) = ontology else {
3328        return Vec::new();
3329    };
3330    let mut seen = BTreeSet::new();
3331    alias
3332        .split('/')
3333        .flat_map(|part| part.split('.'))
3334        .map(str::trim)
3335        .filter(|tag| !tag.is_empty())
3336        .filter_map(|tag| {
3337            let key = tag.to_ascii_lowercase();
3338            if !seen.insert(key.clone()) {
3339                return None;
3340            }
3341            let ontology_tag = ontology.tags.get(&key)?;
3342            let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
3343            Some(CompactOntologyRefPreview {
3344                handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
3345                tag: ontology_tag.tag.clone(),
3346                path,
3347                title: ontology_tag.title.clone(),
3348                domain: ontology_tag.domain.clone(),
3349            })
3350        })
3351        .collect()
3352}
3353
3354fn relativize_ontology_path(path: &Path, root: &Path) -> String {
3355    path.strip_prefix(root)
3356        .unwrap_or(path)
3357        .to_string_lossy()
3358        .replace('\\', "/")
3359}
3360
3361fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
3362    match tag_alias {
3363        Some(alias) => format!("{handle} {name} tag:{alias}"),
3364        None => format!("{handle} {name}"),
3365    }
3366}
3367
3368fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
3369    match summary.tag_alias.as_deref() {
3370        Some(alias) => format!(
3371            "{} {} tag:{} expand:{}",
3372            summary.handle, summary.symbol, alias, summary.expand
3373        ),
3374        None => format!(
3375            "{} {} expand:{}",
3376            summary.handle, summary.symbol, summary.expand
3377        ),
3378    }
3379}
3380
3381fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
3382    match symbol.tag_alias.as_deref() {
3383        Some(alias) => format!("{}@{}", symbol.handle, alias),
3384        None => format!("{}@{}", symbol.handle, symbol.name),
3385    }
3386}
3387
3388pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
3389    let trimmed = input.trim();
3390    if trimmed.len() <= max_bytes {
3391        return trimmed.to_string();
3392    }
3393    if max_bytes <= 3 {
3394        return ".".repeat(max_bytes);
3395    }
3396
3397    let mut end = 0usize;
3398    for (idx, ch) in trimmed.char_indices() {
3399        let next = idx + ch.len_utf8();
3400        if next > max_bytes.saturating_sub(3) {
3401            break;
3402        }
3403        end = next;
3404    }
3405
3406    if end == 0 {
3407        "...".to_string()
3408    } else {
3409        format!("{}...", &trimmed[..end])
3410    }
3411}
3412
3413struct TokenCappedPreview {
3414    preview: Vec<SourceLinePreview>,
3415    capped_end: usize,
3416    was_capped: bool,
3417}
3418
3419fn build_token_capped_preview(
3420    all_lines: &[&str],
3421    start: usize,
3422    end: usize,
3423    max_bytes: usize,
3424    token_cap: usize,
3425) -> TokenCappedPreview {
3426    let mut preview = Vec::new();
3427    let mut accumulated_tokens = 0usize;
3428    let mut capped_end = end;
3429    let mut was_capped = false;
3430
3431    for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
3432        let truncated = truncate_for_budget(line, max_bytes);
3433        let line_tokens = estimated_tokens_from_bytes(truncated.len());
3434        if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
3435            capped_end = start + idx - 1;
3436            was_capped = true;
3437            break;
3438        }
3439        accumulated_tokens += line_tokens;
3440        preview.push(SourceLinePreview {
3441            line: start + idx,
3442            text: truncated,
3443        });
3444    }
3445
3446    TokenCappedPreview {
3447        preview,
3448        capped_end,
3449        was_capped,
3450    }
3451}
3452
3453pub(crate) fn abbreviate_kind(kind: &str) -> &str {
3454    match kind {
3455        "function" => "fn",
3456        "method" => "meth",
3457        "module" | "mod" => "mod",
3458        "struct" => "struct",
3459        "trait" => "trait",
3460        "impl" => "impl",
3461        "class" => "cls",
3462        "interface" => "iface",
3463        "type_alias" => "type",
3464        "data_class" => "data_cls",
3465        "sealed_class" => "sealed_cls",
3466        "enum_class" => "enum_cls",
3467        "companion_object" => "comp_obj",
3468        "object" => "obj",
3469        "heading" => "h",
3470        "code_block" => "code",
3471        "alias" => "alias",
3472        other => other,
3473    }
3474}
3475
3476pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
3477    match kind {
3478        "calls" => "c",
3479        "defines" => "d",
3480        "contains" => "ct",
3481        "imports" => "i",
3482        "mentions" => "m",
3483        "mentions_concept" => "mc",
3484        "mentions_entity" => "me",
3485        "semantic_relation" => "sr",
3486        "belongs_to" => "bt",
3487        "scopes_context" => "sctx",
3488        "scopes_source" => "ssrc",
3489        "requests_context" => "rctx",
3490        "explains_result" => "er",
3491        "tagged_concept" => "tc",
3492        "tagged_entity" => "te",
3493        "related_concept" => "relc",
3494        "handled_by" => "hb",
3495        "defines_route" => "dr",
3496        "handles_route" => "hr",
3497        "targets" => "tgt",
3498        "has_vector_handle" => "hv",
3499        "parent" => "p",
3500        "child" => "ch",
3501        "uses" => "u",
3502        "projects_source" => "psrc",
3503        "records_memory_source" => "rms",
3504        "records_memory_event" => "rme",
3505        "has_ast_span" => "ha",
3506        "represents_symbol" => "rs",
3507        "contains_embedded_symbol" => "ces",
3508        "embedded_in_fence" => "ef",
3509        "contains_markdown_block" => "cmb",
3510        "contains_embedded_code" => "cec",
3511        "enclosing_module" => "em",
3512        "enclosing_section" => "es",
3513        "previous_sibling" => "psib",
3514        "next_sibling" => "nsib",
3515        "explicit_depends_on" => "edo",
3516        "worker_result_follow_up" => "wrf",
3517        "shared_resource" => "shr",
3518        "community_member" => "cm",
3519        other => other,
3520    }
3521}
3522
3523pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
3524    match mt {
3525        "exact_name" => "exact",
3526        "all_tags" => "all_tags",
3527        "partial_tags" => "partial",
3528        other => other,
3529    }
3530}
3531
3532pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
3533    path.iter()
3534        .map(|n| n.name.as_str())
3535        .collect::<Vec<_>>()
3536        .join(" -> ")
3537}
3538
3539const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
3540
3541struct SearchHitGroup {
3542    path: String,
3543    first_rank: usize,
3544    top_score: f64,
3545    confidence: String,
3546    hits: usize,
3547    samples: Vec<String>,
3548}
3549
3550fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
3551    let snippet = compact_snippet(&hit.snippet)?;
3552    Some(match hit.location.as_deref() {
3553        Some(location) => format!("{location}: {snippet}"),
3554        None => snippet,
3555    })
3556}
3557
3558pub(crate) fn group_search_hits(
3559    hits: &[sift::SearchHit],
3560    root: &Path,
3561    absolute: bool,
3562) -> Vec<SearchHitGroup> {
3563    let mut positions = BTreeMap::new();
3564    let mut groups = Vec::new();
3565    for hit in hits {
3566        let path = if absolute {
3567            hit.path.clone()
3568        } else {
3569            relativize(&hit.path, root)
3570        };
3571        let entry = positions.entry(path.clone()).or_insert_with(|| {
3572            groups.push(SearchHitGroup {
3573                path: path.clone(),
3574                first_rank: hit.rank,
3575                top_score: hit.score,
3576                confidence: format!("{:?}", hit.confidence),
3577                hits: 0,
3578                samples: Vec::new(),
3579            });
3580            groups.len() - 1
3581        });
3582        let group = &mut groups[*entry];
3583        group.hits += 1;
3584        if hit.rank < group.first_rank {
3585            group.first_rank = hit.rank;
3586        }
3587        if hit.score > group.top_score {
3588            group.top_score = hit.score;
3589        }
3590        if let Some(sample) = format_search_sample(hit)
3591            && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
3592            && !group.samples.contains(&sample)
3593        {
3594            group.samples.push(sample);
3595        }
3596    }
3597    groups.sort_by_key(|group| group.first_rank);
3598    groups
3599}
3600
3601pub(crate) fn should_collapse_search_hits(
3602    hits: &[sift::SearchHit],
3603    root: &Path,
3604    absolute: bool,
3605) -> bool {
3606    let groups = group_search_hits(hits, root, absolute);
3607    let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
3608    max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
3609}
3610
3611pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
3612    let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
3613    for edge in edges {
3614        let key = edge.caller_file.as_str();
3615        let name = if use_callers {
3616            edge.caller_name.as_str()
3617        } else {
3618            edge.callee_name.as_str()
3619        };
3620        let names = grouped.entry(key).or_default();
3621        if !names.contains(&name) {
3622            names.push(name);
3623        }
3624    }
3625
3626    grouped
3627        .into_iter()
3628        .map(|(file, names)| format!("  {} ({}): {}", file, names.len(), names.join(", ")))
3629        .collect()
3630}
3631
3632pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
3633    let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
3634    for edge in edges {
3635        *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
3636    }
3637    let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
3638    max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
3639}
3640
3641fn resolve_query_index_target(
3642    root: &Path,
3643    path_hint: &Path,
3644    scope: Option<&str>,
3645) -> Result<SearchIndexTarget> {
3646    let cfg = config::Config::load(root)?;
3647    if let Some(scope_name) = scope {
3648        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3649            return Ok(SearchIndexTarget {
3650                label: format!("submodule `{}` index", scope.id),
3651                db_path: cfg.db_path_for(root, &scope.id),
3652                source_root: scope.source_root.clone(),
3653                scope_name: Some(scope.id.clone()),
3654                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3655            });
3656        }
3657        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3658            return Ok(cargo_package_index_target(root, package));
3659        }
3660        config::Config::resolve_submodule(root, scope_name)?;
3661    }
3662
3663    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3664        return Ok(SearchIndexTarget {
3665            label: format!("submodule `{}` index", scope.id),
3666            db_path: cfg.db_path_for(root, &scope.id),
3667            source_root: scope.source_root.clone(),
3668            scope_name: Some(scope.id.clone()),
3669            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3670        });
3671    }
3672
3673    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3674        return Ok(cargo_package_index_target(root, package));
3675    }
3676
3677    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3678        return Ok(SearchIndexTarget {
3679            label: format!("submodule `{}` index", scope.id),
3680            db_path: cfg.db_path_for(root, &scope.id),
3681            source_root: scope.source_root.clone(),
3682            scope_name: Some(scope.id.clone()),
3683            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3684        });
3685    }
3686
3687    let db_path = root.join(".tsift/index.db");
3688    if db_path.exists() {
3689        return Ok(SearchIndexTarget {
3690            label: "index".to_string(),
3691            db_path,
3692            source_root: root.to_path_buf(),
3693            scope_name: None,
3694            reindex_cmd: format!("tsift index {}", root.display()),
3695        });
3696    }
3697
3698    let scopes = config::Config::submodule_dirs(root)?;
3699    if scopes.is_empty() {
3700        return Ok(SearchIndexTarget {
3701            label: "index".to_string(),
3702            db_path,
3703            source_root: root.to_path_buf(),
3704            scope_name: None,
3705            reindex_cmd: format!("tsift index {}", root.display()),
3706        });
3707    }
3708
3709    let available_scopes = scopes
3710        .iter()
3711        .map(|scope| scope.id.as_str())
3712        .collect::<Vec<_>>()
3713        .join(", ");
3714    let indexed_scopes = scopes
3715        .iter()
3716        .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3717        .map(|scope| scope.id.as_str())
3718        .collect::<Vec<_>>();
3719    let indexed_label = if indexed_scopes.is_empty() {
3720        "none".to_string()
3721    } else {
3722        indexed_scopes.join(", ")
3723    };
3724
3725    bail!(
3726        "workspace root {} has no shared root index at {}. Read-only graph queries require `--scope <scope>` when the workspace is indexed into `.tsift/indexes/*/index.db`. Available scopes: {}. Indexed scopes: {}.",
3727        root.display(),
3728        db_path.display(),
3729        available_scopes,
3730        indexed_label
3731    );
3732}
3733
3734pub(crate) fn resolve_query_db_path(
3735    root: &Path,
3736    path_hint: &Path,
3737    scope: Option<&str>,
3738) -> Result<PathBuf> {
3739    Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3740}
3741
3742fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3743    let state = inspect_search_index(target)?;
3744    let Some(reason) = index_reason_for_state(state) else {
3745        return Ok(());
3746    };
3747
3748    match apply_search_index_update(root, target) {
3749        Ok(_) => {
3750            index::inspect_scope_invalidate_all();
3751            Ok(())
3752        }
3753        Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3754            eprintln!(
3755                "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3756                 Continuing with the current read-only index snapshot; graph results may lag. \
3757                 Retry `{}` after the active writer finishes for fresh graph results.",
3758                index_reason_detail(target, reason),
3759                target.reindex_cmd
3760            );
3761            Ok(())
3762        }
3763        Err(err) => Err(err),
3764    }
3765}
3766
3767pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3768    let root = lint::resolve_project_root_or_canonical_path(path)?;
3769    let target = resolve_query_index_target(&root, path, scope)?;
3770    ensure_query_index_current(&root, &target)?;
3771    let db_path = target.db_path;
3772    if !db_path.exists() {
3773        bail!(
3774            "no index found at {}. Run `tsift index` first.",
3775            db_path.display()
3776        );
3777    }
3778    index::IndexDb::open_read_only_resilient(&db_path)
3779}
3780
3781pub(crate) fn query_tagpath_root(
3782    root: &std::path::Path,
3783    path_hint: &std::path::Path,
3784    scope: Option<&str>,
3785) -> Result<PathBuf> {
3786    if let Some(scope_name) = scope {
3787        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3788            return Ok(scope.source_root);
3789        }
3790        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3791            return Ok(package.package_root);
3792        }
3793        config::Config::resolve_submodule(root, scope_name)?;
3794    }
3795    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3796        return Ok(scope.source_root);
3797    }
3798    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3799        return Ok(package.package_root);
3800    }
3801    Ok(root.to_path_buf())
3802}
3803
3804#[derive(Clone, Debug, Serialize, PartialEq)]
3805struct TraversalNode {
3806    handle: String,
3807    kind: String,
3808    label: String,
3809    #[serde(skip_serializing_if = "Option::is_none")]
3810    ref_id: Option<String>,
3811    #[serde(skip_serializing_if = "Option::is_none")]
3812    path: Option<String>,
3813    #[serde(skip_serializing_if = "Option::is_none")]
3814    line: Option<i64>,
3815    #[serde(skip_serializing_if = "Option::is_none")]
3816    detail: Option<String>,
3817    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3818    properties: BTreeMap<String, String>,
3819    expand: String,
3820}
3821
3822#[derive(Clone, Debug, Serialize, PartialEq)]
3823struct TraversalEdge {
3824    from: String,
3825    to: String,
3826    relation: String,
3827    #[serde(skip_serializing_if = "Option::is_none")]
3828    label: Option<String>,
3829    weight: usize,
3830}
3831
3832#[derive(Clone, Debug, Default)]
3833struct TraversalGraphBuild {
3834    nodes: BTreeMap<String, TraversalNode>,
3835    edges: Vec<TraversalEdge>,
3836    edge_keys: BTreeSet<(String, String, String)>,
3837    warnings: Vec<String>,
3838}
3839
3840pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3841const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3842const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3843const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3844const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3845    "context-pack-graph-orchestration-v1";
3846const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3847const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3848const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3849const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3850const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3851const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3852const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3853
3854#[derive(Debug, Serialize, PartialEq)]
3855struct TraversalTotals {
3856    nodes: usize,
3857    edges: usize,
3858}
3859
3860#[derive(Debug, Serialize, PartialEq)]
3861struct TraversalPathReport {
3862    from: TraversalNode,
3863    to: TraversalNode,
3864    hops: usize,
3865    nodes: Vec<TraversalNode>,
3866    edges: Vec<TraversalEdge>,
3867}
3868
3869#[derive(Debug, Serialize, PartialEq)]
3870struct TraversalRecommendation {
3871    handle: String,
3872    kind: String,
3873    label: String,
3874    reason: String,
3875    score: usize,
3876    expand: String,
3877}
3878
3879#[derive(Debug, Serialize, PartialEq)]
3880struct TraversalReport {
3881    root: String,
3882    #[serde(skip_serializing_if = "Option::is_none")]
3883    scope: Option<String>,
3884    mode: String,
3885    totals: TraversalTotals,
3886    #[serde(skip_serializing_if = "Option::is_none")]
3887    query: Option<String>,
3888    #[serde(skip_serializing_if = "Option::is_none")]
3889    target: Option<String>,
3890    nodes: Vec<TraversalNode>,
3891    edges: Vec<TraversalEdge>,
3892    #[serde(skip_serializing_if = "Option::is_none")]
3893    shortest_path: Option<TraversalPathReport>,
3894    recommendations: Vec<TraversalRecommendation>,
3895    exploration: ExplorationPacket,
3896    truncated: bool,
3897    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3898    warnings: Vec<String>,
3899}
3900
3901#[derive(Debug, Serialize, PartialEq)]
3902struct SemanticRelatedReport {
3903    root: String,
3904    #[serde(skip_serializing_if = "Option::is_none")]
3905    scope: Option<String>,
3906    query: String,
3907    embedding_model: String,
3908    count: usize,
3909    items: Vec<SemanticRelatedItem>,
3910    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3911    warnings: Vec<String>,
3912}
3913
3914#[derive(Clone, Debug, Serialize, PartialEq)]
3915struct SemanticRelatedItem {
3916    handle: String,
3917    kind: String,
3918    label: String,
3919    score: f64,
3920    #[serde(skip_serializing_if = "Option::is_none")]
3921    file_path: Option<String>,
3922    #[serde(skip_serializing_if = "Option::is_none")]
3923    source_symbol: Option<String>,
3924    #[serde(skip_serializing_if = "Option::is_none")]
3925    detail: Option<String>,
3926    expand: String,
3927}
3928
3929#[derive(Clone)]
3930struct TraversalSymbolIndexEntry {
3931    handle: String,
3932    node: TraversalNode,
3933    tokens: BTreeSet<String>,
3934}
3935
3936#[derive(Clone)]
3937struct TraversalFileIndexEntry {
3938    handle: String,
3939    node: TraversalNode,
3940    tokens: BTreeSet<String>,
3941}
3942
3943#[derive(Clone)]
3944struct TraversalRouteIndexEntry {
3945    handle: String,
3946    node: TraversalNode,
3947    tokens: BTreeSet<String>,
3948}
3949
3950#[derive(Clone)]
3951struct TraversalAstSpanIndexEntry {
3952    handle: String,
3953    symbol_handle: String,
3954    file_handle: Option<String>,
3955    file: String,
3956    name: String,
3957    kind: String,
3958    language: String,
3959    node_kind: String,
3960    start_byte: usize,
3961    end_byte: usize,
3962    parent_module: Option<String>,
3963    markdown: Option<MarkdownSpanMetadata>,
3964}
3965
3966#[derive(Clone)]
3967struct TraversalMultiplicityIndexEntry {
3968    handle: String,
3969    node: TraversalNode,
3970    tokens: BTreeSet<String>,
3971}
3972
3973struct TraversalCodeLookup<'a> {
3974    symbols: &'a [TraversalSymbolIndexEntry],
3975    files: &'a [TraversalFileIndexEntry],
3976    routes: &'a [TraversalRouteIndexEntry],
3977    multiplicities: &'a [TraversalMultiplicityIndexEntry],
3978    symbol_index: HashMap<String, Vec<usize>>,
3979    file_index: HashMap<String, Vec<usize>>,
3980    route_index: HashMap<String, Vec<usize>>,
3981    multiplicity_index: HashMap<String, Vec<usize>>,
3982    file_path_index: HashMap<String, String>,
3983}
3984
3985#[derive(Clone, Debug, Serialize, PartialEq)]
3986struct ExplorationBudget {
3987    project_size: String,
3988    max_source_windows: usize,
3989    lines_per_window: usize,
3990    relationship_limit: usize,
3991}
3992
3993#[derive(Clone, Debug, Serialize, PartialEq)]
3994struct ExplorationRelation {
3995    from: String,
3996    relation: String,
3997    to: String,
3998    #[serde(skip_serializing_if = "Option::is_none")]
3999    label: Option<String>,
4000}
4001
4002#[derive(Clone, Debug, Serialize, PartialEq)]
4003struct ExplorationSourceWindow {
4004    handle: String,
4005    file: String,
4006    start: usize,
4007    end: usize,
4008    reason: String,
4009    expand: String,
4010}
4011
4012#[derive(Clone, Debug, Serialize, PartialEq)]
4013struct ExplorationWorkerContext {
4014    handle: String,
4015    target: String,
4016    summary: String,
4017    expand: String,
4018}
4019
4020#[derive(Clone, Debug, Serialize, PartialEq)]
4021struct ExplorationPacket {
4022    budget: ExplorationBudget,
4023    relationship_map: Vec<ExplorationRelation>,
4024    source_windows: Vec<ExplorationSourceWindow>,
4025    #[serde(skip_serializing_if = "Vec::is_empty", default)]
4026    worker_context: Vec<ExplorationWorkerContext>,
4027    no_reread_guidance: String,
4028}
4029
4030impl TraversalGraphBuild {
4031    fn add_node(&mut self, node: TraversalNode) {
4032        self.nodes.entry(node.handle.clone()).or_insert(node);
4033    }
4034
4035    fn add_edge(
4036        &mut self,
4037        from: &str,
4038        to: &str,
4039        relation: &str,
4040        label: Option<String>,
4041        weight: usize,
4042    ) {
4043        if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
4044            return;
4045        }
4046        let key = (from.to_string(), to.to_string(), relation.to_string());
4047        if self.edge_keys.insert(key) {
4048            self.edges.push(TraversalEdge {
4049                from: from.to_string(),
4050                to: to.to_string(),
4051                relation: relation.to_string(),
4052                label,
4053                weight,
4054            });
4055        }
4056    }
4057}
4058
4059pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
4060    match scope {
4061        Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
4062        None => root.join(".tsift/graph.db"),
4063    }
4064}
4065
4066fn graph_projection_meta_id(scope: Option<&str>) -> String {
4067    format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
4068}
4069
4070pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
4071    let bytes = serde_json::to_vec(value)?;
4072    Ok(blake3::hash(&bytes).to_hex().to_string())
4073}
4074
4075fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
4076    let mut hashable = node.clone();
4077    hashable.freshness = None;
4078    node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4079    Ok(node)
4080}
4081
4082fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
4083    let mut hashable = edge.clone();
4084    hashable.freshness = None;
4085    edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4086    Ok(edge)
4087}
4088
4089const SEMANTIC_EMBEDDING_DIM: usize = 32;
4090const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
4091
4092fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
4093    match kind {
4094        SemanticRelatedKind::Concept => "concept",
4095        SemanticRelatedKind::Entity => "entity",
4096        SemanticRelatedKind::All => "all",
4097    }
4098}
4099
4100fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
4101    format!(
4102        "tsift semantic {} --path {} --kind {} --limit 10",
4103        shell_quote(query),
4104        shell_quote(root.to_string_lossy().as_ref()),
4105        semantic_related_kind_name(kind)
4106    )
4107}
4108
4109fn semantic_embedding(input: &str) -> Vec<f64> {
4110    let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
4111    let mut tokens = traversal_tokens(input);
4112    if tokens.is_empty() {
4113        let trimmed = input.trim().to_ascii_lowercase();
4114        if !trimmed.is_empty() {
4115            tokens.insert(trimmed);
4116        }
4117    }
4118
4119    for token in tokens {
4120        let hash = blake3::hash(token.as_bytes());
4121        let bytes = hash.as_bytes();
4122        let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
4123        let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
4124        vector[idx] += sign;
4125    }
4126
4127    let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
4128    if norm > 0.0 {
4129        for value in &mut vector {
4130            *value /= norm;
4131        }
4132    }
4133    vector
4134}
4135
4136fn semantic_embedding_property(input: &str) -> String {
4137    semantic_embedding(input)
4138        .iter()
4139        .map(|value| format!("{value:.6}"))
4140        .collect::<Vec<_>>()
4141        .join(",")
4142}
4143
4144fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
4145    let parsed = value
4146        .split(',')
4147        .map(str::trim)
4148        .map(str::parse::<f64>)
4149        .collect::<std::result::Result<Vec<_>, _>>()
4150        .ok()?;
4151    (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
4152}
4153
4154fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
4155    if left.len() != right.len() {
4156        return 0.0;
4157    }
4158    left.iter()
4159        .zip(right.iter())
4160        .map(|(left, right)| left * right)
4161        .sum::<f64>()
4162}
4163
4164fn semantic_entity_handle(name: &str, kind: &str) -> String {
4165    stable_handle(
4166        "gent",
4167        &format!(
4168            "entity:{}:{}",
4169            kind.trim().to_ascii_lowercase(),
4170            name.trim().to_ascii_lowercase()
4171        ),
4172    )
4173}
4174
4175fn semantic_concept_handle(label: &str) -> String {
4176    stable_handle(
4177        "gcon",
4178        &format!("concept:{}", label.trim().to_ascii_lowercase()),
4179    )
4180}
4181
4182fn summary_source_handles(
4183    summary: &summarize::Summary,
4184    file_node_by_path: &BTreeMap<String, String>,
4185    symbol_node_by_file_label: &BTreeMap<(String, String), String>,
4186) -> Vec<String> {
4187    let mut handles = Vec::new();
4188    if let Some(handle) = file_node_by_path.get(&summary.file_path) {
4189        handles.push(handle.clone());
4190    }
4191    if let Some(handle) =
4192        symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
4193        && !handles.iter().any(|existing| existing == handle)
4194    {
4195        handles.push(handle.clone());
4196    }
4197    handles
4198}
4199
4200fn semantic_entity_node(
4201    root: &Path,
4202    summary: &summarize::Summary,
4203    name: &str,
4204    kind: &str,
4205    description: &str,
4206    provenance: &GraphProvenance,
4207) -> SubstrateGraphNode {
4208    let handle = semantic_entity_handle(name, kind);
4209    let detail = if description.trim().is_empty() {
4210        format!("{kind} entity from cached summaries")
4211    } else {
4212        format!("{kind}: {description}")
4213    };
4214    SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
4215        .with_property("handle", handle)
4216        .with_property("ref_id", name.to_string())
4217        .with_property("detail", detail)
4218        .with_property("entity_kind", kind.to_string())
4219        .with_property("description", description.to_string())
4220        .with_property("source_file", summary.file_path.clone())
4221        .with_property("source_symbol", summary.symbol_name.clone())
4222        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4223        .with_property(
4224            "embedding",
4225            semantic_embedding_property(&format!("{name} {kind} {description}")),
4226        )
4227        .with_property(
4228            "expand",
4229            semantic_related_command(root, name, SemanticRelatedKind::Entity),
4230        )
4231        .with_provenance(provenance.clone())
4232}
4233
4234fn semantic_concept_node(
4235    root: &Path,
4236    summary: &summarize::Summary,
4237    label: &str,
4238    provenance: &GraphProvenance,
4239) -> SubstrateGraphNode {
4240    let handle = semantic_concept_handle(label);
4241    SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
4242        .with_property("handle", handle)
4243        .with_property("ref_id", label.to_string())
4244        .with_property("detail", "concept label from cached summaries".to_string())
4245        .with_property("source_file", summary.file_path.clone())
4246        .with_property("source_symbol", summary.symbol_name.clone())
4247        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4248        .with_property("embedding", semantic_embedding_property(label))
4249        .with_property(
4250            "expand",
4251            semantic_related_command(root, label, SemanticRelatedKind::Concept),
4252        )
4253        .with_provenance(provenance.clone())
4254}
4255
4256fn insert_semantic_edge(
4257    edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
4258    edge: SubstrateGraphEdge,
4259) {
4260    edge_map
4261        .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
4262        .or_insert(edge);
4263}
4264
4265fn append_summary_semantic_projection_rows(
4266    root: &Path,
4267    graph: &TraversalGraphBuild,
4268    provenance: &GraphProvenance,
4269    nodes: &mut Vec<SubstrateGraphNode>,
4270    edges: &mut Vec<SubstrateGraphEdge>,
4271) -> Result<()> {
4272    let summaries_db = root.join(".tsift/summaries.db");
4273    if !summaries_db.exists() {
4274        return Ok(());
4275    }
4276
4277    let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
4278    let summaries = summary_db.all()?;
4279    if summaries.is_empty() {
4280        return Ok(());
4281    }
4282
4283    let file_node_by_path = graph
4284        .nodes
4285        .values()
4286        .filter(|node| node.kind == "file")
4287        .filter_map(|node| {
4288            node.path
4289                .as_ref()
4290                .map(|path| (path.clone(), node.handle.clone()))
4291        })
4292        .collect::<BTreeMap<_, _>>();
4293    let symbol_node_by_file_label = graph
4294        .nodes
4295        .values()
4296        .filter(|node| node.kind == "symbol")
4297        .filter_map(|node| {
4298            Some((
4299                (node.path.clone()?, node.label.clone()),
4300                node.handle.clone(),
4301            ))
4302        })
4303        .collect::<BTreeMap<_, _>>();
4304
4305    let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
4306    let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
4307
4308    for summary in &summaries {
4309        let source_handles =
4310            summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
4311        let mut entity_ids_by_name = BTreeMap::<String, String>::new();
4312
4313        if let Some(entities) = &summary.entities {
4314            for entity in entities {
4315                let node = semantic_entity_node(
4316                    root,
4317                    summary,
4318                    &entity.name,
4319                    &entity.kind,
4320                    &entity.description,
4321                    provenance,
4322                );
4323                let entity_id = node.id.clone();
4324                entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
4325                semantic_nodes.entry(entity_id.clone()).or_insert(node);
4326
4327                for source_handle in &source_handles {
4328                    insert_semantic_edge(
4329                        &mut semantic_edges,
4330                        SubstrateGraphEdge::new(
4331                            source_handle.clone(),
4332                            entity_id.clone(),
4333                            "mentions_entity",
4334                        )
4335                        .with_property("label", format!("summary entity: {}", entity.name))
4336                        .with_property("source_file", summary.file_path.clone())
4337                        .with_provenance(provenance.clone()),
4338                    );
4339                }
4340            }
4341        }
4342
4343        let mut concept_ids = Vec::new();
4344        if let Some(labels) = &summary.concept_labels {
4345            for label in labels
4346                .iter()
4347                .map(|label| label.trim())
4348                .filter(|label| !label.is_empty())
4349            {
4350                let node = semantic_concept_node(root, summary, label, provenance);
4351                let concept_id = node.id.clone();
4352                semantic_nodes.entry(concept_id.clone()).or_insert(node);
4353                concept_ids.push(concept_id.clone());
4354
4355                for source_handle in &source_handles {
4356                    insert_semantic_edge(
4357                        &mut semantic_edges,
4358                        SubstrateGraphEdge::new(
4359                            source_handle.clone(),
4360                            concept_id.clone(),
4361                            "mentions_concept",
4362                        )
4363                        .with_property("label", format!("summary concept: {label}"))
4364                        .with_property("source_file", summary.file_path.clone())
4365                        .with_provenance(provenance.clone()),
4366                    );
4367                }
4368            }
4369        }
4370
4371        for entity_id in entity_ids_by_name.values() {
4372            for concept_id in &concept_ids {
4373                insert_semantic_edge(
4374                    &mut semantic_edges,
4375                    SubstrateGraphEdge::new(
4376                        entity_id.clone(),
4377                        concept_id.clone(),
4378                        "tagged_concept",
4379                    )
4380                    .with_property("label", "entity concept label".to_string())
4381                    .with_property("source_file", summary.file_path.clone())
4382                    .with_provenance(provenance.clone()),
4383                );
4384            }
4385        }
4386
4387        for idx in 0..concept_ids.len() {
4388            for next_idx in (idx + 1)..concept_ids.len() {
4389                insert_semantic_edge(
4390                    &mut semantic_edges,
4391                    SubstrateGraphEdge::new(
4392                        concept_ids[idx].clone(),
4393                        concept_ids[next_idx].clone(),
4394                        "related_concept",
4395                    )
4396                    .with_property("label", format!("co-occurs in {}", summary.symbol_name))
4397                    .with_property("source_file", summary.file_path.clone())
4398                    .with_provenance(provenance.clone()),
4399                );
4400            }
4401        }
4402
4403        if let Some(relationships) = &summary.relationships {
4404            for relationship in relationships {
4405                let from_id = entity_ids_by_name
4406                    .get(&relationship.from.to_ascii_lowercase())
4407                    .cloned()
4408                    .unwrap_or_else(|| {
4409                        let node = semantic_entity_node(
4410                            root,
4411                            summary,
4412                            &relationship.from,
4413                            "unknown",
4414                            "",
4415                            provenance,
4416                        );
4417                        let id = node.id.clone();
4418                        semantic_nodes.entry(id.clone()).or_insert(node);
4419                        id
4420                    });
4421                let to_id = entity_ids_by_name
4422                    .get(&relationship.to.to_ascii_lowercase())
4423                    .cloned()
4424                    .unwrap_or_else(|| {
4425                        let node = semantic_entity_node(
4426                            root,
4427                            summary,
4428                            &relationship.to,
4429                            "unknown",
4430                            "",
4431                            provenance,
4432                        );
4433                        let id = node.id.clone();
4434                        semantic_nodes.entry(id.clone()).or_insert(node);
4435                        id
4436                    });
4437                insert_semantic_edge(
4438                    &mut semantic_edges,
4439                    SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
4440                        .with_property("relationship_kind", relationship.kind.clone())
4441                        .with_property("label", relationship.kind.clone())
4442                        .with_property("source_file", summary.file_path.clone())
4443                        .with_property("source_symbol", summary.symbol_name.clone())
4444                        .with_provenance(provenance.clone()),
4445                );
4446            }
4447        }
4448    }
4449
4450    for node in semantic_nodes.into_values() {
4451        nodes.push(node_with_content_freshness(node)?);
4452    }
4453    for edge in semantic_edges.into_values() {
4454        edges.push(edge_with_content_freshness(edge)?);
4455    }
4456
4457    Ok(())
4458}
4459
4460fn projection_content_hash(
4461    nodes: &[SubstrateGraphNode],
4462    edges: &[SubstrateGraphEdge],
4463) -> Result<String> {
4464    #[derive(Serialize)]
4465    struct Payload<'a> {
4466        version: &'static str,
4467        nodes: &'a [SubstrateGraphNode],
4468        edges: &'a [SubstrateGraphEdge],
4469    }
4470
4471    content_hash(&Payload {
4472        version: GRAPH_PROJECTION_VERSION,
4473        nodes,
4474        edges,
4475    })
4476}
4477
4478pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
4479    projection
4480        .nodes
4481        .iter()
4482        .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
4483        .and_then(|node| node.properties.get("content_hash").cloned())
4484}
4485
4486fn traversal_projection_from_graph(
4487    root: &Path,
4488    scope: Option<&str>,
4489    graph: &TraversalGraphBuild,
4490) -> Result<GraphProjection> {
4491    let provenance = GraphProvenance::new(
4492        "tsift.traverse",
4493        format!("{}:{}", root.display(), scope.unwrap_or("root")),
4494    );
4495    let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
4496    for node in graph.nodes.values() {
4497        let mut projected =
4498            SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
4499                .with_property("handle", node.handle.clone())
4500                .with_property("expand", node.expand.clone())
4501                .with_provenance(provenance.clone());
4502        if let Some(ref_id) = &node.ref_id {
4503            projected = projected.with_property("ref_id", ref_id.clone());
4504        }
4505        if let Some(path) = &node.path {
4506            projected = projected.with_property("path", path.clone());
4507        }
4508        if let Some(line) = node.line {
4509            projected = projected.with_property("line", line.to_string());
4510        }
4511        if let Some(detail) = &node.detail {
4512            projected = projected.with_property("detail", detail.clone());
4513        }
4514        for (key, value) in &node.properties {
4515            projected = projected.with_property(key.clone(), value.clone());
4516        }
4517        nodes.push(node_with_content_freshness(projected)?);
4518    }
4519
4520    let mut edges = Vec::with_capacity(graph.edges.len());
4521    for edge in &graph.edges {
4522        let mut projected =
4523            SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
4524                .with_property("weight", edge.weight.to_string())
4525                .with_provenance(provenance.clone());
4526        if let Some(label) = &edge.label {
4527            projected = projected.with_property("label", label.clone());
4528        }
4529        edges.push(edge_with_content_freshness(projected)?);
4530    }
4531
4532    append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4533    append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4534    append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
4535
4536    let projection_hash = projection_content_hash(&nodes, &edges)?;
4537    let meta = SubstrateGraphNode::new(
4538        graph_projection_meta_id(scope),
4539        GRAPH_PROJECTION_META_KIND,
4540        "tsift traversal projection",
4541    )
4542    .with_property("projection_version", GRAPH_PROJECTION_VERSION)
4543    .with_property("content_hash", projection_hash.clone())
4544    .with_property("root", root.to_string_lossy().to_string())
4545    .with_property("scope", scope.unwrap_or("root"))
4546    .with_property("node_count", graph.nodes.len().to_string())
4547    .with_property("edge_count", graph.edges.len().to_string())
4548    .with_provenance(provenance)
4549    .with_freshness(GraphFreshness::content_hash(projection_hash));
4550    nodes.push(meta);
4551
4552    Ok(GraphProjection { nodes, edges })
4553}
4554
4555#[allow(clippy::too_many_arguments)]
4556fn ensure_traversal_source_handle(
4557    root: &Path,
4558    provenance: &GraphProvenance,
4559    file_node_by_path: &BTreeMap<String, String>,
4560    node: &TraversalNode,
4561    budget: &ExplorationBudget,
4562    source_handle_by_node: &mut BTreeMap<String, String>,
4563    seen_windows: &mut BTreeMap<(String, usize, usize), String>,
4564    nodes: &mut Vec<SubstrateGraphNode>,
4565    edges: &mut Vec<SubstrateGraphEdge>,
4566) -> Result<Option<String>> {
4567    if let Some(handle) = source_handle_by_node.get(&node.handle) {
4568        return Ok(Some(handle.clone()));
4569    }
4570    let Some(window) = exploration_source_window_for_node(root, node, budget) else {
4571        return Ok(None);
4572    };
4573    let window_key = (window.file.clone(), window.start, window.end);
4574    let handle = if let Some(handle) = seen_windows.get(&window_key) {
4575        handle.clone()
4576    } else {
4577        let label = format!("{}:{}-{}", window.file, window.start, window.end);
4578        let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
4579            .with_property("handle", window.handle.clone())
4580            .with_property("file", window.file.clone())
4581            .with_property("start", window.start.to_string())
4582            .with_property("end", window.end.to_string())
4583            .with_property("reason", window.reason.clone())
4584            .with_property("expand", window.expand.clone())
4585            .with_provenance(provenance.clone());
4586        nodes.push(node_with_content_freshness(projected)?);
4587
4588        if let Some(file_handle) = file_node_by_path.get(&window.file) {
4589            let edge = SubstrateGraphEdge::new(
4590                window.handle.clone(),
4591                file_handle.clone(),
4592                "expands_source",
4593            )
4594            .with_property("label", window.reason.clone())
4595            .with_provenance(provenance.clone());
4596            edges.push(edge_with_content_freshness(edge)?);
4597        }
4598        if node.kind != "file" {
4599            let edge = SubstrateGraphEdge::new(
4600                window.handle.clone(),
4601                node.handle.clone(),
4602                "anchors_source",
4603            )
4604            .with_property("label", window.reason.clone())
4605            .with_provenance(provenance.clone());
4606            edges.push(edge_with_content_freshness(edge)?);
4607        }
4608        seen_windows.insert(window_key, window.handle.clone());
4609        window.handle
4610    };
4611    source_handle_by_node.insert(node.handle.clone(), handle.clone());
4612    Ok(Some(handle))
4613}
4614
4615fn push_traversal_backlog_target_handles<'a>(
4616    backlog: &TraversalNode,
4617    edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
4618    node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
4619    max_handles: usize,
4620    seen_target_nodes: &mut BTreeSet<String>,
4621    target_node_handles: &mut Vec<String>,
4622) {
4623    for edge in edges_by_from
4624        .get(backlog.handle.as_str())
4625        .into_iter()
4626        .flatten()
4627        .filter(|edge| edge.relation == "mentions")
4628    {
4629        let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
4630            continue;
4631        };
4632        if !matches!(
4633            target_node.kind.as_str(),
4634            "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
4635        ) {
4636            continue;
4637        }
4638        if target_node
4639            .path
4640            .as_deref()
4641            .zip(backlog.path.as_deref())
4642            .is_some_and(|(target_path, backlog_path)| {
4643                target_path == backlog_path && target_path.ends_with(".md")
4644            })
4645        {
4646            continue;
4647        }
4648        if seen_target_nodes.insert(target_node.handle.clone()) {
4649            target_node_handles.push(target_node.handle.clone());
4650        }
4651        if target_node_handles.len() >= max_handles {
4652            break;
4653        }
4654    }
4655}
4656
4657fn append_traversal_context_projection_rows(
4658    root: &Path,
4659    graph: &TraversalGraphBuild,
4660    provenance: &GraphProvenance,
4661    nodes: &mut Vec<SubstrateGraphNode>,
4662    edges: &mut Vec<SubstrateGraphEdge>,
4663) -> Result<()> {
4664    let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4665    let file_node_by_path = graph
4666        .nodes
4667        .values()
4668        .filter(|node| node.kind == "file")
4669        .filter_map(|node| {
4670            node.path
4671                .as_ref()
4672                .map(|path| (path.clone(), node.handle.clone()))
4673        })
4674        .collect::<BTreeMap<_, _>>();
4675
4676    let node_by_handle = graph
4677        .nodes
4678        .values()
4679        .map(|node| (node.handle.as_str(), node))
4680        .collect::<BTreeMap<_, _>>();
4681    let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4682    for edge in &graph.edges {
4683        edges_by_from
4684            .entry(edge.from.as_str())
4685            .or_default()
4686            .push(edge);
4687    }
4688    for rows in edges_by_from.values_mut() {
4689        rows.sort_by(|left, right| {
4690            right
4691                .weight
4692                .cmp(&left.weight)
4693                .then(left.relation.cmp(&right.relation))
4694                .then(left.to.cmp(&right.to))
4695        });
4696    }
4697
4698    let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4699    let mut source_handle_by_node = BTreeMap::<String, String>::new();
4700
4701    let mut code_context_count = 0usize;
4702    let code_context_limit = budget.relationship_limit.min(8);
4703    for node in graph.nodes.values() {
4704        if !matches!(
4705            node.kind.as_str(),
4706            "backlog" | "job_packet" | "worker_result"
4707        ) {
4708            continue;
4709        }
4710        let mut target_node_handles = Vec::new();
4711        let mut fallback_target_handles = Vec::new();
4712        let mut seen_target_nodes = BTreeSet::new();
4713        if node.kind == "backlog" || node.kind == "worker_result" {
4714            push_traversal_backlog_target_handles(
4715                node,
4716                &edges_by_from,
4717                &node_by_handle,
4718                budget.max_source_windows,
4719                &mut seen_target_nodes,
4720                &mut target_node_handles,
4721            );
4722            fallback_target_handles.push(node.handle.clone());
4723        } else {
4724            for edge in edges_by_from
4725                .get(node.handle.as_str())
4726                .into_iter()
4727                .flatten()
4728                .filter(|edge| edge.relation == "targets")
4729            {
4730                let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4731                    continue;
4732                };
4733                fallback_target_handles.push(backlog.handle.clone());
4734                push_traversal_backlog_target_handles(
4735                    backlog,
4736                    &edges_by_from,
4737                    &node_by_handle,
4738                    budget.max_source_windows,
4739                    &mut seen_target_nodes,
4740                    &mut target_node_handles,
4741                );
4742                if target_node_handles.len() >= budget.max_source_windows {
4743                    break;
4744                }
4745            }
4746            if fallback_target_handles.is_empty() {
4747                continue;
4748            }
4749        }
4750        let code_context = !target_node_handles.is_empty();
4751        if target_node_handles.is_empty() {
4752            target_node_handles = dedupe_preserve_order(fallback_target_handles);
4753        } else if code_context_count >= code_context_limit {
4754            continue;
4755        }
4756
4757        let mut worker_source_handles = Vec::new();
4758        let mut seen_worker_handles = BTreeSet::new();
4759        for target_handle in target_node_handles {
4760            if worker_source_handles.len() >= budget.max_source_windows {
4761                break;
4762            }
4763            let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4764                continue;
4765            };
4766            let Some(handle) = ensure_traversal_source_handle(
4767                root,
4768                provenance,
4769                &file_node_by_path,
4770                target_node,
4771                &budget,
4772                &mut source_handle_by_node,
4773                &mut seen_windows,
4774                nodes,
4775                edges,
4776            )?
4777            else {
4778                continue;
4779            };
4780            if seen_worker_handles.insert(handle.clone()) {
4781                worker_source_handles.push(handle);
4782            }
4783        }
4784        if worker_source_handles.is_empty() {
4785            continue;
4786        }
4787        let target = node
4788            .path
4789            .clone()
4790            .unwrap_or_else(|| root.to_string_lossy().to_string());
4791        let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4792        let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4793        let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4794            .with_property("handle", handle.clone())
4795            .with_property("target", target.clone())
4796            .with_property("summary", summary)
4797            .with_property(
4798                "source_handle_count",
4799                worker_source_handles.len().to_string(),
4800            )
4801            .with_property(
4802                "expand",
4803                format!(
4804                    "tsift --envelope context-pack {} --budget normal",
4805                    shell_quote(&target)
4806                ),
4807            )
4808            .with_provenance(provenance.clone());
4809        nodes.push(node_with_content_freshness(projected)?);
4810
4811        let request_edge =
4812            SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4813                .with_property("label", "bounded worker context".to_string())
4814                .with_provenance(provenance.clone());
4815        edges.push(edge_with_content_freshness(request_edge)?);
4816
4817        for source_handle in &worker_source_handles {
4818            let scope_edge =
4819                SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4820                    .with_property("label", "bounded worker source window".to_string())
4821                    .with_provenance(provenance.clone());
4822            edges.push(edge_with_content_freshness(scope_edge)?);
4823        }
4824        if code_context {
4825            code_context_count += 1;
4826        }
4827    }
4828
4829    Ok(())
4830}
4831
4832fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4833    let handle = node
4834        .properties
4835        .get("handle")
4836        .cloned()
4837        .unwrap_or_else(|| node.id.clone());
4838    TraversalNode {
4839        expand: node
4840            .properties
4841            .get("expand")
4842            .cloned()
4843            .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4844        handle,
4845        kind: node.kind,
4846        label: node.label,
4847        ref_id: node.properties.get("ref_id").cloned(),
4848        path: node.properties.get("path").cloned(),
4849        line: node
4850            .properties
4851            .get("line")
4852            .and_then(|value| value.parse::<i64>().ok()),
4853        detail: node.properties.get("detail").cloned(),
4854        properties: node.properties,
4855    }
4856}
4857
4858fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4859    let mut graph = TraversalGraphBuild::default();
4860    for node in store.all_nodes()? {
4861        if node.kind == GRAPH_PROJECTION_META_KIND {
4862            continue;
4863        }
4864        graph.add_node(traversal_node_from_graph_node(root, node));
4865    }
4866    for edge in store.all_edges()? {
4867        graph.add_edge(
4868            &edge.from_id,
4869            &edge.to_id,
4870            &edge.kind,
4871            edge.properties.get("label").cloned(),
4872            edge.properties
4873                .get("weight")
4874                .and_then(|value| value.parse::<usize>().ok())
4875                .unwrap_or(1),
4876        );
4877    }
4878    Ok(graph)
4879}
4880
4881pub(crate) fn convex_rows_from_graph_store(
4882    store: &impl GraphStore,
4883) -> Result<ConvexProjectionRows> {
4884    Ok(GraphProjection {
4885        nodes: store.all_nodes()?,
4886        edges: store.all_edges()?,
4887    }
4888    .to_convex_rows())
4889}
4890
4891#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4892struct ConvexRequiredIndex {
4893    table: String,
4894    name: String,
4895    fields: Vec<String>,
4896}
4897
4898#[derive(Clone, Debug, Serialize, PartialEq)]
4899struct ConvexSyncChunk {
4900    operation: String,
4901    chunk: usize,
4902    count: usize,
4903    keys: Vec<String>,
4904    max_attempts: usize,
4905    retry_policy: String,
4906}
4907
4908#[derive(Clone, Debug, Serialize, PartialEq)]
4909struct ConvexTransportSummary {
4910    endpoint_env: String,
4911    endpoint_configured: bool,
4912    auth_token_env: String,
4913    auth_configured: bool,
4914    remote_snapshot: bool,
4915    applied_chunks: usize,
4916}
4917
4918#[derive(Clone, Debug, Serialize, PartialEq)]
4919struct ConvexTransportReceipt {
4920    operation: String,
4921    chunk: usize,
4922    attempt: usize,
4923    status: String,
4924    message: Option<String>,
4925}
4926
4927#[derive(Serialize)]
4928#[serde(rename_all = "camelCase")]
4929struct ConvexTransportRequest<'a> {
4930    operation: &'a str,
4931    chunk: usize,
4932    projection_version: &'a str,
4933    projection_hash: Option<&'a str>,
4934    #[serde(skip_serializing_if = "Option::is_none")]
4935    projection_meta_id: Option<&'a str>,
4936    node_rows: Vec<ConvexNodeRow>,
4937    edge_rows: Vec<ConvexEdgeRow>,
4938    keys: Vec<String>,
4939    #[serde(skip_serializing_if = "Option::is_none")]
4940    cursor: Option<String>,
4941    #[serde(skip_serializing_if = "Option::is_none")]
4942    limit: Option<usize>,
4943}
4944
4945#[derive(Deserialize)]
4946#[serde(rename_all = "camelCase")]
4947struct ConvexTransportResponse {
4948    status: Option<String>,
4949    message: Option<String>,
4950    rows: Option<ConvexProjectionRows>,
4951    #[serde(default)]
4952    meta: Option<ConvexSnapshotMeta>,
4953    #[serde(default)]
4954    page: Option<ConvexSnapshotPage>,
4955}
4956
4957#[derive(Deserialize, Debug, Clone)]
4958#[serde(rename_all = "camelCase")]
4959struct ConvexSnapshotMeta {
4960    // Captured for completeness/debugging; not currently consumed by the
4961    // freshness diff (indexes are already validated against the required set
4962    // via `convex_required_indexes`, and `page_size` is informational only).
4963    #[serde(default)]
4964    #[allow(dead_code)]
4965    indexes: Vec<ConvexRequiredIndex>,
4966    #[serde(default)]
4967    #[allow(dead_code)]
4968    node_count: Option<usize>,
4969    #[serde(default)]
4970    #[allow(dead_code)]
4971    edge_count: Option<usize>,
4972    #[serde(default)]
4973    projection_hash: Option<String>,
4974    #[serde(default)]
4975    #[allow(dead_code)]
4976    page_size: Option<usize>,
4977}
4978
4979/// Paginated snapshot page response. `rows` is either node rows or edge rows
4980/// depending on which operation was called; we deserialize as raw values to
4981/// keep the transport struct shared between both shapes, then narrow per call
4982/// site.
4983#[derive(Deserialize, Debug, Clone)]
4984#[serde(rename_all = "camelCase")]
4985struct ConvexSnapshotPage {
4986    rows: Vec<serde_json::Value>,
4987    #[serde(default)]
4988    next_cursor: Option<String>,
4989}
4990
4991#[derive(Clone, Debug, Serialize, PartialEq)]
4992struct ConvexProjectionFreshness {
4993    status: String,
4994    fail_closed: bool,
4995    local_hash: Option<String>,
4996    snapshot_hash: Option<String>,
4997    missing_nodes: Vec<String>,
4998    stale_nodes: Vec<String>,
4999    missing_edges: Vec<String>,
5000    stale_edges: Vec<String>,
5001    diagnostics: Vec<String>,
5002}
5003
5004const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
5005
5006impl ConvexProjectionFreshness {
5007    fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
5008        Self {
5009            status: "current".to_string(),
5010            fail_closed: false,
5011            local_hash,
5012            snapshot_hash,
5013            missing_nodes: Vec::new(),
5014            stale_nodes: Vec::new(),
5015            missing_edges: Vec::new(),
5016            stale_edges: Vec::new(),
5017            diagnostics: Vec::new(),
5018        }
5019    }
5020}
5021
5022#[derive(Clone, Debug, Serialize, PartialEq)]
5023struct ConvexSyncReport {
5024    root: String,
5025    #[serde(skip_serializing_if = "Option::is_none")]
5026    scope: Option<String>,
5027    graph_db: String,
5028    dry_run: bool,
5029    projection_version: String,
5030    projection_hash: Option<String>,
5031    required_indexes: Vec<ConvexRequiredIndex>,
5032    node_upserts: Vec<ConvexNodeRow>,
5033    edge_upserts: Vec<ConvexEdgeRow>,
5034    node_tombstones: Vec<String>,
5035    edge_tombstones: Vec<String>,
5036    chunks: Vec<ConvexSyncChunk>,
5037    freshness: ConvexProjectionFreshness,
5038    transport: Option<ConvexTransportSummary>,
5039    receipts: Vec<ConvexTransportReceipt>,
5040    diagnostics: Vec<String>,
5041    warnings: Vec<String>,
5042}
5043
5044fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
5045    vec![
5046        ConvexRequiredIndex {
5047            table: "nodes".to_string(),
5048            name: "by_external_id".to_string(),
5049            fields: vec!["externalId".to_string()],
5050        },
5051        ConvexRequiredIndex {
5052            table: "nodes".to_string(),
5053            name: "by_kind".to_string(),
5054            fields: vec!["kind".to_string()],
5055        },
5056        ConvexRequiredIndex {
5057            table: "edges".to_string(),
5058            name: "by_edge_key".to_string(),
5059            fields: vec!["edgeKey".to_string()],
5060        },
5061        ConvexRequiredIndex {
5062            table: "edges".to_string(),
5063            name: "by_from_kind".to_string(),
5064            fields: vec!["fromExternalId".to_string(), "kind".to_string()],
5065        },
5066        ConvexRequiredIndex {
5067            table: "edges".to_string(),
5068            name: "by_to_kind".to_string(),
5069            fields: vec!["toExternalId".to_string(), "kind".to_string()],
5070        },
5071    ]
5072}
5073
5074pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
5075    let content = fs::read_to_string(path)
5076        .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
5077    serde_json::from_str(&content)
5078        .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
5079}
5080
5081fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
5082    let mut diagnostics = Vec::new();
5083    let mut node_counts = BTreeMap::<&str, usize>::new();
5084    for row in &rows.nodes {
5085        *node_counts.entry(row.external_id.as_str()).or_default() += 1;
5086    }
5087    for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
5088        diagnostics.push(format!(
5089            "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
5090        ));
5091    }
5092
5093    let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
5094    let mut edge_counts = BTreeMap::<&str, usize>::new();
5095    for edge in &rows.edges {
5096        *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
5097        if !node_ids.contains(edge.from_external_id.as_str()) {
5098            diagnostics.push(format!(
5099                "Convex snapshot edge {} references missing from node {}",
5100                edge.edge_key, edge.from_external_id
5101            ));
5102        }
5103        if !node_ids.contains(edge.to_external_id.as_str()) {
5104            diagnostics.push(format!(
5105                "Convex snapshot edge {} references missing to node {}",
5106                edge.edge_key, edge.to_external_id
5107            ));
5108        }
5109        let expected_key =
5110            ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
5111        if edge.edge_key != expected_key {
5112            diagnostics.push(format!(
5113                "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
5114                edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
5115            ));
5116        }
5117    }
5118    for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
5119        diagnostics.push(format!(
5120            "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
5121        ));
5122    }
5123    diagnostics
5124}
5125
5126pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
5127    let diagnostics = convex_projection_row_diagnostics(rows);
5128    if diagnostics.is_empty() {
5129        Ok(())
5130    } else {
5131        bail!("{}", diagnostics.join("; "))
5132    }
5133}
5134
5135pub(crate) struct ConvexHttpTransport {
5136    endpoint: String,
5137    auth_token_env: String,
5138    auth_token: Option<String>,
5139}
5140
5141impl ConvexHttpTransport {
5142    fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
5143        let endpoint = endpoint
5144            .map(str::to_string)
5145            .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
5146            .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
5147        let auth_token = env::var(auth_token_env)
5148            .ok()
5149            .filter(|value| !value.trim().is_empty());
5150        Ok(Self {
5151            endpoint,
5152            auth_token_env: auth_token_env.to_string(),
5153            auth_token,
5154        })
5155    }
5156
5157    fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
5158        ConvexTransportSummary {
5159            endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
5160            endpoint_configured: true,
5161            auth_token_env: self.auth_token_env.clone(),
5162            auth_configured: self.auth_token.is_some(),
5163            remote_snapshot,
5164            applied_chunks,
5165        }
5166    }
5167
5168    fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
5169        let mut builder = ureq::post(&self.endpoint);
5170        if let Some(token) = &self.auth_token {
5171            builder = builder.header("Authorization", &format!("Bearer {token}"));
5172        }
5173        builder
5174            .send_json(request)
5175            .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
5176            .body_mut()
5177            .read_json::<ConvexTransportResponse>()
5178            .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
5179    }
5180
5181    /// Fetch a full snapshot of the Convex graph backend.
5182    ///
5183    /// Uses the paginated `snapshot_meta` + `snapshot_nodes_page` +
5184    /// `snapshot_edges_page` triplet so the call works on tables larger than
5185    /// ~5k rows (the single-shot `snapshot` query hits Convex's 15s per-request
5186    /// syscall budget at that scale; see `#convexsnapshotscale`).
5187    ///
5188    /// Falls back to the legacy single-shot `snapshot` operation if the
5189    /// backend doesn't recognize `snapshot_meta` (older deployments that
5190    /// haven't redeployed the new schema).
5191    fn fetch_snapshot(
5192        &self,
5193        projection_version: &str,
5194        scope: Option<&str>,
5195        local_hash: Option<&str>,
5196        local_rows: Option<&ConvexProjectionRows>,
5197    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5198        match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
5199            Ok(rows) => Ok(rows),
5200            Err(err) => {
5201                // Only fall through to the legacy path if the failure looks
5202                // like "operation unknown" (older backend). Any other failure
5203                // (HTTP timeout, deserialization mismatch) should surface so
5204                // the operator sees the real cause.
5205                let msg = format!("{err:#}");
5206                let is_unknown_op = msg.contains("unknown operation")
5207                    || msg.contains("snapshot_meta")
5208                    || msg.contains("404");
5209                if !is_unknown_op {
5210                    return Err(err);
5211                }
5212                self.fetch_snapshot_legacy(projection_version)
5213                    .map(|rows| (rows, Vec::new()))
5214            }
5215        }
5216    }
5217
5218    fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
5219        let response = self.post(&ConvexTransportRequest {
5220            operation: "snapshot",
5221            chunk: 0,
5222            projection_version,
5223            projection_hash: None,
5224            projection_meta_id: None,
5225            node_rows: Vec::new(),
5226            edge_rows: Vec::new(),
5227            keys: Vec::new(),
5228            cursor: None,
5229            limit: None,
5230        })?;
5231        response
5232            .rows
5233            .context("Convex snapshot response did not include rows")
5234    }
5235
5236    fn fetch_snapshot_paginated(
5237        &self,
5238        projection_version: &str,
5239        scope: Option<&str>,
5240        local_hash: Option<&str>,
5241        local_rows: Option<&ConvexProjectionRows>,
5242    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5243        let projection_meta_id = graph_projection_meta_id(scope);
5244        let meta_response = self.post(&ConvexTransportRequest {
5245            operation: "snapshot_meta",
5246            chunk: 0,
5247            projection_version,
5248            projection_hash: None,
5249            projection_meta_id: Some(&projection_meta_id),
5250            node_rows: Vec::new(),
5251            edge_rows: Vec::new(),
5252            keys: Vec::new(),
5253            cursor: None,
5254            limit: None,
5255        })?;
5256        if matches!(meta_response.status.as_deref(), Some("error")) {
5257            anyhow::bail!(
5258                "Convex snapshot_meta returned error: {}",
5259                meta_response.message.unwrap_or_default()
5260            );
5261        }
5262        let meta = meta_response
5263            .meta
5264            .context("Convex snapshot_meta response did not include meta")?;
5265        if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
5266            (meta.projection_hash.as_deref(), local_hash, local_rows)
5267            && remote_hash == local_hash
5268        {
5269            return Ok((
5270                local_rows.clone(),
5271                vec![
5272                    "remote projection hash matched local graph; skipped full row-page snapshot diff"
5273                        .to_string(),
5274                ],
5275            ));
5276        }
5277
5278        let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
5279        let mut node_cursor: Option<String> = None;
5280        loop {
5281            let response = self.post(&ConvexTransportRequest {
5282                operation: "snapshot_nodes_page",
5283                chunk: 0,
5284                projection_version,
5285                projection_hash: None,
5286                projection_meta_id: None,
5287                node_rows: Vec::new(),
5288                edge_rows: Vec::new(),
5289                keys: Vec::new(),
5290                cursor: node_cursor.clone(),
5291                limit: None,
5292            })?;
5293            let page = response
5294                .page
5295                .context("Convex snapshot_nodes_page response did not include page")?;
5296            for raw in page.rows {
5297                let row: ConvexNodeRow =
5298                    serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
5299                nodes.push(row);
5300            }
5301            match page.next_cursor {
5302                Some(next) => node_cursor = Some(next),
5303                None => break,
5304            }
5305        }
5306
5307        let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
5308        let mut edge_cursor: Option<String> = None;
5309        loop {
5310            let response = self.post(&ConvexTransportRequest {
5311                operation: "snapshot_edges_page",
5312                chunk: 0,
5313                projection_version,
5314                projection_hash: None,
5315                projection_meta_id: None,
5316                node_rows: Vec::new(),
5317                edge_rows: Vec::new(),
5318                keys: Vec::new(),
5319                cursor: edge_cursor.clone(),
5320                limit: None,
5321            })?;
5322            let page = response
5323                .page
5324                .context("Convex snapshot_edges_page response did not include page")?;
5325            for raw in page.rows {
5326                let row: ConvexEdgeRow =
5327                    serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
5328                edges.push(row);
5329            }
5330            match page.next_cursor {
5331                Some(next) => edge_cursor = Some(next),
5332                None => break,
5333            }
5334        }
5335
5336        Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
5337    }
5338
5339    fn apply_chunk(
5340        &self,
5341        report: &ConvexSyncReport,
5342        chunk: &ConvexSyncChunk,
5343    ) -> Result<ConvexTransportReceipt> {
5344        let node_rows = if chunk.operation == "upsert_nodes" {
5345            report
5346                .node_upserts
5347                .iter()
5348                .filter(|row| chunk.keys.contains(&row.external_id))
5349                .cloned()
5350                .collect()
5351        } else {
5352            Vec::new()
5353        };
5354        let edge_rows = if chunk.operation == "upsert_edges" {
5355            report
5356                .edge_upserts
5357                .iter()
5358                .filter(|row| chunk.keys.contains(&row.edge_key))
5359                .cloned()
5360                .collect()
5361        } else {
5362            Vec::new()
5363        };
5364        let request = ConvexTransportRequest {
5365            operation: &chunk.operation,
5366            chunk: chunk.chunk,
5367            projection_version: &report.projection_version,
5368            projection_hash: report.projection_hash.as_deref(),
5369            projection_meta_id: None,
5370            node_rows,
5371            edge_rows,
5372            keys: chunk.keys.clone(),
5373            cursor: None,
5374            limit: None,
5375        };
5376        let mut last_error = None;
5377        for attempt in 1..=chunk.max_attempts {
5378            match self.post(&request) {
5379                Ok(response) => {
5380                    return Ok(ConvexTransportReceipt {
5381                        operation: chunk.operation.clone(),
5382                        chunk: chunk.chunk,
5383                        attempt,
5384                        status: response.status.unwrap_or_else(|| "ok".to_string()),
5385                        message: response.message,
5386                    });
5387                }
5388                Err(err) => {
5389                    last_error = Some(err);
5390                    if attempt < chunk.max_attempts {
5391                        std::thread::sleep(Duration::from_millis(100 * attempt as u64));
5392                    }
5393                }
5394            }
5395        }
5396        Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
5397            .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
5398    }
5399}
5400
5401fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
5402    let meta_id = graph_projection_meta_id(scope);
5403    rows.nodes
5404        .iter()
5405        .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
5406        .and_then(|row| row.properties.get("content_hash").cloned())
5407}
5408
5409fn convex_projection_freshness(
5410    local: &ConvexProjectionRows,
5411    snapshot: Option<&ConvexProjectionRows>,
5412    scope: Option<&str>,
5413) -> ConvexProjectionFreshness {
5414    let local_hash = convex_projection_hash(local, scope);
5415    let Some(snapshot) = snapshot else {
5416        return ConvexProjectionFreshness {
5417            status: "unchecked".to_string(),
5418            fail_closed: false,
5419            local_hash,
5420            snapshot_hash: None,
5421            missing_nodes: Vec::new(),
5422            stale_nodes: Vec::new(),
5423            missing_edges: Vec::new(),
5424            stale_edges: Vec::new(),
5425            diagnostics: vec![
5426                "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
5427            ],
5428        };
5429    };
5430
5431    let snapshot_hash = convex_projection_hash(snapshot, scope);
5432    let snapshot_nodes = snapshot
5433        .nodes
5434        .iter()
5435        .map(|row| (row.external_id.as_str(), row))
5436        .collect::<BTreeMap<_, _>>();
5437    let snapshot_edges = snapshot
5438        .edges
5439        .iter()
5440        .map(|row| (row.edge_key.as_str(), row))
5441        .collect::<BTreeMap<_, _>>();
5442
5443    let mut missing_nodes = Vec::new();
5444    let mut stale_nodes = Vec::new();
5445    for row in &local.nodes {
5446        match snapshot_nodes.get(row.external_id.as_str()) {
5447            Some(snapshot_row) if *snapshot_row == row => {}
5448            Some(_) => stale_nodes.push(row.external_id.clone()),
5449            None => missing_nodes.push(row.external_id.clone()),
5450        }
5451    }
5452
5453    let mut missing_edges = Vec::new();
5454    let mut stale_edges = Vec::new();
5455    for row in &local.edges {
5456        match snapshot_edges.get(row.edge_key.as_str()) {
5457            Some(snapshot_row) if *snapshot_row == row => {}
5458            Some(_) => stale_edges.push(row.edge_key.clone()),
5459            None => missing_edges.push(row.edge_key.clone()),
5460        }
5461    }
5462
5463    let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
5464    let rows_current = missing_nodes.is_empty()
5465        && stale_nodes.is_empty()
5466        && missing_edges.is_empty()
5467        && stale_edges.is_empty();
5468    if hash_current && rows_current {
5469        return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
5470    }
5471
5472    let mut diagnostics = Vec::new();
5473    if local_hash != snapshot_hash {
5474        diagnostics.push(format!(
5475            "projection hash mismatch: local={} snapshot={}",
5476            local_hash.as_deref().unwrap_or("missing"),
5477            snapshot_hash.as_deref().unwrap_or("missing")
5478        ));
5479    }
5480    if !missing_nodes.is_empty() || !missing_edges.is_empty() {
5481        diagnostics.push(format!(
5482            "Convex snapshot is missing {} node(s) and {} edge(s)",
5483            missing_nodes.len(),
5484            missing_edges.len()
5485        ));
5486    }
5487    if !stale_nodes.is_empty() || !stale_edges.is_empty() {
5488        diagnostics.push(format!(
5489            "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
5490            stale_nodes.len(),
5491            stale_edges.len()
5492        ));
5493    }
5494
5495    ConvexProjectionFreshness {
5496        status: "stale".to_string(),
5497        fail_closed: true,
5498        local_hash,
5499        snapshot_hash,
5500        missing_nodes,
5501        stale_nodes,
5502        missing_edges,
5503        stale_edges,
5504        diagnostics,
5505    }
5506}
5507
5508pub(crate) fn verify_convex_projection_snapshot(
5509    root: &Path,
5510    scope: Option<&str>,
5511    snapshot_path: &Path,
5512) -> Result<()> {
5513    let graph_db = graph_substrate_db_path(root, scope);
5514    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5515    let local = convex_rows_from_graph_store(&store)?;
5516    let snapshot = load_convex_projection_rows(snapshot_path)?;
5517    validate_convex_projection_rows(&snapshot)?;
5518    let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
5519    if freshness.fail_closed {
5520        bail!(
5521            "Convex graph projection is not current for {}: {}",
5522            root.display(),
5523            freshness.diagnostics.join("; ")
5524        );
5525    }
5526    Ok(())
5527}
5528
5529fn convex_rows_diff(
5530    local: &ConvexProjectionRows,
5531    snapshot: Option<&ConvexProjectionRows>,
5532) -> (
5533    Vec<ConvexNodeRow>,
5534    Vec<ConvexEdgeRow>,
5535    Vec<String>,
5536    Vec<String>,
5537) {
5538    let Some(snapshot) = snapshot else {
5539        return (
5540            local.nodes.clone(),
5541            local.edges.clone(),
5542            Vec::new(),
5543            Vec::new(),
5544        );
5545    };
5546    let local_nodes = local
5547        .nodes
5548        .iter()
5549        .map(|row| (row.external_id.as_str(), row))
5550        .collect::<BTreeMap<_, _>>();
5551    let local_edges = local
5552        .edges
5553        .iter()
5554        .map(|row| (row.edge_key.as_str(), row))
5555        .collect::<BTreeMap<_, _>>();
5556    let snapshot_nodes = snapshot
5557        .nodes
5558        .iter()
5559        .map(|row| (row.external_id.as_str(), row))
5560        .collect::<BTreeMap<_, _>>();
5561    let snapshot_edges = snapshot
5562        .edges
5563        .iter()
5564        .map(|row| (row.edge_key.as_str(), row))
5565        .collect::<BTreeMap<_, _>>();
5566
5567    let node_upserts = local
5568        .nodes
5569        .iter()
5570        .filter(|row| {
5571            snapshot_nodes
5572                .get(row.external_id.as_str())
5573                .is_none_or(|snapshot_row| *snapshot_row != *row)
5574        })
5575        .cloned()
5576        .collect::<Vec<_>>();
5577    let edge_upserts = local
5578        .edges
5579        .iter()
5580        .filter(|row| {
5581            snapshot_edges
5582                .get(row.edge_key.as_str())
5583                .is_none_or(|snapshot_row| *snapshot_row != *row)
5584        })
5585        .cloned()
5586        .collect::<Vec<_>>();
5587    let node_tombstones = snapshot
5588        .nodes
5589        .iter()
5590        .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
5591        .map(|row| row.external_id.clone())
5592        .collect::<Vec<_>>();
5593    let edge_tombstones = snapshot
5594        .edges
5595        .iter()
5596        .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
5597        .map(|row| row.edge_key.clone())
5598        .collect::<Vec<_>>();
5599
5600    (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
5601}
5602
5603fn push_sync_chunks(
5604    chunks: &mut Vec<ConvexSyncChunk>,
5605    operation: &str,
5606    keys: Vec<String>,
5607    size: usize,
5608) {
5609    if keys.is_empty() {
5610        return;
5611    }
5612    for (idx, chunk) in keys.chunks(size).enumerate() {
5613        chunks.push(ConvexSyncChunk {
5614            operation: operation.to_string(),
5615            chunk: idx + 1,
5616            count: chunk.len(),
5617            keys: chunk.to_vec(),
5618            max_attempts: 3,
5619            retry_policy:
5620                "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
5621                    .to_string(),
5622        });
5623    }
5624}
5625
5626pub(crate) fn build_convex_sync_report_with_snapshot(
5627    path: &Path,
5628    scope: Option<&str>,
5629    snapshot: Option<ConvexProjectionRows>,
5630    chunk_size: usize,
5631    dry_run: bool,
5632) -> Result<ConvexSyncReport> {
5633    if chunk_size == 0 {
5634        bail!("--chunk-size must be greater than zero");
5635    }
5636    let root = lint::resolve_project_root_or_canonical_path(path)?;
5637    let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
5638    let graph_db = graph_substrate_db_path(&root, scope);
5639    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5640    let local = convex_rows_from_graph_store(&store)?;
5641    let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
5642    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
5643        convex_rows_diff(&local, snapshot.as_ref());
5644
5645    let mut chunks = Vec::new();
5646    push_sync_chunks(
5647        &mut chunks,
5648        "delete_edges",
5649        edge_tombstones.clone(),
5650        chunk_size,
5651    );
5652    push_sync_chunks(
5653        &mut chunks,
5654        "upsert_nodes",
5655        node_upserts
5656            .iter()
5657            .map(|row| row.external_id.clone())
5658            .collect(),
5659        chunk_size,
5660    );
5661    push_sync_chunks(
5662        &mut chunks,
5663        "upsert_edges",
5664        edge_upserts
5665            .iter()
5666            .map(|row| row.edge_key.clone())
5667            .collect(),
5668        chunk_size,
5669    );
5670    push_sync_chunks(
5671        &mut chunks,
5672        "delete_nodes",
5673        node_tombstones.clone(),
5674        chunk_size,
5675    );
5676
5677    let mut diagnostics = vec![
5678        "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5679            .to_string(),
5680    ];
5681    if dry_run {
5682        diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5683    }
5684    if freshness.fail_closed {
5685        diagnostics.push(
5686            "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5687                .to_string(),
5688        );
5689    }
5690
5691    Ok(ConvexSyncReport {
5692        root: root.to_string_lossy().to_string(),
5693        scope: scope.map(str::to_string),
5694        graph_db: graph_db.to_string_lossy().to_string(),
5695        dry_run,
5696        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5697        projection_hash: convex_projection_hash(&local, scope),
5698        required_indexes: convex_required_indexes(),
5699        node_upserts,
5700        edge_upserts,
5701        node_tombstones,
5702        edge_tombstones,
5703        chunks,
5704        freshness,
5705        transport: None,
5706        receipts: Vec::new(),
5707        diagnostics,
5708        warnings: graph.warnings,
5709    })
5710}
5711
5712#[cfg(test)]
5713fn build_convex_sync_report(
5714    path: &Path,
5715    scope: Option<&str>,
5716    snapshot_path: Option<&Path>,
5717    chunk_size: usize,
5718) -> Result<ConvexSyncReport> {
5719    let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5720    build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5721}
5722
5723pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5724    if compact {
5725        println!(
5726            "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5727            report.node_upserts.len(),
5728            report.node_tombstones.len(),
5729            report.edge_upserts.len(),
5730            report.edge_tombstones.len(),
5731            report.chunks.len(),
5732            report.freshness.status
5733        );
5734        return;
5735    }
5736
5737    println!(
5738        "Convex graph sync {}",
5739        if report.dry_run { "dry-run" } else { "apply" }
5740    );
5741    println!("root: {}", report.root);
5742    println!("graph_db: {}", report.graph_db);
5743    println!(
5744        "upserts: {} node(s), {} edge(s)",
5745        report.node_upserts.len(),
5746        report.edge_upserts.len()
5747    );
5748    println!(
5749        "tombstones: {} node(s), {} edge(s)",
5750        report.node_tombstones.len(),
5751        report.edge_tombstones.len()
5752    );
5753    println!("chunks: {}", report.chunks.len());
5754    println!("freshness: {}", report.freshness.status);
5755    if let Some(transport) = &report.transport {
5756        println!(
5757            "transport: endpoint_env={} auth_env={} applied_chunks={}",
5758            transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5759        );
5760    }
5761    for receipt in &report.receipts {
5762        println!(
5763            "receipt: {} chunk {} attempt {} {}",
5764            receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5765        );
5766    }
5767    for diagnostic in report
5768        .diagnostics
5769        .iter()
5770        .chain(report.freshness.diagnostics.iter())
5771    {
5772        println!("- {}", diagnostic);
5773    }
5774}
5775
5776pub(crate) struct ConvexSyncOptions<'a> {
5777    path: &'a Path,
5778    scope: Option<&'a str>,
5779    snapshot: Option<&'a Path>,
5780    chunk_size: usize,
5781    remote_snapshot: bool,
5782    apply: bool,
5783    endpoint: Option<&'a str>,
5784    auth_token_env: &'a str,
5785}
5786
5787#[derive(Serialize)]
5788struct GraphDbSchemaField {
5789    name: &'static str,
5790    value_type: &'static str,
5791    description: &'static str,
5792}
5793
5794#[derive(Serialize)]
5795struct GraphDbSchemaOperation {
5796    command: &'static str,
5797    description: &'static str,
5798}
5799
5800#[derive(Serialize)]
5801struct GraphDbSchemaContract {
5802    name: &'static str,
5803    version: &'static str,
5804    description: &'static str,
5805}
5806
5807#[derive(Serialize)]
5808struct GraphDbSchema {
5809    contract_versions: Vec<GraphDbSchemaContract>,
5810    node_fields: Vec<GraphDbSchemaField>,
5811    edge_fields: Vec<GraphDbSchemaField>,
5812    operations: Vec<GraphDbSchemaOperation>,
5813}
5814
5815#[derive(Clone, Serialize, Deserialize)]
5816struct GraphDbFreshnessReport {
5817    status: String,
5818    fail_closed: bool,
5819    projection_version: Option<String>,
5820    content_hash: Option<String>,
5821    source_watermark: Option<String>,
5822    diagnostics: Vec<String>,
5823}
5824
5825#[derive(Clone, Debug, Serialize)]
5826pub(crate) struct GraphEffectivenessReadiness {
5827    pub(crate) status: String,
5828    pub(crate) fail_closed: bool,
5829    pub(crate) reason: String,
5830    pub(crate) diagnostics: Vec<String>,
5831    pub(crate) next_commands: Vec<String>,
5832}
5833
5834#[derive(Clone, Debug, Serialize, PartialEq)]
5835struct GraphDbPropertyFilter {
5836    key: String,
5837    value: String,
5838}
5839
5840#[derive(Clone, Debug, Default)]
5841struct GraphDbQueryOptions {
5842    cursor: Option<String>,
5843    limit: Option<usize>,
5844    property_filters: Vec<GraphDbPropertyFilter>,
5845}
5846
5847#[derive(Clone, Debug, Serialize, PartialEq)]
5848struct GraphDbPageReport {
5849    #[serde(skip_serializing_if = "Option::is_none")]
5850    cursor: Option<String>,
5851    #[serde(skip_serializing_if = "Option::is_none")]
5852    limit: Option<usize>,
5853    #[serde(skip_serializing_if = "Option::is_none")]
5854    next_cursor: Option<String>,
5855    returned_nodes: usize,
5856    returned_edges: usize,
5857    truncated: bool,
5858    property_filters: Vec<GraphDbPropertyFilter>,
5859    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5860    diagnostics: Vec<String>,
5861}
5862
5863type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5864
5865#[derive(Clone, Debug, Serialize)]
5866struct CommunityTruncationSummary {
5867    total_communities: usize,
5868    fully_kept: usize,
5869    partially_pruned: usize,
5870    fully_pruned: usize,
5871    pruned_community_kinds: Vec<String>,
5872    pruned_community_top_labels: Vec<String>,
5873}
5874
5875#[derive(Clone, Debug, Serialize)]
5876struct GraphDbRankedNeighborhoodComparison {
5877    traversal_nodes: usize,
5878    traversal_edges: usize,
5879    pruned_count: usize,
5880    total_discovered: usize,
5881    latency_micros: u128,
5882    overlap_with_unranked_pct: f64,
5883    useful_hit_density_ranked: f64,
5884    useful_hit_density_unranked: f64,
5885    duplicate_name_count_ranked: usize,
5886    duplicate_name_count_unranked: usize,
5887    handle_coverage_ranked_pct: f64,
5888    handle_coverage_unranked_pct: f64,
5889    #[serde(skip_serializing_if = "Option::is_none")]
5890    community_truncation_summary: Option<CommunityTruncationSummary>,
5891    diagnostics: Vec<String>,
5892}
5893
5894#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5895struct GraphDbDroppedByBudget {
5896    item: String,
5897    kind: String,
5898    dropped: usize,
5899    reason: String,
5900}
5901
5902#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5903struct GraphDbOutputBudgetReport {
5904    max_tokens: usize,
5905    estimated_tokens: usize,
5906    selected_nodes: usize,
5907    selected_edges: usize,
5908    candidate_nodes: usize,
5909    candidate_edges: usize,
5910    dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5911    diagnostics: Vec<String>,
5912}
5913
5914#[derive(Clone, Debug, Serialize, PartialEq)]
5915struct GraphDbKnowledgeRetrieval {
5916    mode: String,
5917    query: String,
5918    seed_kind: String,
5919    seed_limit: usize,
5920    seed_count: usize,
5921    depth: usize,
5922    limit: usize,
5923    node_count: usize,
5924    edge_count: usize,
5925    truncated: bool,
5926    traversal: String,
5927    freshness_boundary: String,
5928    privacy_boundary: String,
5929    diagnostics: Vec<String>,
5930}
5931
5932struct GraphDbSemanticSeededSubgraph {
5933    nodes: Vec<SubstrateGraphNode>,
5934    edges: Vec<SubstrateGraphEdge>,
5935    truncated: bool,
5936    diagnostics: Vec<String>,
5937}
5938
5939type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5940
5941#[derive(Serialize)]
5942struct GraphDbReport {
5943    root: String,
5944    #[serde(skip_serializing_if = "Option::is_none")]
5945    scope: Option<String>,
5946    backend: String,
5947    query: String,
5948    freshness: GraphDbFreshnessReport,
5949    #[serde(skip_serializing_if = "Option::is_none")]
5950    readiness: Option<GraphEffectivenessReadiness>,
5951    #[serde(skip_serializing_if = "Option::is_none")]
5952    schema: Option<GraphDbSchema>,
5953    #[serde(skip_serializing_if = "Option::is_none")]
5954    node: Option<SubstrateTerseGraphNode>,
5955    #[serde(skip_serializing_if = "Option::is_none")]
5956    edge: Option<SubstrateTerseGraphEdge>,
5957    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5958    nodes: Vec<SubstrateTerseGraphNode>,
5959    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5960    edges: Vec<SubstrateTerseGraphEdge>,
5961    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5962    ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5963    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5964    semantic_related: Vec<SemanticRelatedItem>,
5965    #[serde(skip_serializing_if = "Option::is_none")]
5966    neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5967    #[serde(skip_serializing_if = "Option::is_none")]
5968    ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5969    #[serde(skip_serializing_if = "Option::is_none")]
5970    knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5971    #[serde(skip_serializing_if = "Option::is_none")]
5972    output_budget: Option<GraphDbOutputBudgetReport>,
5973    #[serde(skip_serializing_if = "Option::is_none")]
5974    path: Option<substrate::GraphPath>,
5975    #[serde(skip_serializing_if = "Option::is_none")]
5976    page: Option<GraphDbPageReport>,
5977    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5978    warnings: Vec<String>,
5979}
5980
5981struct ExperimentalReadOnlyGraphStore {
5982    backend: GraphDbExperimentalBackend,
5983    nodes: BTreeMap<String, SubstrateGraphNode>,
5984    edges: BTreeMap<String, SubstrateGraphEdge>,
5985    node_ids_by_kind: BTreeMap<String, Vec<String>>,
5986    outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5987}
5988
5989impl ExperimentalReadOnlyGraphStore {
5990    fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5991        validate_convex_projection_rows(rows)?;
5992        let nodes = rows
5993            .nodes
5994            .iter()
5995            .map(|row| {
5996                let node = SubstrateGraphNode {
5997                    id: row.external_id.clone(),
5998                    kind: row.kind.clone(),
5999                    label: row.label.clone(),
6000                    properties: row.properties.clone(),
6001                    provenance: row.provenance.clone(),
6002                    freshness: row.freshness.clone(),
6003                };
6004                (node.id.clone(), node)
6005            })
6006            .collect::<BTreeMap<_, _>>();
6007        let edges = rows
6008            .edges
6009            .iter()
6010            .map(|row| {
6011                let edge = SubstrateGraphEdge {
6012                    id: row.edge_key.clone(),
6013                    from_id: row.from_external_id.clone(),
6014                    to_id: row.to_external_id.clone(),
6015                    kind: row.kind.clone(),
6016                    properties: row.properties.clone(),
6017                    provenance: row.provenance.clone(),
6018                    freshness: row.freshness.clone(),
6019                };
6020                (graph_db_edge_key(&edge), edge)
6021            })
6022            .collect::<BTreeMap<_, _>>();
6023        let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
6024        for node in nodes.values() {
6025            node_ids_by_kind
6026                .entry(node.kind.clone())
6027                .or_default()
6028                .push(node.id.clone());
6029        }
6030        for ids in node_ids_by_kind.values_mut() {
6031            ids.sort();
6032        }
6033        let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
6034        for edge in edges.values() {
6035            outgoing_edge_keys_by_from
6036                .entry(edge.from_id.clone())
6037                .or_default()
6038                .push(graph_db_edge_key(edge));
6039        }
6040        for edge_keys in outgoing_edge_keys_by_from.values_mut() {
6041            edge_keys.sort_by(|left_key, right_key| {
6042                let left = &edges[left_key];
6043                let right = &edges[right_key];
6044                left.to_id
6045                    .cmp(&right.to_id)
6046                    .then(left.kind.cmp(&right.kind))
6047                    .then(left_key.cmp(right_key))
6048            });
6049        }
6050        Ok(Self {
6051            backend,
6052            nodes,
6053            edges,
6054            node_ids_by_kind,
6055            outgoing_edge_keys_by_from,
6056        })
6057    }
6058}
6059
6060impl GraphStore for ExperimentalReadOnlyGraphStore {
6061    fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
6062        bail!("{} backend-eval adapter is read-only", self.backend.name())
6063    }
6064
6065    fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
6066        bail!("{} backend-eval adapter is read-only", self.backend.name())
6067    }
6068
6069    fn delete_node(&self, _id: &str) -> Result<usize> {
6070        bail!("{} backend-eval adapter is read-only", self.backend.name())
6071    }
6072
6073    fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
6074        bail!("{} backend-eval adapter is read-only", self.backend.name())
6075    }
6076
6077    fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
6078        Ok(self.nodes.get(id).cloned())
6079    }
6080
6081    fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
6082        Ok(self.nodes.values().cloned().collect())
6083    }
6084
6085    fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
6086        let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
6087        edges.sort_by(|left, right| {
6088            left.from_id
6089                .cmp(&right.from_id)
6090                .then(left.kind.cmp(&right.kind))
6091                .then(left.to_id.cmp(&right.to_id))
6092        });
6093        Ok(edges)
6094    }
6095
6096    fn graph_counts(&self) -> Result<(usize, usize)> {
6097        Ok((self.nodes.len(), self.edges.len()))
6098    }
6099
6100    fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
6101        let mut edges = self
6102            .edges
6103            .values()
6104            .filter(|edge| edge.from_id != edge.to_id)
6105            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6106            .cloned()
6107            .collect::<Vec<_>>();
6108        edges.sort_by(|left, right| {
6109            left.from_id
6110                .cmp(&right.from_id)
6111                .then(left.kind.cmp(&right.kind))
6112                .then(left.to_id.cmp(&right.to_id))
6113        });
6114        Ok(edges.into_iter().next())
6115    }
6116
6117    fn sample_edge_with_property(
6118        &self,
6119    ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
6120        Ok(self
6121            .edges
6122            .values()
6123            .filter(|edge| edge.from_id != edge.to_id)
6124            .filter_map(|edge| {
6125                edge.properties.iter().next().map(|(key, value)| {
6126                    (
6127                        edge,
6128                        GraphPropertyFilter {
6129                            key: key.clone(),
6130                            value: value.clone(),
6131                        },
6132                    )
6133                })
6134            })
6135            .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
6136                left_filter
6137                    .key
6138                    .cmp(&right_filter.key)
6139                    .then(left_filter.value.cmp(&right_filter.value))
6140                    .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
6141            })
6142            .map(|(edge, filter)| (edge.clone(), filter)))
6143    }
6144
6145    fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
6146        Ok(self
6147            .node_ids_by_kind
6148            .get(kind)
6149            .into_iter()
6150            .flatten()
6151            .filter_map(|id| self.nodes.get(id).cloned())
6152            .collect())
6153    }
6154
6155    fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
6156        Ok(self
6157            .outgoing_edge_keys_by_from
6158            .get(from_id)
6159            .into_iter()
6160            .flatten()
6161            .filter_map(|key| self.edges.get(key))
6162            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6163            .cloned()
6164            .collect())
6165    }
6166
6167    fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
6168        Ok(self
6169            .edges
6170            .values()
6171            .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
6172            .cloned()
6173            .collect())
6174    }
6175
6176    fn shortest_path(
6177        &self,
6178        from_id: &str,
6179        to_id: &str,
6180        kind: Option<&str>,
6181    ) -> Result<Option<substrate::GraphPath>> {
6182        if from_id == to_id {
6183            return Ok(Some(substrate::GraphPath {
6184                nodes: vec![from_id.to_string()],
6185                hops: 0,
6186            }));
6187        }
6188
6189        let mut queue = VecDeque::new();
6190        let mut parent = BTreeMap::<String, String>::new();
6191        parent.insert(from_id.to_string(), String::new());
6192        queue.push_back(from_id.to_string());
6193
6194        while let Some(current) = queue.pop_front() {
6195            for edge in self.outgoing_edges(&current, kind)? {
6196                if parent.contains_key(&edge.to_id) {
6197                    continue;
6198                }
6199                parent.insert(edge.to_id.clone(), current.clone());
6200                if edge.to_id == to_id {
6201                    let mut nodes = vec![to_id.to_string()];
6202                    let mut cursor = to_id;
6203                    while let Some(previous) = parent.get(cursor) {
6204                        if previous.is_empty() {
6205                            break;
6206                        }
6207                        nodes.push(previous.clone());
6208                        cursor = previous;
6209                    }
6210                    nodes.reverse();
6211                    return Ok(Some(substrate::GraphPath {
6212                        hops: nodes.len().saturating_sub(1),
6213                        nodes,
6214                    }));
6215                }
6216                queue.push_back(edge.to_id);
6217            }
6218        }
6219
6220        Ok(None)
6221    }
6222
6223    fn reachable_nodes_by_kinds(
6224        &self,
6225        from_id: &str,
6226        kinds: &[&str],
6227        depth: usize,
6228        limit: usize,
6229    ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
6230        let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
6231        let mut rows = requested
6232            .iter()
6233            .map(|kind| {
6234                (
6235                    (*kind).to_string(),
6236                    BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
6237                )
6238            })
6239            .collect::<BTreeMap<_, _>>();
6240        if requested.is_empty() {
6241            return Ok(BTreeMap::new());
6242        }
6243
6244        let mut seen = BTreeSet::from([from_id.to_string()]);
6245        let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
6246        while let Some((current, path)) = queue.pop_front() {
6247            let current_depth = path.len().saturating_sub(1);
6248            if current_depth >= depth {
6249                continue;
6250            }
6251            for edge in self.outgoing_edges(&current, None)? {
6252                if !seen.insert(edge.to_id.clone()) {
6253                    continue;
6254                }
6255                let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
6256                    continue;
6257                };
6258                let mut next_path = path.clone();
6259                next_path.push(edge.to_id.clone());
6260                let graph_path = substrate::GraphPath {
6261                    hops: next_path.len().saturating_sub(1),
6262                    nodes: next_path.clone(),
6263                };
6264                if requested.contains(node.kind.as_str()) {
6265                    rows.entry(node.kind.clone())
6266                        .or_default()
6267                        .entry(node.id.clone())
6268                        .or_insert((node.clone(), graph_path));
6269                }
6270                queue.push_back((edge.to_id, next_path));
6271            }
6272        }
6273
6274        Ok(rows
6275            .into_iter()
6276            .map(|(kind, values)| {
6277                let mut values = values.into_values().collect::<Vec<_>>();
6278                values.sort_by(|(left_node, left_path), (right_node, right_path)| {
6279                    left_path
6280                        .hops
6281                        .cmp(&right_path.hops)
6282                        .then(left_node.label.cmp(&right_node.label))
6283                        .then(left_node.id.cmp(&right_node.id))
6284                });
6285                if limit > 0 && values.len() > limit {
6286                    values.truncate(limit);
6287                }
6288                (kind, values)
6289            })
6290            .collect())
6291    }
6292}
6293
6294pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
6295pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
6296pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
6297const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
6298pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
6299const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
6300const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
6301const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
6302const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
6303
6304#[derive(Clone, Serialize, Deserialize)]
6305pub(crate) struct GraphDbBackendEvalPhaseTiming {
6306    name: String,
6307    duration_micros: u128,
6308    detail: String,
6309}
6310
6311#[derive(Serialize, Deserialize)]
6312struct GraphDbBackendEvalFullProjectionCache {
6313    version: String,
6314    key: String,
6315    source_watermark: String,
6316    projection: GraphProjection,
6317    warnings: Vec<String>,
6318}
6319
6320#[derive(Clone, Default)]
6321struct GraphDbBackendEvalFullProjectionCacheStats {
6322    hit: bool,
6323    disk_bytes: u64,
6324    json_bytes: u64,
6325    pruned_files: usize,
6326    pruned_bytes: u64,
6327}
6328
6329#[derive(Serialize)]
6330struct GraphDbBackendEvalRawSourceWatermarkRow {
6331    path: String,
6332    bytes: u64,
6333    content_hash: String,
6334}
6335
6336#[derive(Clone)]
6337struct GraphDbBackendEvalFullProjectionSourceWatermark {
6338    value: String,
6339    detail: String,
6340}
6341
6342#[derive(Serialize)]
6343pub(crate) struct GraphDbBackendEvalConfig {
6344    high_degree_nodes: usize,
6345    high_degree_fanout: usize,
6346    deep_chain_nodes: usize,
6347    deep_chain_fanout: usize,
6348    depth: usize,
6349    limit: usize,
6350    impact_limit: usize,
6351    path_max_hops: usize,
6352    path_direct_hop_budget: usize,
6353    path_deep_chain_hop_budget: usize,
6354    path_extended_hop_budgets: Vec<usize>,
6355    path_hop_policy: String,
6356    path_probe_strategy: String,
6357    path_query_plan_checks: Vec<String>,
6358    full_projection_enabled: bool,
6359    full_projection_profile: String,
6360    normalization_row_unit: usize,
6361}
6362
6363#[derive(Clone)]
6364struct GraphDbBackendEvalSignature {
6365    operation: String,
6366    value: serde_json::Value,
6367}
6368
6369#[derive(Serialize)]
6370struct GraphDbBackendEvalOperation {
6371    name: String,
6372    supported: bool,
6373    status: String,
6374    duration_micros: u128,
6375    #[serde(skip_serializing_if = "Option::is_none")]
6376    rows: Option<usize>,
6377    #[serde(skip_serializing_if = "Option::is_none")]
6378    error: Option<String>,
6379}
6380
6381#[derive(Serialize)]
6382struct GraphDbBackendEvalParity {
6383    matches_sqlite: bool,
6384    diagnostics: Vec<String>,
6385}
6386
6387#[derive(Serialize)]
6388struct GraphDbBackendEvalBackendReport {
6389    backend: String,
6390    adapter: String,
6391    read_only: bool,
6392    projection_load: String,
6393    operations: Vec<GraphDbBackendEvalOperation>,
6394    total_micros: u128,
6395    parity: GraphDbBackendEvalParity,
6396    lock_behavior: String,
6397    install_portability: String,
6398}
6399
6400#[derive(Serialize)]
6401struct GraphDbBackendEvalDataset {
6402    name: String,
6403    target_count: usize,
6404    nodes: usize,
6405    edges: usize,
6406    backends: Vec<GraphDbBackendEvalBackendReport>,
6407}
6408
6409#[derive(Serialize)]
6410struct GraphDbBackendPromotionDecision {
6411    backend: String,
6412    decision: String,
6413    reasons: Vec<String>,
6414    gate: GraphDbBackendPromotionGate,
6415}
6416
6417#[derive(Serialize)]
6418struct GraphDbBackendEvalPerformanceGate {
6419    baseline_fixture: String,
6420    ci_profile: String,
6421    opt_in_real_profile: String,
6422    full_projection_cache_hit_gate: String,
6423    allowed_regression_percent: f64,
6424    minimum_sample_runs: usize,
6425    normalized_metric_unit: String,
6426    required_metrics: Vec<String>,
6427    digest_command: String,
6428    repeated_sample_command: String,
6429    hop_cap_promotion: GraphDbHopCapPromotionGate,
6430    backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
6431}
6432
6433#[derive(Serialize)]
6434struct GraphDbHopCapPromotionGate {
6435    status: String,
6436    current_default_hops: usize,
6437    candidate_hop_tiers: Vec<usize>,
6438    required_backend: String,
6439    required_workloads: Vec<String>,
6440    required_metrics: Vec<String>,
6441    allowed_regression_percent: f64,
6442    minimum_sample_runs: usize,
6443    decision_rule: String,
6444}
6445
6446#[derive(Serialize)]
6447struct GraphDbBackendAdapterSpikeGate {
6448    status: String,
6449    candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
6450    required_workloads: Vec<String>,
6451    required_checks: Vec<String>,
6452    decision_rule: String,
6453    evidence_plan: String,
6454}
6455
6456#[derive(Serialize)]
6457struct GraphDbBackendAdapterSpikeCandidate {
6458    backend: String,
6459    adapter_label: String,
6460    projection_load: String,
6461    lock_behavior: String,
6462    install_portability: String,
6463}
6464
6465#[derive(Serialize)]
6466pub(crate) struct GraphDbBackendEvalReport {
6467    root: String,
6468    #[serde(skip_serializing_if = "Option::is_none")]
6469    scope: Option<String>,
6470    label: String,
6471    baseline_backend: String,
6472    candidates: Vec<String>,
6473    targets: Vec<String>,
6474    config: GraphDbBackendEvalConfig,
6475    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6476    datasets: Vec<GraphDbBackendEvalDataset>,
6477    promotion: Vec<GraphDbBackendPromotionDecision>,
6478    performance_gate: GraphDbBackendEvalPerformanceGate,
6479    metrics: BTreeMap<String, f64>,
6480    metric_digest_command: String,
6481    warnings: Vec<String>,
6482}
6483
6484#[derive(Clone, Debug, Serialize)]
6485struct GraphDbDoctorCheck {
6486    name: String,
6487    status: String,
6488    fail_closed: bool,
6489    diagnostics: Vec<String>,
6490    repair_commands: Vec<String>,
6491}
6492
6493#[derive(Serialize)]
6494pub(crate) struct GraphDbDoctorReport {
6495    root: String,
6496    #[serde(skip_serializing_if = "Option::is_none")]
6497    scope: Option<String>,
6498    backend: String,
6499    graph_db: String,
6500    #[serde(skip_serializing_if = "Option::is_none")]
6501    convex_snapshot: Option<String>,
6502    status: String,
6503    fail_closed: bool,
6504    checks: Vec<GraphDbDoctorCheck>,
6505    repair_commands: Vec<String>,
6506    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6507    required_indexes: Vec<ConvexRequiredIndex>,
6508}
6509
6510#[derive(Serialize)]
6511struct GraphDbDriftSummary {
6512    node_upserts: usize,
6513    edge_upserts: usize,
6514    node_tombstones: usize,
6515    edge_tombstones: usize,
6516    stale_nodes: usize,
6517    stale_edges: usize,
6518    stale_projection_metadata: usize,
6519    duplicate_failures: usize,
6520    orphan_failures: usize,
6521    missing_required_indexes: usize,
6522}
6523
6524#[derive(Serialize)]
6525struct GraphDbDriftReport {
6526    root: String,
6527    #[serde(skip_serializing_if = "Option::is_none")]
6528    scope: Option<String>,
6529    graph_db: String,
6530    convex_snapshot: String,
6531    status: String,
6532    graph_reads_allowed: bool,
6533    projection_version: String,
6534    local_hash: Option<String>,
6535    snapshot_hash: Option<String>,
6536    summary: GraphDbDriftSummary,
6537    node_upserts: Vec<String>,
6538    edge_upserts: Vec<String>,
6539    node_tombstones: Vec<String>,
6540    edge_tombstones: Vec<String>,
6541    stale_nodes: Vec<String>,
6542    stale_edges: Vec<String>,
6543    diagnostics: Vec<String>,
6544    next_commands: Vec<String>,
6545    required_indexes: Vec<ConvexRequiredIndex>,
6546    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6547    warnings: Vec<String>,
6548}
6549
6550#[derive(Clone, Serialize)]
6551struct GraphDbTombstoneCounts {
6552    nodes: usize,
6553    edges: usize,
6554    total: usize,
6555}
6556
6557#[derive(Clone, Serialize)]
6558struct GraphDbOperatorCounts {
6559    nodes: usize,
6560    edges: usize,
6561    tombstones: GraphDbTombstoneCounts,
6562    #[serde(skip_serializing_if = "Option::is_none")]
6563    file_size_bytes: Option<u64>,
6564    #[serde(skip_serializing_if = "Option::is_none")]
6565    freelist_bytes: Option<u64>,
6566}
6567
6568#[derive(Clone, Serialize)]
6569struct GraphDbCompactionPolicy {
6570    status: String,
6571    tombstone_scan_rows: usize,
6572    live_rows: usize,
6573    file_size_bytes: Option<u64>,
6574    freelist_bytes: Option<u64>,
6575    safe_to_prune_tombstones: bool,
6576    requires_convex_reconciliation: bool,
6577    recommendations: Vec<String>,
6578    proof: Vec<String>,
6579}
6580
6581#[derive(Serialize)]
6582pub(crate) struct GraphDbRefreshSummary {
6583    scope: String,
6584    projection_version: String,
6585    mode: String,
6586    #[serde(skip_serializing_if = "Option::is_none")]
6587    source_watermark: Option<String>,
6588    tombstoned_nodes: usize,
6589    tombstoned_edges: usize,
6590    upserted_nodes: usize,
6591    upserted_edges: usize,
6592    unchanged_nodes: usize,
6593    unchanged_edges: usize,
6594    upserted_properties: usize,
6595    unchanged_properties: usize,
6596    deleted_properties: usize,
6597    deleted_nodes: usize,
6598    deleted_edges: usize,
6599    pruned_tombstones: usize,
6600    #[serde(skip_serializing_if = "Option::is_none")]
6601    file_size_bytes_before: Option<u64>,
6602    #[serde(skip_serializing_if = "Option::is_none")]
6603    file_size_bytes_after: Option<u64>,
6604    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6605    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6606}
6607
6608#[derive(Serialize)]
6609struct GraphDbOperatorReport {
6610    root: String,
6611    #[serde(skip_serializing_if = "Option::is_none")]
6612    scope: Option<String>,
6613    graph_db: String,
6614    operation: String,
6615    status: String,
6616    materialized: bool,
6617    freshness: GraphDbFreshnessReport,
6618    readiness: GraphEffectivenessReadiness,
6619    counts: GraphDbOperatorCounts,
6620    #[serde(skip_serializing_if = "Option::is_none")]
6621    refresh: Option<GraphDbRefreshSummary>,
6622    compaction: GraphDbCompactionPolicy,
6623    #[serde(skip_serializing_if = "Option::is_none")]
6624    recovery: Option<index::ReadOnlyRecovery>,
6625    next_commands: Vec<String>,
6626    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6627    warnings: Vec<String>,
6628}
6629
6630#[derive(Serialize)]
6631pub(crate) struct GraphDbCompactionReport {
6632    root: String,
6633    #[serde(skip_serializing_if = "Option::is_none")]
6634    scope: Option<String>,
6635    graph_db: String,
6636    applied: bool,
6637    pruned_tombstones: usize,
6638    counts_before: GraphDbOperatorCounts,
6639    counts_after: GraphDbOperatorCounts,
6640    compaction_before: GraphDbCompactionPolicy,
6641    compaction_after: GraphDbCompactionPolicy,
6642    reclaimed_bytes: i64,
6643    next_commands: Vec<String>,
6644    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6645    warnings: Vec<String>,
6646}
6647
6648#[derive(Clone, Serialize, Deserialize)]
6649struct GraphDbEvidencePath {
6650    to: String,
6651    kind: String,
6652    label: String,
6653    #[serde(skip_serializing_if = "Option::is_none")]
6654    path: Option<substrate::GraphPath>,
6655    #[serde(skip_serializing_if = "Option::is_none")]
6656    expand: Option<String>,
6657}
6658
6659#[derive(Clone, Serialize, Deserialize)]
6660struct GraphDbFixtureCoverage {
6661    test: String,
6662    fixture: String,
6663    assertions: Vec<String>,
6664}
6665
6666#[derive(Clone, Serialize, Deserialize)]
6667struct GraphDbEvidenceReport {
6668    root: String,
6669    #[serde(skip_serializing_if = "Option::is_none")]
6670    scope: Option<String>,
6671    backend: String,
6672    contract_version: String,
6673    target: String,
6674    packet_id: String,
6675    #[serde(skip_serializing_if = "Option::is_none")]
6676    projection_hash: Option<String>,
6677    freshness: GraphDbFreshnessReport,
6678    target_node: SubstrateTerseGraphNode,
6679    worker_context: Vec<SubstrateTerseGraphNode>,
6680    source_handles: Vec<SubstrateTerseGraphNode>,
6681    worker_results: Vec<SubstrateTerseGraphNode>,
6682    semantic_related: Vec<SubstrateTerseGraphNode>,
6683    shortest_paths: Vec<GraphDbEvidencePath>,
6684    #[serde(skip_serializing_if = "Option::is_none")]
6685    output_budget: Option<GraphDbOutputBudgetReport>,
6686    #[serde(default)]
6687    truncated: bool,
6688    #[serde(skip_serializing_if = "Option::is_none")]
6689    next_cursor: Option<String>,
6690    next_commands: Vec<String>,
6691    replay_commands: Vec<String>,
6692    repair_commands: Vec<String>,
6693    fixture_coverage: GraphDbFixtureCoverage,
6694    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6695    warnings: Vec<String>,
6696}
6697
6698pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6699    root: &'a Path,
6700    scope: Option<&'a str>,
6701    backend: &'a str,
6702    target: &'a str,
6703    preferred_path: Option<&'a str>,
6704    depth: usize,
6705    limit: usize,
6706    cursor: Option<&'a str>,
6707    store: &'a S,
6708    freshness: GraphDbFreshnessReport,
6709    warnings: Vec<String>,
6710}
6711
6712impl GraphDbDoctorReport {
6713    fn new(
6714        root: &Path,
6715        scope: Option<&str>,
6716        backend: &str,
6717        graph_db: &Path,
6718        convex_snapshot: Option<&Path>,
6719    ) -> Self {
6720        Self {
6721            root: root.to_string_lossy().to_string(),
6722            scope: scope.map(str::to_string),
6723            backend: backend.to_string(),
6724            graph_db: graph_db.to_string_lossy().to_string(),
6725            convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6726            status: "ok".to_string(),
6727            fail_closed: false,
6728            checks: Vec::new(),
6729            repair_commands: Vec::new(),
6730            required_indexes: Vec::new(),
6731        }
6732    }
6733
6734    fn push_check(&mut self, check: GraphDbDoctorCheck) {
6735        self.checks.push(check);
6736    }
6737
6738    fn finalize(&mut self) {
6739        self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6740        self.status = if self.fail_closed {
6741            "fail_closed"
6742        } else {
6743            "ok"
6744        }
6745        .to_string();
6746        let mut commands = BTreeSet::new();
6747        for check in &self.checks {
6748            commands.extend(check.repair_commands.iter().cloned());
6749        }
6750        self.repair_commands = commands.into_iter().collect();
6751    }
6752
6753    fn summary(&self) -> String {
6754        self.checks
6755            .iter()
6756            .filter(|check| check.fail_closed)
6757            .flat_map(|check| check.diagnostics.iter())
6758            .take(3)
6759            .cloned()
6760            .collect::<Vec<_>>()
6761            .join("; ")
6762    }
6763}
6764
6765fn graph_db_doctor_check(
6766    name: impl Into<String>,
6767    diagnostics: Vec<String>,
6768    repair_commands: Vec<String>,
6769) -> GraphDbDoctorCheck {
6770    let fail_closed = !diagnostics.is_empty();
6771    GraphDbDoctorCheck {
6772        name: name.into(),
6773        status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6774        fail_closed,
6775        diagnostics,
6776        repair_commands: if fail_closed {
6777            repair_commands
6778        } else {
6779            Vec::new()
6780        },
6781    }
6782}
6783
6784pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6785    scope
6786        .map(|scope| format!(" --scope {}", shell_quote(scope)))
6787        .unwrap_or_default()
6788}
6789
6790fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6791    format!(
6792        "tsift graph-db --path {}{} refresh --json",
6793        shell_quote(root.to_string_lossy().as_ref()),
6794        graph_db_scope_arg(scope)
6795    )
6796}
6797
6798fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6799    graph_db_refresh_command(root, scope)
6800}
6801
6802fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6803    let backup = format!("{}.bak", graph_db.to_string_lossy());
6804    format!(
6805        "mv {} {} && {}",
6806        shell_quote(graph_db.to_string_lossy().as_ref()),
6807        shell_quote(&backup),
6808        graph_db_rebuild_command(root, scope)
6809    )
6810}
6811
6812fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6813    format!(
6814        "tsift convex-sync {}{} --remote-snapshot --apply --json",
6815        shell_quote(root.to_string_lossy().as_ref()),
6816        graph_db_scope_arg(scope)
6817    )
6818}
6819
6820fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6821    substrate::open_graph_read_only_connection_resilient(graph_db)
6822}
6823
6824fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6825    conn.query_row(
6826        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6827        [table],
6828        |row| row.get::<_, bool>(0),
6829    )
6830    .map_err(Into::into)
6831}
6832
6833fn row_usize(row: &Row<'_>, idx: usize) -> rusqlite::Result<usize> {
6834    let value: i64 = row.get(idx)?;
6835    usize::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6836}
6837
6838fn row_u64(row: &Row<'_>, idx: usize) -> rusqlite::Result<u64> {
6839    let value: i64 = row.get(idx)?;
6840    u64::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6841}
6842
6843fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6844    let sql = match table {
6845        "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6846        "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6847        "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6848        other => bail!("unsupported graph count table {other}"),
6849    };
6850    conn.query_row(sql, [], |row| row_usize(row, 0))
6851        .map_err(Into::into)
6852}
6853
6854fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6855    if !sqlite_table_exists(conn, "graph_tombstones")? {
6856        return Ok(GraphDbTombstoneCounts {
6857            nodes: 0,
6858            edges: 0,
6859            total: 0,
6860        });
6861    }
6862    let mut stmt =
6863        conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6864    let mut rows = stmt.query([])?;
6865    let mut nodes = 0usize;
6866    let mut edges = 0usize;
6867    while let Some(row) = rows.next()? {
6868        let row_kind: String = row.get(0)?;
6869        let count = row_usize(row, 1)?;
6870        match row_kind.as_str() {
6871            "node" => nodes = count,
6872            "edge" => edges = count,
6873            _ => {}
6874        }
6875    }
6876    Ok(GraphDbTombstoneCounts {
6877        nodes,
6878        edges,
6879        total: nodes + edges,
6880    })
6881}
6882
6883fn sqlite_graph_counts_from_cache(
6884    conn: &Connection,
6885    scope: &str,
6886) -> Result<Option<GraphDbOperatorCounts>> {
6887    if !sqlite_table_exists(conn, "graph_operator_stats")? {
6888        return Ok(None);
6889    }
6890    let row = conn
6891        .query_row(
6892            r#"
6893        SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6894        FROM graph_operator_stats
6895        WHERE scope = ?1
6896        "#,
6897            [scope],
6898            |row| {
6899                Ok((
6900                    row_usize(row, 0)?,
6901                    row_usize(row, 1)?,
6902                    row_usize(row, 2)?,
6903                    row_usize(row, 3)?,
6904                    row.get::<_, Option<i64>>(4)?,
6905                    row.get::<_, Option<i64>>(5)?,
6906                ))
6907            },
6908        )
6909        .optional()?;
6910    Ok(row.map(
6911        |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6912            GraphDbOperatorCounts {
6913                nodes,
6914                edges,
6915                tombstones: GraphDbTombstoneCounts {
6916                    nodes: tombstone_nodes,
6917                    edges: tombstone_edges,
6918                    total: tombstone_nodes + tombstone_edges,
6919                },
6920                file_size_bytes: file_size_bytes
6921                    .and_then(|value| u64::try_from(value).ok())
6922                    .or_else(|| sqlite_database_size_bytes(conn).ok()),
6923                freelist_bytes: freelist_bytes
6924                    .and_then(|value| u64::try_from(value).ok())
6925                    .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6926            }
6927        },
6928    ))
6929}
6930
6931fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6932    if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6933        return Ok(counts);
6934    }
6935    let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6936        sqlite_known_table_count(conn, "graph_nodes")?
6937    } else {
6938        0
6939    };
6940    let edges = if sqlite_table_exists(conn, "graph_edges")? {
6941        sqlite_known_table_count(conn, "graph_edges")?
6942    } else {
6943        0
6944    };
6945    Ok(GraphDbOperatorCounts {
6946        nodes,
6947        edges,
6948        tombstones: sqlite_tombstone_counts(conn)?,
6949        file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6950        freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6951    })
6952}
6953
6954fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6955    if !sqlite_table_exists(conn, "graph_nodes")? {
6956        return Ok(0);
6957    }
6958    let count: i64 = conn.query_row(
6959        "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6960        [],
6961        |row| row.get(0),
6962    )?;
6963    Ok(count as usize)
6964}
6965
6966pub(crate) fn graph_db_compaction_policy(
6967    root: &Path,
6968    scope: Option<&str>,
6969    counts: &GraphDbOperatorCounts,
6970    prune_confirmed: bool,
6971) -> GraphDbCompactionPolicy {
6972    let live_rows = counts.nodes + counts.edges;
6973    let tombstone_scan_rows = counts.tombstones.total;
6974    let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6975    let freelist_heavy = counts
6976        .file_size_bytes
6977        .zip(counts.freelist_bytes)
6978        .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6979    let status = if tombstone_heavy || freelist_heavy {
6980        "recommended"
6981    } else {
6982        "not_needed"
6983    }
6984    .to_string();
6985    let mut recommendations = vec![
6986        convex_refresh_command(root, scope),
6987        graph_db_refresh_command(root, scope),
6988        format!(
6989            "tsift graph-db --path {}{} compact --apply --json",
6990            shell_quote(root.to_string_lossy().as_ref()),
6991            graph_db_scope_arg(scope)
6992        ),
6993    ];
6994    if prune_confirmed {
6995        recommendations.push(format!(
6996            "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6997            shell_quote(root.to_string_lossy().as_ref()),
6998            graph_db_scope_arg(scope)
6999        ));
7000    }
7001    let proof = vec![
7002        format!("{live_rows} live graph row(s)"),
7003        format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
7004        format!(
7005            "graph.db file_size={} byte(s), freelist={} byte(s)",
7006            counts.file_size_bytes.unwrap_or(0),
7007            counts.freelist_bytes.unwrap_or(0)
7008        ),
7009    ];
7010    GraphDbCompactionPolicy {
7011        status,
7012        tombstone_scan_rows,
7013        live_rows,
7014        file_size_bytes: counts.file_size_bytes,
7015        freelist_bytes: counts.freelist_bytes,
7016        safe_to_prune_tombstones: prune_confirmed,
7017        requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
7018        recommendations,
7019        proof,
7020    }
7021}
7022
7023fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
7024    let page_count = conn.query_row("PRAGMA page_count", [], |row| row_u64(row, 0))?;
7025    let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
7026    Ok(page_count.saturating_mul(page_size))
7027}
7028
7029fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
7030    let freelist_count = conn.query_row("PRAGMA freelist_count", [], |row| row_u64(row, 0))?;
7031    let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
7032    Ok(freelist_count.saturating_mul(page_size))
7033}
7034
7035fn sqlite_graph_tombstone_retention_diagnostics(
7036    conn: &Connection,
7037    scope: &str,
7038) -> Result<Vec<String>> {
7039    if !sqlite_table_exists(conn, "graph_tombstones")? {
7040        return Ok(Vec::new());
7041    }
7042    let cached = sqlite_graph_counts_from_cache(conn, scope)?;
7043    let counts = match cached.clone() {
7044        Some(counts) => counts,
7045        None => sqlite_graph_counts(conn, scope)?,
7046    };
7047    let live_rows = counts.nodes + counts.edges;
7048    let file_size = counts.file_size_bytes.unwrap_or(0);
7049    let freelist = counts.freelist_bytes.unwrap_or(0);
7050    let stale_live_tombstones = if cached.is_some() {
7051        0
7052    } else {
7053        let mut live_keys = BTreeSet::new();
7054        if sqlite_table_exists(conn, "graph_nodes")? {
7055            let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
7056            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7057                live_keys.insert(format!("node:{}", row?));
7058            }
7059        }
7060        if sqlite_table_exists(conn, "graph_edges")? {
7061            let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
7062            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7063                live_keys.insert(format!("edge:{}", row?));
7064            }
7065        }
7066        let mut stale_live_tombstones = 0usize;
7067        let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
7068        for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7069            if live_keys.contains(&row?) {
7070                stale_live_tombstones += 1;
7071            }
7072        }
7073        stale_live_tombstones
7074    };
7075
7076    let mut diagnostics = Vec::new();
7077    if stale_live_tombstones > 0 {
7078        diagnostics.push(format!(
7079            "{stale_live_tombstones} tombstone(s) reference rows that are live again; the next graph-db refresh prunes those stale tombstones before inserting new deletion markers"
7080        ));
7081    }
7082    if counts.tombstones.total > live_rows.max(1) {
7083        let source = if cached.is_some() {
7084            "cached refresh stats"
7085        } else {
7086            "live row scan"
7087        };
7088        diagnostics.push(format!(
7089            "tombstone retention exceeds live graph rows: {} tombstone(s) vs {} live row(s) from {}; graph.db file_size={} byte(s), freelist={} byte(s), status/doctor tombstone scans inspect {} extra row(s). Run convex-sync against the remote snapshot before rebuild/compaction if a remote consumer may still need deletion reconciliation.",
7090            counts.tombstones.total,
7091            live_rows,
7092            source,
7093            file_size,
7094            freelist,
7095            counts.tombstones.total
7096        ));
7097    }
7098    Ok(diagnostics)
7099}
7100
7101fn sqlite_graph_freshness_from_conn(
7102    conn: &Connection,
7103    scope: &str,
7104) -> Result<GraphDbFreshnessReport> {
7105    if !sqlite_table_exists(conn, "graph_projection_versions")? {
7106        return Ok(GraphDbFreshnessReport {
7107            status: "missing".to_string(),
7108            fail_closed: true,
7109            projection_version: None,
7110            content_hash: None,
7111            source_watermark: None,
7112            diagnostics: vec![
7113                "graph projection metadata table is missing; refresh graph.db before trusting reads"
7114                    .to_string(),
7115            ],
7116        });
7117    }
7118    let version = conn
7119        .query_row(
7120            r#"
7121            SELECT projection_version, content_hash, source_watermark
7122            FROM graph_projection_versions
7123            WHERE scope = ?1
7124            "#,
7125            [scope],
7126            |row| {
7127                Ok((
7128                    row.get::<_, String>(0)?,
7129                    row.get::<_, Option<String>>(1)?,
7130                    row.get::<_, Option<String>>(2)?,
7131                ))
7132            },
7133        )
7134        .optional()?;
7135    let Some((projection_version, content_hash, source_watermark)) = version else {
7136        return Ok(GraphDbFreshnessReport {
7137            status: "missing".to_string(),
7138            fail_closed: true,
7139            projection_version: None,
7140            content_hash: None,
7141            source_watermark: None,
7142            diagnostics: vec![
7143                "graph projection metadata is missing; refresh graph.db before trusting reads"
7144                    .to_string(),
7145            ],
7146        });
7147    };
7148
7149    let mut diagnostics = Vec::new();
7150    if projection_version != GRAPH_PROJECTION_VERSION {
7151        diagnostics.push(format!(
7152            "projection version mismatch: expected {} got {}",
7153            GRAPH_PROJECTION_VERSION, projection_version
7154        ));
7155    }
7156    if content_hash.is_none() {
7157        diagnostics.push("projection content hash is missing".to_string());
7158    }
7159    let fail_closed = !diagnostics.is_empty();
7160    Ok(GraphDbFreshnessReport {
7161        status: if fail_closed { "stale" } else { "current" }.to_string(),
7162        fail_closed,
7163        projection_version: Some(projection_version),
7164        content_hash,
7165        source_watermark,
7166        diagnostics,
7167    })
7168}
7169
7170fn graph_db_operator_next_commands(
7171    root: &Path,
7172    scope: Option<&str>,
7173    include_refresh: bool,
7174) -> Vec<String> {
7175    let mut commands = Vec::new();
7176    if include_refresh {
7177        commands.push(graph_db_refresh_command(root, scope));
7178    }
7179    commands.push(format!(
7180        "tsift graph-db --path {}{} doctor --json",
7181        shell_quote(root.to_string_lossy().as_ref()),
7182        graph_db_scope_arg(scope)
7183    ));
7184    commands.push(format!(
7185        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
7186        shell_quote(root.to_string_lossy().as_ref()),
7187        graph_db_scope_arg(scope)
7188    ));
7189    commands.push(format!(
7190        "tsift convex-sync {}{} --remote-snapshot --apply --json",
7191        shell_quote(root.to_string_lossy().as_ref()),
7192        graph_db_scope_arg(scope)
7193    ));
7194    commands
7195}
7196
7197pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
7198    match recovery {
7199        index::ReadOnlyRecovery::SnapshotFallback => {
7200            "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
7201        }
7202        index::ReadOnlyRecovery::SnapshotFallbackWal => {
7203            "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
7204        }
7205    }
7206}
7207
7208fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
7209    let mut stmt = conn.prepare(sql)?;
7210    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7211    let mut values = BTreeSet::new();
7212    for row in rows {
7213        values.insert(row?);
7214    }
7215    Ok(values)
7216}
7217
7218fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
7219    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
7220    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
7221    let mut columns = BTreeSet::new();
7222    for row in rows {
7223        columns.insert(row?);
7224    }
7225    Ok(columns)
7226}
7227
7228fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7229    let mut diagnostics = Vec::new();
7230    let user_version: i64 =
7231        conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
7232    if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
7233        diagnostics.push(format!(
7234            "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7235        ));
7236    } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
7237        diagnostics.push(format!(
7238            "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7239        ));
7240    }
7241
7242    let tables = sqlite_string_set(
7243        conn,
7244        "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
7245    )?;
7246    let required_tables = [
7247        (
7248            "graph_nodes",
7249            vec![
7250                "id",
7251                "kind",
7252                "label",
7253                "properties_json",
7254                "provenance_json",
7255                "freshness_json",
7256                "row_hash",
7257                "source_watermark",
7258            ],
7259        ),
7260        (
7261            "graph_edges",
7262            vec![
7263                "edge_key",
7264                "from_id",
7265                "to_id",
7266                "kind",
7267                "properties_json",
7268                "provenance_json",
7269                "freshness_json",
7270                "row_hash",
7271                "source_watermark",
7272            ],
7273        ),
7274        (
7275            "graph_projection_versions",
7276            vec![
7277                "scope",
7278                "projection_version",
7279                "content_hash",
7280                "source_watermark",
7281                "observed_at_unix",
7282            ],
7283        ),
7284        (
7285            "graph_tombstones",
7286            vec!["row_key", "row_kind", "deleted_at_unix"],
7287        ),
7288        ("graph_node_properties", vec!["node_id", "key", "value"]),
7289        ("graph_edge_properties", vec!["edge_key", "key", "value"]),
7290    ];
7291    for (table, required_columns) in required_tables {
7292        if !tables.contains(table) {
7293            diagnostics.push(format!("graph.db schema drift: missing table {table}"));
7294            continue;
7295        }
7296        let columns = sqlite_column_names(conn, table)?;
7297        for column in required_columns {
7298            if !columns.contains(column) {
7299                diagnostics.push(format!(
7300                    "graph.db schema drift: missing column {table}.{column}"
7301                ));
7302            }
7303        }
7304    }
7305
7306    let indexes = sqlite_string_set(
7307        conn,
7308        "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
7309    )?;
7310    for index in [
7311        "idx_graph_nodes_kind",
7312        "idx_graph_edges_from_kind",
7313        "idx_graph_edges_to_kind",
7314        "idx_graph_edges_edge_key",
7315        "idx_graph_node_properties_key_value_node",
7316        "idx_graph_edge_properties_key_value_edge",
7317    ] {
7318        if !indexes.contains(index) {
7319            diagnostics.push(format!("graph.db schema drift: missing index {index}"));
7320        }
7321    }
7322
7323    if tables.contains("graph_edges") {
7324        let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
7325        let rows = stmt.query_map([], |row| {
7326            Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
7327        })?;
7328        let mut fks = BTreeSet::new();
7329        for row in rows {
7330            fks.insert(row?);
7331        }
7332        for expected in [
7333            ("from_id".to_string(), "id".to_string()),
7334            ("to_id".to_string(), "id".to_string()),
7335        ] {
7336            if !fks.contains(&expected) {
7337                diagnostics.push(format!(
7338                    "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
7339                    expected.0, expected.1
7340                ));
7341            }
7342        }
7343    }
7344
7345    Ok(diagnostics)
7346}
7347
7348fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
7349    let mut stmt = conn.prepare(sql)?;
7350    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7351    let mut diagnostics = Vec::new();
7352    for row in rows {
7353        diagnostics.push(row?);
7354    }
7355    Ok(diagnostics)
7356}
7357
7358fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7359    let mut diagnostics = sqlite_query_diagnostics(
7360        conn,
7361        r#"
7362        SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
7363        FROM graph_nodes
7364        GROUP BY id
7365        HAVING COUNT(*) > 1
7366        ORDER BY id
7367        "#,
7368    )?;
7369    diagnostics.extend(sqlite_query_diagnostics(
7370        conn,
7371        r#"
7372        SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
7373        FROM graph_edges
7374        GROUP BY from_id, to_id, kind
7375        HAVING COUNT(*) > 1
7376        ORDER BY from_id, kind, to_id
7377        "#,
7378    )?);
7379    diagnostics.extend(sqlite_query_diagnostics(
7380        conn,
7381        r#"
7382        SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
7383        FROM graph_edges
7384        GROUP BY edge_key
7385        HAVING COUNT(*) > 1
7386        ORDER BY edge_key
7387        "#,
7388    )?);
7389    Ok(diagnostics)
7390}
7391
7392fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7393    sqlite_query_diagnostics(
7394        conn,
7395        r#"
7396        SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7397        FROM graph_edges e
7398        LEFT JOIN graph_nodes n ON n.id = e.from_id
7399        WHERE n.id IS NULL
7400        UNION ALL
7401        SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7402        FROM graph_edges e
7403        LEFT JOIN graph_nodes n ON n.id = e.to_id
7404        WHERE n.id IS NULL
7405        ORDER BY 1
7406        "#,
7407    )
7408}
7409
7410fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7411    let mut diagnostics = Vec::new();
7412    let mut node_stmt = conn.prepare(
7413        "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7414    )?;
7415    let node_rows = node_stmt.query_map([], |row| {
7416        Ok((
7417            row.get::<_, String>(0)?,
7418            row.get::<_, String>(1)?,
7419            row.get::<_, String>(2)?,
7420            row.get::<_, Option<String>>(3)?,
7421        ))
7422    })?;
7423    for row in node_rows {
7424        let (id, properties_json, provenance_json, freshness_json) = row?;
7425        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7426            diagnostics.push(format!(
7427                "graph_nodes {id} properties_json is invalid: {err}"
7428            ));
7429        }
7430        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7431            diagnostics.push(format!(
7432                "graph_nodes {id} provenance_json is invalid: {err}"
7433            ));
7434        }
7435        if let Some(freshness_json) = freshness_json
7436            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7437        {
7438            diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
7439        }
7440    }
7441
7442    let mut edge_stmt = conn.prepare(
7443        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7444    )?;
7445    let edge_rows = edge_stmt.query_map([], |row| {
7446        Ok((
7447            row.get::<_, String>(0)?,
7448            row.get::<_, String>(1)?,
7449            row.get::<_, String>(2)?,
7450            row.get::<_, String>(3)?,
7451            row.get::<_, String>(4)?,
7452            row.get::<_, String>(5)?,
7453            row.get::<_, Option<String>>(6)?,
7454        ))
7455    })?;
7456    for row in edge_rows {
7457        let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
7458            row?;
7459        let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
7460        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7461            diagnostics.push(format!(
7462                "graph_edges {edge} properties_json is invalid: {err}"
7463            ));
7464        }
7465        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7466            diagnostics.push(format!(
7467                "graph_edges {edge} provenance_json is invalid: {err}"
7468            ));
7469        }
7470        if let Some(freshness_json) = freshness_json
7471            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7472        {
7473            diagnostics.push(format!(
7474                "graph_edges {edge} freshness_json is invalid: {err}"
7475            ));
7476        }
7477    }
7478    Ok(diagnostics)
7479}
7480
7481fn sqlite_graph_projection_metadata_diagnostics(
7482    conn: &Connection,
7483    scope: Option<&str>,
7484) -> Result<Vec<String>> {
7485    let mut diagnostics = Vec::new();
7486    let scope_key = scope.unwrap_or("root");
7487    let version = conn
7488        .query_row(
7489            r#"
7490            SELECT projection_version, content_hash, source_watermark
7491            FROM graph_projection_versions
7492            WHERE scope = ?1
7493            "#,
7494            [scope_key],
7495            |row| {
7496                Ok((
7497                    row.get::<_, String>(0)?,
7498                    row.get::<_, Option<String>>(1)?,
7499                    row.get::<_, Option<String>>(2)?,
7500                ))
7501            },
7502        )
7503        .optional()?;
7504    let Some((projection_version, content_hash, _source_watermark)) = version else {
7505        diagnostics.push(format!(
7506            "graph projection metadata is missing for scope {scope_key}"
7507        ));
7508        return Ok(diagnostics);
7509    };
7510    if projection_version != GRAPH_PROJECTION_VERSION {
7511        diagnostics.push(format!(
7512            "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
7513        ));
7514    }
7515    if content_hash.is_none() {
7516        diagnostics.push("projection content hash is missing".to_string());
7517    }
7518
7519    let meta_id = graph_projection_meta_id(scope);
7520    let meta_properties = conn
7521        .query_row(
7522            "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
7523            (&meta_id, GRAPH_PROJECTION_META_KIND),
7524            |row| row.get::<_, String>(0),
7525        )
7526        .optional()?;
7527    let Some(meta_properties) = meta_properties else {
7528        diagnostics.push(format!("projection_meta node {meta_id} is missing"));
7529        return Ok(diagnostics);
7530    };
7531    let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
7532        .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
7533    if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
7534        diagnostics.push(format!(
7535            "projection_meta node {meta_id} has stale projection_version"
7536        ));
7537    }
7538    if properties.get("content_hash") != content_hash.as_ref() {
7539        diagnostics.push(format!(
7540            "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
7541        ));
7542    }
7543    Ok(diagnostics)
7544}
7545
7546pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
7547    let mut node_stmt = conn.prepare(
7548        "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7549    )?;
7550    let node_rows = node_stmt.query_map([], |row| {
7551        let properties_json: String = row.get(3)?;
7552        let provenance_json: String = row.get(4)?;
7553        let freshness_json: Option<String> = row.get(5)?;
7554        Ok((
7555            row.get::<_, String>(0)?,
7556            row.get::<_, String>(1)?,
7557            row.get::<_, String>(2)?,
7558            properties_json,
7559            provenance_json,
7560            freshness_json,
7561        ))
7562    })?;
7563    let mut nodes = Vec::new();
7564    for row in node_rows {
7565        let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
7566        nodes.push(ConvexNodeRow {
7567            external_id,
7568            kind,
7569            label,
7570            properties: serde_json::from_str(&properties_json)?,
7571            provenance: serde_json::from_str(&provenance_json)?,
7572            freshness: freshness_json
7573                .map(|value| serde_json::from_str(&value))
7574                .transpose()?,
7575        });
7576    }
7577
7578    let mut edge_stmt = conn.prepare(
7579        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7580    )?;
7581    let edge_rows = edge_stmt.query_map([], |row| {
7582        let properties_json: String = row.get(4)?;
7583        let provenance_json: String = row.get(5)?;
7584        let freshness_json: Option<String> = row.get(6)?;
7585        Ok((
7586            row.get::<_, String>(0)?,
7587            row.get::<_, String>(1)?,
7588            row.get::<_, String>(2)?,
7589            row.get::<_, String>(3)?,
7590            properties_json,
7591            provenance_json,
7592            freshness_json,
7593        ))
7594    })?;
7595    let mut edges = Vec::new();
7596    for row in edge_rows {
7597        let (
7598            edge_key,
7599            from_external_id,
7600            to_external_id,
7601            kind,
7602            properties_json,
7603            provenance_json,
7604            freshness_json,
7605        ) = row?;
7606        edges.push(ConvexEdgeRow {
7607            edge_key,
7608            from_external_id,
7609            to_external_id,
7610            kind,
7611            properties: serde_json::from_str(&properties_json)?,
7612            provenance: serde_json::from_str(&provenance_json)?,
7613            freshness: freshness_json
7614                .map(|value| serde_json::from_str(&value))
7615                .transpose()?,
7616        });
7617    }
7618    Ok(ConvexProjectionRows { nodes, edges })
7619}
7620
7621fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
7622    format!("{}.{}({})", index.table, index.name, index.fields.join(","))
7623}
7624
7625fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
7626    value
7627        .get("indexes")
7628        .or_else(|| value.get("requiredIndexes"))
7629        .or_else(|| {
7630            value
7631                .get("metadata")
7632                .and_then(|metadata| metadata.get("indexes"))
7633        })
7634}
7635
7636fn convex_snapshot_declared_indexes(
7637    value: &serde_json::Value,
7638) -> Result<Option<Vec<ConvexRequiredIndex>>> {
7639    convex_snapshot_index_value(value)
7640        .map(|indexes| {
7641            serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
7642                .context("parsing Convex snapshot index metadata")
7643        })
7644        .transpose()
7645}
7646
7647fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
7648    let required = convex_required_indexes();
7649    let Some(declared) = convex_snapshot_declared_indexes(value)? else {
7650        return Ok(vec![format!(
7651            "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
7652            required
7653                .iter()
7654                .map(convex_required_index_label)
7655                .collect::<Vec<_>>()
7656                .join(", ")
7657        )]);
7658    };
7659    let declared = declared.into_iter().collect::<BTreeSet<_>>();
7660    let missing = required
7661        .iter()
7662        .filter(|index| !declared.contains(*index))
7663        .map(convex_required_index_label)
7664        .collect::<Vec<_>>();
7665    if missing.is_empty() {
7666        Ok(Vec::new())
7667    } else {
7668        Ok(vec![format!(
7669            "Convex snapshot is missing required index metadata: {}",
7670            missing.join(", ")
7671        )])
7672    }
7673}
7674
7675pub(crate) fn load_convex_projection_snapshot_value(
7676    snapshot_path: &Path,
7677) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7678    let content = fs::read_to_string(snapshot_path).with_context(|| {
7679        format!(
7680            "reading Convex projection snapshot {}",
7681            snapshot_path.display()
7682        )
7683    })?;
7684    let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7685        format!(
7686            "parsing Convex projection snapshot {}",
7687            snapshot_path.display()
7688        )
7689    })?;
7690    let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7691        .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7692    Ok((rows, value))
7693}
7694
7695pub(crate) fn append_sqlite_graph_doctor_checks(
7696    report: &mut GraphDbDoctorReport,
7697    root: &Path,
7698    scope: Option<&str>,
7699    graph_db: &Path,
7700) -> Option<substrate::SqliteReadOnlyConnection> {
7701    let rebuild = graph_db_rebuild_command(root, scope);
7702    let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7703    if !graph_db.exists() {
7704        report.push_check(graph_db_doctor_check(
7705            "sqlite_graph_db_exists",
7706            vec![format!("graph.db is missing at {}", graph_db.display())],
7707            vec![rebuild],
7708        ));
7709        return None;
7710    }
7711    report.push_check(graph_db_doctor_check(
7712        "sqlite_graph_db_exists",
7713        Vec::new(),
7714        vec![rebuild.clone()],
7715    ));
7716
7717    let conn = match open_sqlite_graph_db_readonly(graph_db) {
7718        Ok(conn) => conn,
7719        Err(err) => {
7720            report.push_check(graph_db_doctor_check(
7721                "sqlite_graph_db_open",
7722                vec![err.to_string()],
7723                vec![backup_rebuild],
7724            ));
7725            return None;
7726        }
7727    };
7728    report.push_check(graph_db_doctor_check(
7729        "sqlite_graph_db_open",
7730        Vec::new(),
7731        vec![rebuild.clone()],
7732    ));
7733    if let Some(recovery) = conn.recovery() {
7734        report.push_check(GraphDbDoctorCheck {
7735            name: "sqlite_graph_db_read_recovery".to_string(),
7736            status: "recovered".to_string(),
7737            fail_closed: false,
7738            diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7739            repair_commands: Vec::new(),
7740        });
7741    }
7742
7743    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7744        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7745    report.push_check(graph_db_doctor_check(
7746        "sqlite_schema",
7747        schema_diagnostics,
7748        vec![backup_rebuild.clone()],
7749    ));
7750
7751    let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7752        .unwrap_or_else(|err| {
7753            vec![format!(
7754                "graph projection metadata inspection failed: {err}"
7755            )]
7756        });
7757    report.push_check(graph_db_doctor_check(
7758        "sqlite_projection_metadata",
7759        metadata_diagnostics,
7760        vec![rebuild.clone()],
7761    ));
7762
7763    let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7764        .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7765    report.push_check(graph_db_doctor_check(
7766        "sqlite_duplicate_ids",
7767        duplicate_diagnostics,
7768        vec![backup_rebuild.clone()],
7769    ));
7770
7771    let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7772        .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7773    report.push_check(graph_db_doctor_check(
7774        "sqlite_orphan_edges",
7775        orphan_diagnostics,
7776        vec![rebuild.clone()],
7777    ));
7778
7779    let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7780        .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7781    report.push_check(graph_db_doctor_check(
7782        "sqlite_row_json",
7783        json_diagnostics,
7784        vec![backup_rebuild],
7785    ));
7786
7787    let tombstone_diagnostics =
7788        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7789            .unwrap_or_else(|err| {
7790                vec![format!(
7791                    "graph tombstone retention inspection failed: {err}"
7792                )]
7793            });
7794    report.push_check(GraphDbDoctorCheck {
7795        name: "sqlite_tombstone_retention".to_string(),
7796        status: if tombstone_diagnostics.is_empty() {
7797            "ok".to_string()
7798        } else {
7799            "warning".to_string()
7800        },
7801        fail_closed: false,
7802        diagnostics: tombstone_diagnostics,
7803        repair_commands: Vec::new(),
7804    });
7805    let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7806        Ok(counts) => {
7807            let policy = graph_db_compaction_policy(root, scope, &counts, false);
7808            GraphDbDoctorCheck {
7809                name: "sqlite_compaction_policy".to_string(),
7810                status: policy.status.clone(),
7811                fail_closed: false,
7812                diagnostics: policy.proof,
7813                repair_commands: if policy.status == "recommended" {
7814                    policy.recommendations
7815                } else {
7816                    Vec::new()
7817                },
7818            }
7819        }
7820        Err(err) => GraphDbDoctorCheck {
7821            name: "sqlite_compaction_policy".to_string(),
7822            status: "warning".to_string(),
7823            fail_closed: false,
7824            diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7825            repair_commands: Vec::new(),
7826        },
7827    };
7828    report.push_check(compaction_check);
7829
7830    Some(conn)
7831}
7832
7833pub(crate) fn append_convex_snapshot_doctor_checks(
7834    report: &mut GraphDbDoctorReport,
7835    root: &Path,
7836    scope: Option<&str>,
7837    local_rows: Option<&ConvexProjectionRows>,
7838    snapshot_path: Option<&Path>,
7839) {
7840    let repair = convex_refresh_command(root, scope);
7841    let Some(snapshot_path) = snapshot_path else {
7842        report.push_check(graph_db_doctor_check(
7843            "convex_snapshot_present",
7844            vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7845            vec![format!(
7846                "tsift convex-sync {}{} --json > convex-rows.json",
7847                shell_quote(root.to_string_lossy().as_ref()),
7848                graph_db_scope_arg(scope)
7849            )],
7850        ));
7851        return;
7852    };
7853    report.push_check(graph_db_doctor_check(
7854        "convex_snapshot_present",
7855        Vec::new(),
7856        vec![repair.clone()],
7857    ));
7858
7859    let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7860        Ok(snapshot) => snapshot,
7861        Err(err) => {
7862            report.push_check(graph_db_doctor_check(
7863                "convex_snapshot_parse",
7864                vec![err.to_string()],
7865                vec![repair],
7866            ));
7867            return;
7868        }
7869    };
7870    report.push_check(graph_db_doctor_check(
7871        "convex_snapshot_parse",
7872        Vec::new(),
7873        vec![repair.clone()],
7874    ));
7875
7876    let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7877    report.push_check(graph_db_doctor_check(
7878        "convex_snapshot_rows",
7879        row_diagnostics,
7880        vec![repair.clone()],
7881    ));
7882
7883    let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7884        .unwrap_or_else(|err| vec![err.to_string()]);
7885    report.required_indexes = convex_required_indexes();
7886    report.push_check(graph_db_doctor_check(
7887        "convex_required_indexes",
7888        index_diagnostics,
7889        vec![
7890            "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7891                .to_string(),
7892        ],
7893    ));
7894
7895    if let Some(local_rows) = local_rows {
7896        let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7897        report.push_check(graph_db_doctor_check(
7898            "convex_projection_freshness",
7899            freshness.diagnostics,
7900            vec![repair],
7901        ));
7902    } else {
7903        report.push_check(graph_db_doctor_check(
7904            "convex_projection_freshness",
7905            vec![
7906                "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7907                    .to_string(),
7908            ],
7909            vec![graph_db_rebuild_command(root, scope)],
7910        ));
7911    }
7912}
7913
7914fn graph_db_convex_snapshot_doctor_command(
7915    root: &Path,
7916    scope: Option<&str>,
7917    snapshot_path: &Path,
7918) -> String {
7919    format!(
7920        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7921        shell_quote(root.to_string_lossy().as_ref()),
7922        graph_db_scope_arg(scope),
7923        shell_quote(snapshot_path.to_string_lossy().as_ref())
7924    )
7925}
7926
7927fn graph_db_convex_snapshot_read_command(
7928    root: &Path,
7929    scope: Option<&str>,
7930    snapshot_path: &Path,
7931) -> String {
7932    format!(
7933        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7934        shell_quote(root.to_string_lossy().as_ref()),
7935        graph_db_scope_arg(scope),
7936        shell_quote(snapshot_path.to_string_lossy().as_ref())
7937    )
7938}
7939
7940fn convex_sync_snapshot_diff_command(
7941    root: &Path,
7942    scope: Option<&str>,
7943    snapshot_path: &Path,
7944) -> String {
7945    format!(
7946        "tsift convex-sync {}{} --snapshot {} --json",
7947        shell_quote(root.to_string_lossy().as_ref()),
7948        graph_db_scope_arg(scope),
7949        shell_quote(snapshot_path.to_string_lossy().as_ref())
7950    )
7951}
7952
7953pub(crate) struct GraphDbDriftInput<'a> {
7954    root: &'a Path,
7955    scope: Option<&'a str>,
7956    graph_db: &'a Path,
7957    snapshot_path: &'a Path,
7958    local: &'a ConvexProjectionRows,
7959    snapshot: &'a ConvexProjectionRows,
7960    snapshot_value: &'a serde_json::Value,
7961    warnings: Vec<String>,
7962}
7963
7964pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7965    let GraphDbDriftInput {
7966        root,
7967        scope,
7968        graph_db,
7969        snapshot_path,
7970        local,
7971        snapshot,
7972        snapshot_value,
7973        warnings,
7974    } = input;
7975    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7976    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7977        convex_rows_diff(local, Some(snapshot));
7978    let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7979    let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7980        .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7981    let local_hash = freshness.local_hash.clone();
7982    let snapshot_hash = freshness.snapshot_hash.clone();
7983    let stale_nodes = freshness.stale_nodes.clone();
7984    let stale_edges = freshness.stale_edges.clone();
7985
7986    let duplicate_failures = row_diagnostics
7987        .iter()
7988        .filter(|diagnostic| diagnostic.contains("duplicate"))
7989        .count();
7990    let orphan_failures = row_diagnostics
7991        .iter()
7992        .filter(|diagnostic| diagnostic.contains("references missing"))
7993        .count();
7994    let missing_required_indexes = index_diagnostics.len();
7995    let stale_projection_metadata =
7996        usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7997    let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7998    let has_drift = freshness.fail_closed
7999        || !node_upserts.is_empty()
8000        || !edge_upserts.is_empty()
8001        || !node_tombstones.is_empty()
8002        || !edge_tombstones.is_empty();
8003    let status = if hard_failures > 0 {
8004        "fail_closed"
8005    } else if has_drift {
8006        "drift"
8007    } else {
8008        "current"
8009    }
8010    .to_string();
8011
8012    let mut diagnostics = Vec::new();
8013    diagnostics.extend(row_diagnostics);
8014    diagnostics.extend(index_diagnostics);
8015    diagnostics.extend(freshness.diagnostics.clone());
8016    if has_drift {
8017        diagnostics.push(format!(
8018            "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
8019            node_upserts.len(),
8020            edge_upserts.len(),
8021            node_tombstones.len(),
8022            edge_tombstones.len()
8023        ));
8024    }
8025
8026    let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
8027        root,
8028        scope,
8029        snapshot_path,
8030    )];
8031    if status == "current" {
8032        next_commands.push(graph_db_convex_snapshot_read_command(
8033            root,
8034            scope,
8035            snapshot_path,
8036        ));
8037    } else {
8038        next_commands.push(convex_sync_snapshot_diff_command(
8039            root,
8040            scope,
8041            snapshot_path,
8042        ));
8043        next_commands.push(convex_refresh_command(root, scope));
8044    }
8045
8046    GraphDbDriftReport {
8047        root: root.to_string_lossy().to_string(),
8048        scope: scope.map(str::to_string),
8049        graph_db: graph_db.to_string_lossy().to_string(),
8050        convex_snapshot: snapshot_path.to_string_lossy().to_string(),
8051        status: status.clone(),
8052        graph_reads_allowed: status == "current",
8053        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
8054        local_hash,
8055        snapshot_hash,
8056        summary: GraphDbDriftSummary {
8057            node_upserts: node_upserts.len(),
8058            edge_upserts: edge_upserts.len(),
8059            node_tombstones: node_tombstones.len(),
8060            edge_tombstones: edge_tombstones.len(),
8061            stale_nodes: stale_nodes.len(),
8062            stale_edges: stale_edges.len(),
8063            stale_projection_metadata,
8064            duplicate_failures,
8065            orphan_failures,
8066            missing_required_indexes,
8067        },
8068        node_upserts: node_upserts
8069            .into_iter()
8070            .map(|row| row.external_id)
8071            .collect(),
8072        edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
8073        node_tombstones,
8074        edge_tombstones,
8075        stale_nodes,
8076        stale_edges,
8077        diagnostics,
8078        next_commands,
8079        required_indexes: convex_required_indexes(),
8080        warnings,
8081    }
8082}
8083
8084pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
8085    println!(
8086        "graph-db drift status: {} reads_allowed: {}",
8087        report.status, report.graph_reads_allowed
8088    );
8089    println!("graph_db: {}", report.graph_db);
8090    println!("convex_snapshot: {}", report.convex_snapshot);
8091    println!(
8092        "upserts: {} node(s), {} edge(s)",
8093        report.summary.node_upserts, report.summary.edge_upserts
8094    );
8095    println!(
8096        "tombstones: {} node(s), {} edge(s)",
8097        report.summary.node_tombstones, report.summary.edge_tombstones
8098    );
8099    for diagnostic in &report.diagnostics {
8100        println!("diagnostic: {diagnostic}");
8101    }
8102    for command in &report.next_commands {
8103        println!("next: {command}");
8104    }
8105}
8106
8107pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
8108    println!(
8109        "graph-db doctor backend: {} status: {}",
8110        report.backend, report.status
8111    );
8112    println!("graph_db: {}", report.graph_db);
8113    if let Some(snapshot) = &report.convex_snapshot {
8114        println!("convex_snapshot: {snapshot}");
8115    }
8116    for check in &report.checks {
8117        println!("check: {} {}", check.name, check.status);
8118        for diagnostic in &check.diagnostics {
8119            println!("  diagnostic: {diagnostic}");
8120        }
8121    }
8122    for command in &report.repair_commands {
8123        println!("repair: {command}");
8124    }
8125}
8126
8127pub(crate) fn graph_db_operator_report_from_disk(
8128    root: &Path,
8129    scope: Option<&str>,
8130    graph_db: &Path,
8131    operation: &str,
8132    refresh: Option<GraphDbRefreshSummary>,
8133    warnings: Vec<String>,
8134) -> Result<GraphDbOperatorReport> {
8135    if !graph_db.exists() {
8136        let next_commands = graph_db_operator_next_commands(root, scope, true);
8137        let counts = GraphDbOperatorCounts {
8138            nodes: 0,
8139            edges: 0,
8140            tombstones: GraphDbTombstoneCounts {
8141                nodes: 0,
8142                edges: 0,
8143                total: 0,
8144            },
8145            file_size_bytes: None,
8146            freelist_bytes: None,
8147        };
8148        return Ok(GraphDbOperatorReport {
8149            root: root.to_string_lossy().to_string(),
8150            scope: scope.map(str::to_string),
8151            graph_db: graph_db.to_string_lossy().to_string(),
8152            operation: operation.to_string(),
8153            status: "missing".to_string(),
8154            materialized: false,
8155            freshness: GraphDbFreshnessReport {
8156                status: "missing".to_string(),
8157                fail_closed: true,
8158                projection_version: None,
8159                content_hash: None,
8160                source_watermark: None,
8161                diagnostics: vec![
8162                    "graph.db is missing; run graph-db refresh before trusting graph reads"
8163                        .to_string(),
8164                ],
8165            },
8166            readiness: graph_effectiveness_blocked(
8167                "graph_db_missing",
8168                vec![
8169                    "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
8170                ],
8171                next_commands.clone(),
8172            ),
8173            counts: counts.clone(),
8174            refresh,
8175            compaction: graph_db_compaction_policy(root, scope, &counts, false),
8176            recovery: None,
8177            next_commands,
8178            warnings,
8179        });
8180    }
8181
8182    let conn = open_sqlite_graph_db_readonly(graph_db)?;
8183    let recovery = conn.recovery();
8184    let mut warnings = warnings;
8185    if let Some(recovery) = recovery {
8186        warnings.push(graph_db_read_recovery_diagnostic(recovery));
8187    }
8188    let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
8189    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
8190        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
8191    if !schema_diagnostics.is_empty() {
8192        freshness.diagnostics.extend(schema_diagnostics);
8193        freshness.fail_closed = true;
8194        freshness.status = "stale".to_string();
8195    }
8196    let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
8197    let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
8198    warnings.extend(
8199        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
8200            .unwrap_or_else(|err| {
8201                vec![format!(
8202                    "graph tombstone retention inspection failed: {err}"
8203                )]
8204            }),
8205    );
8206    let status = if freshness.fail_closed {
8207        "stale"
8208    } else {
8209        "current"
8210    }
8211    .to_string();
8212
8213    Ok(GraphDbOperatorReport {
8214        root: root.to_string_lossy().to_string(),
8215        scope: scope.map(str::to_string),
8216        graph_db: graph_db.to_string_lossy().to_string(),
8217        operation: operation.to_string(),
8218        status,
8219        materialized: true,
8220        freshness,
8221        readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
8222        compaction: graph_db_compaction_policy(root, scope, &counts, false),
8223        counts,
8224        refresh,
8225        recovery,
8226        next_commands: graph_db_operator_next_commands(root, scope, false),
8227        warnings,
8228    })
8229}
8230
8231fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
8232    println!(
8233        "graph-db {} status: {} materialized: {}",
8234        report.operation, report.status, report.materialized
8235    );
8236    println!("graph_db: {}", report.graph_db);
8237    println!(
8238        "projection: version={} hash={} watermark={}",
8239        report
8240            .freshness
8241            .projection_version
8242            .as_deref()
8243            .unwrap_or("<missing>"),
8244        report
8245            .freshness
8246            .content_hash
8247            .as_deref()
8248            .unwrap_or("<missing>"),
8249        report
8250            .freshness
8251            .source_watermark
8252            .as_deref()
8253            .unwrap_or("<missing>")
8254    );
8255    println!(
8256        "rows: {} node(s), {} edge(s), {} tombstone(s)",
8257        report.counts.nodes, report.counts.edges, report.counts.tombstones.total
8258    );
8259    println!(
8260        "readiness: {} reason: {} fail_closed: {}",
8261        report.readiness.status, report.readiness.reason, report.readiness.fail_closed
8262    );
8263    if let Some(file_size) = report.counts.file_size_bytes {
8264        println!(
8265            "storage: {} byte(s), {} free byte(s)",
8266            file_size,
8267            report.counts.freelist_bytes.unwrap_or(0)
8268        );
8269    }
8270    if let Some(refresh) = &report.refresh {
8271        println!(
8272            "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
8273            refresh.tombstoned_nodes, refresh.tombstoned_edges
8274        );
8275        println!(
8276            "delta: {} node upsert(s), {} edge upsert(s), {} property row upsert(s), {} unchanged node(s), {} unchanged edge(s), {} unchanged property row(s), {} deleted property row(s), {} pruned tombstone(s)",
8277            refresh.upserted_nodes,
8278            refresh.upserted_edges,
8279            refresh.upserted_properties,
8280            refresh.unchanged_nodes,
8281            refresh.unchanged_edges,
8282            refresh.unchanged_properties,
8283            refresh.deleted_properties,
8284            refresh.pruned_tombstones
8285        );
8286    }
8287    println!(
8288        "compaction: {} tombstone_scan_rows={} live_rows={}",
8289        report.compaction.status,
8290        report.compaction.tombstone_scan_rows,
8291        report.compaction.live_rows
8292    );
8293    for proof in &report.compaction.proof {
8294        println!("compaction proof: {proof}");
8295    }
8296    if let Some(recovery) = report.recovery {
8297        println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
8298    }
8299    for diagnostic in &report.freshness.diagnostics {
8300        println!("diagnostic: {diagnostic}");
8301    }
8302    for diagnostic in &report.readiness.diagnostics {
8303        println!("readiness diagnostic: {diagnostic}");
8304    }
8305    for warning in &report.warnings {
8306        println!("warning: {warning}");
8307    }
8308    for command in &report.readiness.next_commands {
8309        println!("readiness next: {command}");
8310    }
8311    for command in &report.next_commands {
8312        println!("next: {command}");
8313    }
8314}
8315
8316pub(crate) fn print_graph_db_operator_report(
8317    report: &GraphDbOperatorReport,
8318    format: OutputFormat,
8319) -> Result<()> {
8320    if format.json_output {
8321        print_json_or_envelope(
8322            report,
8323            &format,
8324            "graph-db",
8325            &report.operation,
8326            ToolEnvelopeSummary {
8327                text: format!(
8328                    "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
8329                    report.operation,
8330                    report.status,
8331                    report.counts.nodes,
8332                    report.counts.edges,
8333                    report.counts.tombstones.total
8334                ),
8335                metrics: vec![
8336                    envelope_metric("operation", &report.operation),
8337                    envelope_metric("status", &report.status),
8338                    envelope_metric("nodes", report.counts.nodes),
8339                    envelope_metric("edges", report.counts.edges),
8340                    envelope_metric("tombstones", report.counts.tombstones.total),
8341                    envelope_metric("compaction", &report.compaction.status),
8342                    envelope_metric("readiness", &report.readiness.status),
8343                ],
8344            },
8345            false,
8346            report.next_commands.clone(),
8347        )
8348    } else {
8349        print_graph_db_operator_human(report);
8350        Ok(())
8351    }
8352}
8353
8354fn status_run_command_without_notes(run: &str) -> &str {
8355    run.split_once("  (")
8356        .map(|(command, _)| command)
8357        .unwrap_or(run)
8358}
8359
8360fn status_summarize_extract_command(run: &str) -> &str {
8361    let run = status_run_command_without_notes(run);
8362    run.split(" && ")
8363        .find(|command| command.contains("summarize --extract"))
8364        .unwrap_or(run)
8365}
8366
8367fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
8368    report
8369        .recommendations
8370        .run
8371        .as_deref()
8372        .filter(|command| command.contains("summarize --extract"))
8373        .map(status_summarize_extract_command)
8374        .unwrap_or("tsift summarize --extract .")
8375        .to_string()
8376}
8377
8378fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
8379    let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8380    readiness.diagnostics.push(format!(
8381        "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
8382    ));
8383    readiness
8384}
8385
8386fn graph_db_semantic_readiness(
8387    root: &Path,
8388    scope: Option<&str>,
8389    semantic_row_count: Option<usize>,
8390) -> GraphEffectivenessReadiness {
8391    if let Some(row_count) = semantic_row_count
8392        && row_count > 0
8393    {
8394        return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
8395    }
8396
8397    let report = match status::check_status(root) {
8398        Ok(report) => report,
8399        Err(err) => {
8400            return graph_effectiveness_blocked(
8401                "status_check_unavailable",
8402                vec![format!(
8403                    "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
8404                )],
8405                vec![graph_db_refresh_command(root, scope)],
8406            );
8407        }
8408    };
8409
8410    match &report.summaries {
8411        status::SummaryStatus::Available {
8412            cached_files,
8413            total_indexed_files,
8414            coverage_pct,
8415            ..
8416        } => {
8417            let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8418            readiness.diagnostics.push(format!(
8419                "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
8420            ));
8421            readiness
8422        }
8423        status::SummaryStatus::None { .. } => {
8424            let summarize = graph_db_status_summarize_command(&report);
8425            let index_command = report
8426                .recommendations
8427                .run
8428                .as_deref()
8429                .filter(|cmd| cmd.contains("index"))
8430                .map(str::to_string);
8431            let mut repair = Vec::new();
8432            if let Some(cmd) = index_command {
8433                repair.push(cmd);
8434            }
8435            repair.push(summarize.clone());
8436            repair.push(graph_db_refresh_command(root, scope));
8437            graph_effectiveness_blocked(
8438                "summary_cache_empty",
8439                vec![format!(
8440                    "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8441                    summarize,
8442                    root.display(),
8443                    graph_db_refresh_command(root, scope)
8444                )],
8445                repair,
8446            )
8447        }
8448        status::SummaryStatus::Unavailable => {
8449            let mut repair: Vec<String> = report.recommendations.run.clone().into_iter().collect();
8450            let summarize = "tsift summarize --extract .".to_string();
8451            repair.push(summarize);
8452            repair.push(graph_db_refresh_command(root, scope));
8453            graph_effectiveness_blocked(
8454                "summary_cache_unavailable",
8455                vec![
8456                    "summary cache unavailable because the source index is missing; build the index, extract summaries, and refresh the graph before relying on semantic graph evidence".to_string(),
8457                ],
8458                repair,
8459            )
8460        }
8461    }
8462}
8463
8464pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
8465    let report = match status::check_status(root) {
8466        Ok(report) => report,
8467        Err(err) => {
8468            return vec![format!(
8469                "status check unavailable after graph-db refresh: {err:#}"
8470            )];
8471        }
8472    };
8473
8474    let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8475        Some(graph_db_status_summarize_command(&report))
8476    } else {
8477        None
8478    };
8479    let mut warnings = report.reminders;
8480    if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8481        let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
8482        warnings.push(format!(
8483            "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8484            run,
8485            root.display(),
8486            graph_db_refresh_command(root, scope)
8487        ));
8488    }
8489    dedupe_preserve_order(warnings)
8490}
8491
8492pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
8493    println!(
8494        "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
8495        report.applied, report.pruned_tombstones, report.reclaimed_bytes
8496    );
8497    println!("graph_db: {}", report.graph_db);
8498    println!(
8499        "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8500        report.counts_before.nodes,
8501        report.counts_before.edges,
8502        report.counts_before.tombstones.total,
8503        report.counts_before.file_size_bytes.unwrap_or(0),
8504        report.counts_before.freelist_bytes.unwrap_or(0)
8505    );
8506    println!(
8507        "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8508        report.counts_after.nodes,
8509        report.counts_after.edges,
8510        report.counts_after.tombstones.total,
8511        report.counts_after.file_size_bytes.unwrap_or(0),
8512        report.counts_after.freelist_bytes.unwrap_or(0)
8513    );
8514    for proof in &report.compaction_after.proof {
8515        println!("proof: {proof}");
8516    }
8517    for warning in &report.warnings {
8518        println!("warning: {warning}");
8519    }
8520    for command in &report.next_commands {
8521        println!("next: {command}");
8522    }
8523}
8524
8525fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
8526    raw.iter()
8527        .map(|value| {
8528            let (key, filter_value) = value
8529                .split_once('=')
8530                .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
8531            let key = key.trim();
8532            let filter_value = filter_value.trim();
8533            if key.is_empty() || filter_value.is_empty() {
8534                bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
8535            }
8536            Ok(GraphDbPropertyFilter {
8537                key: key.to_string(),
8538                value: filter_value.to_string(),
8539            })
8540        })
8541        .collect()
8542}
8543
8544fn graph_db_query_options(
8545    cursor: Option<String>,
8546    limit: Option<usize>,
8547    property_filters: &[String],
8548) -> Result<GraphDbQueryOptions> {
8549    Ok(GraphDbQueryOptions {
8550        cursor,
8551        limit: limit.filter(|limit| *limit > 0),
8552        property_filters: parse_graph_db_property_filters(property_filters)?,
8553    })
8554}
8555
8556fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
8557    GraphQueryOptions {
8558        cursor: options.cursor.clone(),
8559        limit: options.limit,
8560        property_filters: options
8561            .property_filters
8562            .iter()
8563            .map(|filter| GraphPropertyFilter {
8564                key: filter.key.clone(),
8565                value: filter.value.clone(),
8566            })
8567            .collect(),
8568    }
8569}
8570
8571fn graph_db_page_report_from_store(
8572    page: GraphQueryPage,
8573    property_filters: Vec<GraphDbPropertyFilter>,
8574) -> GraphDbPageReport {
8575    GraphDbPageReport {
8576        cursor: page.cursor,
8577        limit: page.limit,
8578        next_cursor: page.next_cursor,
8579        returned_nodes: page.returned_nodes,
8580        returned_edges: page.returned_edges,
8581        truncated: page.truncated,
8582        property_filters,
8583        diagnostics: page.diagnostics,
8584    }
8585}
8586
8587fn graph_db_neighborhood_ranking_gate(
8588    ranked_neighbor_cap: usize,
8589) -> GraphDbNeighborhoodRankingGate {
8590    GraphDbNeighborhoodRankingGate {
8591        status: "held_default_order_unchanged".to_string(),
8592        ranked_output_default: false,
8593        default_order: "stable_node_id".to_string(),
8594        default_change_gate: "community_search_quality_metrics".to_string(),
8595        required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
8596            .iter()
8597            .map(|workload| (*workload).to_string())
8598            .collect(),
8599        required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
8600            .iter()
8601            .map(|metric| (*metric).to_string())
8602            .collect(),
8603        max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
8604        min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
8605        min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
8606        min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
8607        diagnostics: vec![
8608            "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
8609            format!(
8610                "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
8611            ),
8612            "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
8613        ],
8614    }
8615}
8616
8617fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
8618    match limit {
8619        Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
8620        Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
8621    }
8622}
8623
8624fn graph_db_ranked_neighbors(
8625    center_id: &str,
8626    nodes: &[SubstrateGraphNode],
8627    edges: &[SubstrateGraphEdge],
8628    cap: usize,
8629) -> Vec<GraphDbRankedNeighbor> {
8630    resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
8631}
8632
8633fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
8634    center_id: &str,
8635    depth: usize,
8636    edge_kind: Option<&str>,
8637    limit: Option<usize>,
8638    unranked_nodes: &[SubstrateGraphNode],
8639    unranked_edges: &[SubstrateGraphEdge],
8640    store: &S,
8641) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
8642    use std::time::Instant;
8643    let max_nodes = match limit {
8644        Some(0) | None => 200,
8645        Some(n) => n.clamp(10, 500),
8646    };
8647    let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
8648        .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
8649    if let Some(kind) = edge_kind {
8650        options = options.with_edge_kind(kind);
8651    }
8652    let start = Instant::now();
8653    let result = store.ranked_neighborhood(center_id, &options)?;
8654    let latency = start.elapsed().as_micros();
8655    let Some(ranked) = result else {
8656        return Ok(None);
8657    };
8658    let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
8659    let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
8660    let overlap_count = ranked_ids.intersection(&unranked_ids).count();
8661    let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
8662        0.0
8663    } else {
8664        (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8665    };
8666    let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8667        let mut name_count = BTreeMap::<&str, usize>::new();
8668        for n in nodes {
8669            *name_count.entry(&n.label).or_default() += 1;
8670        }
8671        name_count.values().filter(|&&c| c > 1).count()
8672    };
8673    let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8674        if nodes.is_empty() {
8675            return 100.0;
8676        }
8677        let with_handle = nodes
8678            .iter()
8679            .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8680            .count();
8681        (with_handle as f64 / nodes.len() as f64) * 100.0
8682    };
8683    let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8684        if nodes.is_empty() {
8685            return 0.0;
8686        }
8687        let semantic_kinds = [
8688            "semantic_concept",
8689            "semantic_entity",
8690            "symbol",
8691            "file",
8692            "source_handle",
8693        ];
8694        let useful = nodes
8695            .iter()
8696            .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8697            .count();
8698        let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8699        let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8700        (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8701            / nodes.len() as f64
8702    };
8703    let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8704        let edge_pairs: Vec<(String, String)> = ranked
8705            .edges
8706            .iter()
8707            .map(|e| (e.from_id.clone(), e.to_id.clone()))
8708            .collect();
8709        let cr = tsift_graph::detect_communities(&edge_pairs);
8710        let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8711        let mut fully_kept = 0usize;
8712        let mut partially_pruned = 0usize;
8713        let mut fully_pruned = 0usize;
8714        let mut pruned_kinds = BTreeSet::new();
8715        let mut pruned_labels = Vec::new();
8716        for comm in &cr.communities {
8717            let kept_in_comm: Vec<&str> = comm
8718                .members
8719                .iter()
8720                .filter(|m| kept_labels.contains(m.name.as_str()))
8721                .map(|m| m.name.as_str())
8722                .collect();
8723            if kept_in_comm.len() == comm.members.len() {
8724                fully_kept += 1;
8725            } else if kept_in_comm.is_empty() {
8726                fully_pruned += 1;
8727                for m in &comm.members {
8728                    if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8729                        pruned_kinds.insert(n.kind.clone());
8730                    }
8731                    pruned_labels.push(m.name.clone());
8732                }
8733            } else {
8734                partially_pruned += 1;
8735            }
8736        }
8737        pruned_labels.truncate(5);
8738        Some(CommunityTruncationSummary {
8739            total_communities: cr.communities.len(),
8740            fully_kept,
8741            partially_pruned,
8742            fully_pruned,
8743            pruned_community_kinds: pruned_kinds.into_iter().collect(),
8744            pruned_community_top_labels: pruned_labels,
8745        })
8746    } else {
8747        None
8748    };
8749    Ok(Some(GraphDbRankedNeighborhoodComparison {
8750        traversal_nodes: ranked.nodes.len(),
8751        traversal_edges: ranked.edges.len(),
8752        pruned_count: ranked.pruned_count,
8753        total_discovered: ranked.total_discovered,
8754        latency_micros: latency,
8755        overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8756        useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8757            / 1000.0,
8758        useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8759            .round()
8760            / 1000.0,
8761        duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8762        duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8763        handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8764        handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8765            / 100.0,
8766        community_truncation_summary,
8767        diagnostics: vec![
8768            format!(
8769                "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8770                ranked.nodes.len(),
8771                ranked.edges.len(),
8772                ranked.pruned_count,
8773                ranked.total_discovered,
8774                latency
8775            ),
8776            format!(
8777                "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8778                overlap_pct,
8779                overlap_count,
8780                unranked_ids.len(),
8781                ranked_ids.len()
8782            ),
8783            "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8784        ],
8785    }))
8786}
8787
8788struct GraphDbBudgetedSubgraph {
8789    nodes: Vec<SubstrateGraphNode>,
8790    edges: Vec<SubstrateGraphEdge>,
8791    report: GraphDbOutputBudgetReport,
8792    truncated: bool,
8793    next_cursor: Option<String>,
8794}
8795
8796const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8797const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8798const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8799
8800fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8801    match limit {
8802        Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8803        Some(limit) => limit
8804            .saturating_mul(320)
8805            .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8806    }
8807}
8808
8809fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8810    if matches!(limit, Some(0) | None) {
8811        return match kind {
8812            "source_handle" => 10,
8813            "worker_context" | "worker_result" => 8,
8814            "semantic_concept" | "semantic_entity" => 10,
8815            "file" | "symbol" | "route" => 12,
8816            _ => 8,
8817        };
8818    }
8819    let base = limit.unwrap_or(0).max(1);
8820    match kind {
8821        "source_handle" => base.saturating_add(4),
8822        "worker_context" | "worker_result" => base.saturating_add(2),
8823        "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8824        "file" | "symbol" | "route" => base.saturating_add(4),
8825        _ => base.saturating_add(1),
8826    }
8827}
8828
8829fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8830    if matches!(limit, Some(0) | None) {
8831        return match kind {
8832            "mentions" | "mentions_concept" | "mentions_entity" => 24,
8833            "semantic_relation" | "calls" | "defines" => 20,
8834            _ => 16,
8835        };
8836    }
8837    let base = limit.unwrap_or(0).max(1);
8838    match kind {
8839        "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8840        "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8841        _ => base.saturating_add(2),
8842    }
8843}
8844
8845fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8846    serde_json::to_vec(value)
8847        .map(|bytes| bytes.len().div_ceil(4).max(1))
8848        .unwrap_or(1)
8849}
8850
8851fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8852    let mut parts = vec![node.kind.clone(), node.label.clone()];
8853    for key in [
8854        "detail",
8855        "description",
8856        "source_ref",
8857        "path",
8858        "source_file",
8859        "source_symbol",
8860        "text_preview",
8861    ] {
8862        if let Some(value) = node.properties.get(key) {
8863            parts.push(value.clone());
8864        }
8865    }
8866    parts.join(" ")
8867}
8868
8869fn graph_db_semantic_scores_for_query(
8870    query: Option<&str>,
8871    nodes: &[SubstrateGraphNode],
8872) -> BTreeMap<String, f64> {
8873    let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8874        return BTreeMap::new();
8875    };
8876    let query_embedding = semantic_embedding(query);
8877    nodes
8878        .iter()
8879        .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8880        .filter_map(|node| {
8881            let embedding = node
8882                .properties
8883                .get("embedding")
8884                .and_then(|value| parse_semantic_embedding_property(value))?;
8885            Some((
8886                node.id.clone(),
8887                semantic_cosine(&query_embedding, &embedding),
8888            ))
8889        })
8890        .collect()
8891}
8892
8893fn graph_db_depth_by_id(
8894    origin_ids: &[String],
8895    edges: &[SubstrateGraphEdge],
8896) -> BTreeMap<String, usize> {
8897    let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8898    for edge in edges {
8899        adjacency
8900            .entry(edge.from_id.clone())
8901            .or_default()
8902            .push(edge.to_id.clone());
8903        adjacency
8904            .entry(edge.to_id.clone())
8905            .or_default()
8906            .push(edge.from_id.clone());
8907    }
8908
8909    let mut depth_by_id = BTreeMap::<String, usize>::new();
8910    let mut queue = VecDeque::<String>::new();
8911    for origin in origin_ids {
8912        if depth_by_id.insert(origin.clone(), 0).is_none() {
8913            queue.push_back(origin.clone());
8914        }
8915    }
8916    while let Some(current) = queue.pop_front() {
8917        let depth = depth_by_id.get(&current).copied().unwrap_or(0);
8918        for next in adjacency.get(&current).into_iter().flatten() {
8919            if depth_by_id.contains_key(next) {
8920                continue;
8921            }
8922            depth_by_id.insert(next.clone(), depth.saturating_add(1));
8923            queue.push_back(next.clone());
8924        }
8925    }
8926    depth_by_id
8927}
8928
8929fn graph_db_source_covered_ids(
8930    nodes: &[SubstrateGraphNode],
8931    edges: &[SubstrateGraphEdge],
8932) -> BTreeSet<String> {
8933    let source_ids = nodes
8934        .iter()
8935        .filter(|node| node.kind == "source_handle")
8936        .map(|node| node.id.as_str())
8937        .collect::<BTreeSet<_>>();
8938    let mut covered = source_ids
8939        .iter()
8940        .map(|id| (*id).to_string())
8941        .collect::<BTreeSet<_>>();
8942    for edge in edges {
8943        if source_ids.contains(edge.from_id.as_str()) {
8944            covered.insert(edge.to_id.clone());
8945        }
8946        if source_ids.contains(edge.to_id.as_str()) {
8947            covered.insert(edge.from_id.clone());
8948        }
8949    }
8950    covered
8951}
8952
8953fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8954    for key in [
8955        "observed_at_unix",
8956        "completed_at_unix",
8957        "created_at_unix",
8958        "started_at_unix",
8959    ] {
8960        if let Some(value) = node.properties.get(key)
8961            && let Ok(epoch) = value.parse::<i64>()
8962        {
8963            return epoch.div_euclid(86_400).clamp(0, 40_000);
8964        }
8965    }
8966    0
8967}
8968
8969fn graph_db_node_kind_score(kind: &str) -> i64 {
8970    match kind {
8971        "source_handle" => 180,
8972        "worker_context" => 170,
8973        "worker_result" => 160,
8974        "semantic_concept" | "semantic_entity" => 150,
8975        "backlog" | "job_packet" => 130,
8976        "symbol" => 120,
8977        "file" => 110,
8978        "route" => 105,
8979        "session" => 90,
8980        _ => 40,
8981    }
8982}
8983
8984fn graph_db_edge_kind_score(kind: &str) -> i64 {
8985    match kind {
8986        "mentions_concept" | "mentions_entity" => 180,
8987        "semantic_relation" => 170,
8988        "mentions" => 165,
8989        "requests_context" | "scopes_context" | "scopes_source" => 155,
8990        "explains_result" => 150,
8991        "calls" => 145,
8992        "defines" | "handled_by" | "defines_route" => 130,
8993        "contains" | "targets" => 120,
8994        "records_memory_source" | "has_vector_handle" => 115,
8995        _ => 40,
8996    }
8997}
8998
8999fn graph_db_node_usefulness_score(
9000    node: &SubstrateGraphNode,
9001    depth_by_id: &BTreeMap<String, usize>,
9002    semantic_scores: &BTreeMap<String, f64>,
9003    source_covered_ids: &BTreeSet<String>,
9004    origin_ids: &[String],
9005) -> i64 {
9006    if origin_ids.iter().any(|origin| origin == &node.id) {
9007        return 1_000_000;
9008    }
9009    let semantic = semantic_scores
9010        .get(&node.id)
9011        .map(|score| (score.max(0.0) * 1_000.0) as i64)
9012        .unwrap_or(0);
9013    let depth_penalty = depth_by_id
9014        .get(&node.id)
9015        .map(|depth| (*depth as i64).saturating_mul(55))
9016        .unwrap_or(180);
9017    let source_coverage = if source_covered_ids.contains(&node.id)
9018        || node.properties.contains_key("source_ref")
9019        || node.properties.contains_key("path")
9020    {
9021        120
9022    } else {
9023        0
9024    };
9025    graph_db_node_kind_score(&node.kind)
9026        + semantic
9027        + source_coverage
9028        + graph_db_recency_score(node).min(80)
9029        - depth_penalty
9030}
9031
9032fn graph_db_edge_usefulness_score(
9033    edge: &SubstrateGraphEdge,
9034    node_score_by_id: &BTreeMap<String, i64>,
9035    depth_by_id: &BTreeMap<String, usize>,
9036) -> i64 {
9037    let endpoint_score = node_score_by_id
9038        .get(&edge.from_id)
9039        .copied()
9040        .unwrap_or_default()
9041        .max(
9042            node_score_by_id
9043                .get(&edge.to_id)
9044                .copied()
9045                .unwrap_or_default(),
9046        );
9047    let depth_penalty = depth_by_id
9048        .get(&edge.from_id)
9049        .into_iter()
9050        .chain(depth_by_id.get(&edge.to_id))
9051        .min()
9052        .map(|depth| (*depth as i64).saturating_mul(35))
9053        .unwrap_or(140);
9054    graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
9055}
9056
9057fn graph_db_push_drop(
9058    drops: &mut BTreeMap<(String, String, String), usize>,
9059    item: &str,
9060    kind: &str,
9061    reason: &str,
9062) {
9063    *drops
9064        .entry((item.to_string(), kind.to_string(), reason.to_string()))
9065        .or_default() += 1;
9066}
9067
9068fn graph_db_budget_drop_report(
9069    drops: BTreeMap<(String, String, String), usize>,
9070) -> Vec<GraphDbDroppedByBudget> {
9071    drops
9072        .into_iter()
9073        .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
9074            item,
9075            kind,
9076            reason,
9077            dropped,
9078        })
9079        .collect()
9080}
9081
9082fn graph_db_apply_output_budget(
9083    origin_ids: &[String],
9084    semantic_scores: &BTreeMap<String, f64>,
9085    nodes: Vec<SubstrateGraphNode>,
9086    edges: Vec<SubstrateGraphEdge>,
9087    limit: Option<usize>,
9088) -> GraphDbBudgetedSubgraph {
9089    graph_db_apply_output_budget_with_depths_and_cursor(
9090        origin_ids,
9091        semantic_scores,
9092        nodes,
9093        edges,
9094        limit,
9095        None,
9096        None,
9097    )
9098}
9099
9100fn graph_db_apply_output_budget_with_depths_and_cursor(
9101    origin_ids: &[String],
9102    semantic_scores: &BTreeMap<String, f64>,
9103    nodes: Vec<SubstrateGraphNode>,
9104    edges: Vec<SubstrateGraphEdge>,
9105    limit: Option<usize>,
9106    depth_overrides: Option<&BTreeMap<String, usize>>,
9107    cursor: Option<&str>,
9108) -> GraphDbBudgetedSubgraph {
9109    let max_tokens = graph_db_output_token_cap(limit);
9110    let candidate_nodes = nodes.len();
9111    let candidate_edges = edges.len();
9112    let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
9113    if let Some(depth_overrides) = depth_overrides {
9114        for (id, depth) in depth_overrides {
9115            depth_by_id
9116                .entry(id.clone())
9117                .and_modify(|current| *current = (*current).min(*depth))
9118                .or_insert(*depth);
9119        }
9120    }
9121    let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
9122    let node_score_by_id = nodes
9123        .iter()
9124        .map(|node| {
9125            (
9126                node.id.clone(),
9127                graph_db_node_usefulness_score(
9128                    node,
9129                    &depth_by_id,
9130                    semantic_scores,
9131                    &source_covered_ids,
9132                    origin_ids,
9133                ),
9134            )
9135        })
9136        .collect::<BTreeMap<_, _>>();
9137
9138    let mut node_candidates = nodes.iter().collect::<Vec<_>>();
9139    node_candidates.sort_by(|left, right| {
9140        node_score_by_id
9141            .get(&right.id)
9142            .cmp(&node_score_by_id.get(&left.id))
9143            .then_with(|| left.kind.cmp(&right.kind))
9144            .then_with(|| left.label.cmp(&right.label))
9145            .then_with(|| left.id.cmp(&right.id))
9146    });
9147
9148    let cursor_skip = if let Some(cursor) = cursor {
9149        node_candidates
9150            .iter()
9151            .position(|node| node.id == cursor)
9152            .map(|pos| pos.saturating_add(1))
9153            .unwrap_or(0)
9154    } else {
9155        0
9156    };
9157    if cursor_skip > 0 {
9158        node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
9159    }
9160
9161    let mut selected_node_ids = BTreeSet::new();
9162    let mut selected_node_counts = BTreeMap::<String, usize>::new();
9163    let mut estimated_tokens = 0usize;
9164    let mut drops = BTreeMap::<(String, String, String), usize>::new();
9165    for node in &node_candidates {
9166        let kind_count = selected_node_counts
9167            .get(&node.kind)
9168            .copied()
9169            .unwrap_or_default();
9170        if !origin_ids.iter().any(|origin| origin == &node.id)
9171            && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
9172        {
9173            graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
9174            continue;
9175        }
9176        let tokens = graph_db_estimated_tokens(node);
9177        if !origin_ids.iter().any(|origin| origin == &node.id)
9178            && estimated_tokens.saturating_add(tokens) > max_tokens
9179        {
9180            graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
9181            continue;
9182        }
9183        selected_node_ids.insert(node.id.clone());
9184        *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
9185        estimated_tokens = estimated_tokens.saturating_add(tokens);
9186    }
9187
9188    let has_remaining_candidates = node_candidates
9189        .iter()
9190        .any(|node| !selected_node_ids.contains(&node.id));
9191
9192    let mut selected_nodes = nodes
9193        .into_iter()
9194        .filter(|node| selected_node_ids.contains(&node.id))
9195        .collect::<Vec<_>>();
9196
9197    let mut edge_candidates = edges
9198        .iter()
9199        .filter(|edge| {
9200            selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
9201        })
9202        .collect::<Vec<_>>();
9203    let edge_score_by_key = edge_candidates
9204        .iter()
9205        .map(|edge| {
9206            (
9207                graph_db_edge_key(edge),
9208                graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
9209            )
9210        })
9211        .collect::<BTreeMap<_, _>>();
9212    edge_candidates.sort_by(|left, right| {
9213        edge_score_by_key
9214            .get(&graph_db_edge_key(right))
9215            .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
9216            .then_with(|| left.kind.cmp(&right.kind))
9217            .then_with(|| left.from_id.cmp(&right.from_id))
9218            .then_with(|| left.to_id.cmp(&right.to_id))
9219    });
9220
9221    let endpoint_dropped_edges = edges
9222        .iter()
9223        .filter(|edge| {
9224            !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
9225        })
9226        .count();
9227    if endpoint_dropped_edges > 0 {
9228        drops.insert(
9229            (
9230                "edge".to_string(),
9231                "*".to_string(),
9232                "endpoint_node_dropped".to_string(),
9233            ),
9234            endpoint_dropped_edges,
9235        );
9236    }
9237
9238    let mut selected_edge_ids = BTreeSet::new();
9239    let mut selected_edge_counts = BTreeMap::<String, usize>::new();
9240    for edge in edge_candidates {
9241        let kind_count = selected_edge_counts
9242            .get(&edge.kind)
9243            .copied()
9244            .unwrap_or_default();
9245        if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
9246            graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
9247            continue;
9248        }
9249        let tokens = graph_db_estimated_tokens(edge);
9250        if estimated_tokens.saturating_add(tokens) > max_tokens {
9251            graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
9252            continue;
9253        }
9254        selected_edge_ids.insert(graph_db_edge_key(edge));
9255        *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
9256        estimated_tokens = estimated_tokens.saturating_add(tokens);
9257    }
9258
9259    let selected_edges = edges
9260        .into_iter()
9261        .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
9262        .collect::<Vec<_>>();
9263    let dropped_by_budget = graph_db_budget_drop_report(drops);
9264    let truncated = has_remaining_candidates;
9265    let next_cursor = if truncated {
9266        selected_nodes.last().map(|node| node.id.clone())
9267    } else {
9268        None
9269    };
9270    let mut diagnostics = vec![
9271        "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
9272            .to_string(),
9273        format!(
9274            "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
9275            selected_nodes.len(),
9276            candidate_nodes,
9277            selected_edges.len(),
9278            candidate_edges,
9279            max_tokens
9280        ),
9281    ];
9282    if cursor.is_some() {
9283        diagnostics.push(format!(
9284            "cursor skipped {} previously returned candidate(s)",
9285            cursor_skip
9286        ));
9287    }
9288    if next_cursor.is_some() {
9289        diagnostics.push(
9290            "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
9291        );
9292    }
9293    selected_nodes.shrink_to_fit();
9294
9295    GraphDbBudgetedSubgraph {
9296        nodes: selected_nodes,
9297        edges: selected_edges,
9298        report: GraphDbOutputBudgetReport {
9299            max_tokens,
9300            estimated_tokens,
9301            selected_nodes: selected_node_ids.len(),
9302            selected_edges: selected_edge_ids.len(),
9303            candidate_nodes,
9304            candidate_edges,
9305            dropped_by_budget,
9306            diagnostics,
9307        },
9308        truncated,
9309        next_cursor,
9310    }
9311}
9312
9313fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
9314    if edge.id.is_empty() {
9315        substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
9316    } else {
9317        edge.id.clone()
9318    }
9319}
9320
9321fn graph_db_schema() -> GraphDbSchema {
9322    GraphDbSchema {
9323        contract_versions: vec![
9324            GraphDbSchemaContract {
9325                name: "graph_db_evidence",
9326                version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9327                description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
9328            },
9329            GraphDbSchemaContract {
9330                name: "worker_prompt_packet",
9331                version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
9332                description: "conflict-matrix worker prompt packet with owned scope, scheduler fields, stable graph handles, expected tests, expansion commands, token budget, semantic ranking reasons, worker feedback closure controls, and fail-closed prompt text",
9333            },
9334            GraphDbSchemaContract {
9335                name: "conflict_matrix",
9336                version: CONFLICT_MATRIX_CONTRACT_VERSION,
9337                description: "parallel-dispatch decision report keyed by graph evidence packets, scheduler block fields, hard file/symbol/test/config gates, and soft worker-feedback closure ranking",
9338            },
9339            GraphDbSchemaContract {
9340                name: "context_pack_graph_orchestration",
9341                version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
9342                description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
9343            },
9344            GraphDbSchemaContract {
9345                name: "session_review_follow_up",
9346                version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
9347                description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
9348            },
9349            GraphDbSchemaContract {
9350                name: "dispatch_trace",
9351                version: DISPATCH_TRACE_CONTRACT_VERSION,
9352                description: "operator review trace linking backlog, job packets, worker results, source handles, semantic rows, scheduler fields, evidence packet ids, worker feedback closure controls, and worker prompt packets",
9353            },
9354            GraphDbSchemaContract {
9355                name: "dependency_dag",
9356                version: DEPENDENCY_DAG_CONTRACT_VERSION,
9357                description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
9358            },
9359        ],
9360        node_fields: vec![
9361            GraphDbSchemaField {
9362                name: "id",
9363                value_type: "string",
9364                description: "Stable provider-neutral node id",
9365            },
9366            GraphDbSchemaField {
9367                name: "kind",
9368                value_type: "string",
9369                description: "Application-defined node family such as file, symbol, or backlog",
9370            },
9371            GraphDbSchemaField {
9372                name: "label",
9373                value_type: "string",
9374                description: "Human-readable label",
9375            },
9376            GraphDbSchemaField {
9377                name: "properties",
9378                value_type: "object<string,string>",
9379                description: "Adapter-specific string properties",
9380            },
9381            GraphDbSchemaField {
9382                name: "provenance",
9383                value_type: "array",
9384                description: "Source system and source reference metadata",
9385            },
9386            GraphDbSchemaField {
9387                name: "freshness",
9388                value_type: "object|null",
9389                description: "Optional content hash and observed timestamp",
9390            },
9391        ],
9392        edge_fields: vec![
9393            GraphDbSchemaField {
9394                name: "id",
9395                value_type: "string",
9396                description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
9397            },
9398            GraphDbSchemaField {
9399                name: "from_id",
9400                value_type: "string",
9401                description: "Source node id",
9402            },
9403            GraphDbSchemaField {
9404                name: "to_id",
9405                value_type: "string",
9406                description: "Target node id",
9407            },
9408            GraphDbSchemaField {
9409                name: "kind",
9410                value_type: "string",
9411                description: "Application-defined edge relation",
9412            },
9413            GraphDbSchemaField {
9414                name: "properties",
9415                value_type: "object<string,string>",
9416                description: "Adapter-specific string properties",
9417            },
9418            GraphDbSchemaField {
9419                name: "provenance",
9420                value_type: "array",
9421                description: "Source system and source reference metadata",
9422            },
9423            GraphDbSchemaField {
9424                name: "freshness",
9425                value_type: "object|null",
9426                description: "Optional content hash and observed timestamp",
9427            },
9428        ],
9429        operations: vec![
9430            GraphDbSchemaOperation {
9431                command: "refresh",
9432                description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
9433            },
9434            GraphDbSchemaOperation {
9435                command: "status",
9436                description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
9437            },
9438            GraphDbSchemaOperation {
9439                command: "doctor",
9440                description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
9441            },
9442            GraphDbSchemaOperation {
9443                command: "drift",
9444                description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
9445            },
9446            GraphDbSchemaOperation {
9447                command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
9448                description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
9449            },
9450            GraphDbSchemaOperation {
9451                command: "snapshot-export <output.db.gz> [--force]",
9452                description: "Export the current SQLite graph.db as a gzip-compressed shareable artifact only after freshness, doctor, WAL, and sidecar checks pass",
9453            },
9454            GraphDbSchemaOperation {
9455                command: "snapshot-import <artifact.db.gz> [--replace]",
9456                description: "Stage and validate a compressed SQLite graph.db artifact through doctor and freshness checks before replacing the local graph.db",
9457            },
9458            GraphDbSchemaOperation {
9459                command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
9460                description: "Benchmark experimental read-only GraphStore backend prototypes against SQLite on bounded real, optional full-project, and synthetic projections across refresh/status/path tiers/evidence/conflict-matrix/dispatch-trace and emit promotion hold/eligibility gates",
9461            },
9462            GraphDbSchemaOperation {
9463                command: "evidence <target> [--depth N] [--limit N]",
9464                description: "Return a bounded versioned graph-db handoff packet for a backlog id or job packet handle, including packet_id, projection hash, worker_context rows, source_handle rows, worker_result rows, semantic_concept/entity rows, shortest paths, replay commands, repair commands, and next commands",
9465            },
9466            GraphDbSchemaOperation {
9467                command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
9468                description: "Resolve a natural-language phrase to cached semantic concept/entity seed nodes, then return an incident/outgoing GraphStore neighborhood around those seeds for general knowledge retrieval without changing stable neighborhood pagination defaults",
9469            },
9470            GraphDbSchemaOperation {
9471                command: "dispatch-trace [target...] --path <session> [--format json|html]",
9472                description: "Export a compact graph-backed dispatch trace with evidence packet ids, worker-result feedback closure summaries, graph links, and conflict-matrix worker prompt packets",
9473            },
9474            GraphDbSchemaOperation {
9475                command: "dependency-dag [target...] --path <session>",
9476                description: "Extract a versioned agent-doc dependency DAG from backlog ids, explicit depends-on text, shared file/symbol/test/config evidence, semantic overlap, and worker-result follow-up ids",
9477            },
9478            GraphDbSchemaOperation {
9479                command: "schema",
9480                description: "Return record and operation schemas",
9481            },
9482            GraphDbSchemaOperation {
9483                command: "node <id>",
9484                description: "Return one node by stable id",
9485            },
9486            GraphDbSchemaOperation {
9487                command: "edge <id>",
9488                description: "Return one edge by stable edge id",
9489            },
9490            GraphDbSchemaOperation {
9491                command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9492                description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
9493            },
9494            GraphDbSchemaOperation {
9495                command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9496                description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
9497            },
9498            GraphDbSchemaOperation {
9499                command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
9500                description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
9501            },
9502            GraphDbSchemaOperation {
9503                command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
9504                description: "Return a directed outgoing subgraph around a node using batched SQLite recursive traversal plus pushed filters/paging when available; JSON also includes additive ranked_neighbors while default nodes remain stable-id ordered",
9505            },
9506            GraphDbSchemaOperation {
9507                command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
9508                description: "Return the shortest directed path by node id, optionally bounded by hop count",
9509            },
9510        ],
9511    }
9512}
9513
9514pub(crate) fn sqlite_graph_freshness(
9515    store: &SqliteGraphStore,
9516    scope: &str,
9517) -> Result<GraphDbFreshnessReport> {
9518    let version = store.projection_version(scope)?;
9519    let Some(version) = version else {
9520        return Ok(GraphDbFreshnessReport {
9521            status: "missing".to_string(),
9522            fail_closed: true,
9523            projection_version: None,
9524            content_hash: None,
9525            source_watermark: None,
9526            diagnostics: vec![
9527                "graph projection metadata is missing; rebuild the graph before trusting reads"
9528                    .to_string(),
9529            ],
9530        });
9531    };
9532    let mut diagnostics = Vec::new();
9533    let fail_closed =
9534        version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
9535    if version.projection_version != GRAPH_PROJECTION_VERSION {
9536        diagnostics.push(format!(
9537            "projection version mismatch: expected {} got {}",
9538            GRAPH_PROJECTION_VERSION, version.projection_version
9539        ));
9540    }
9541    if version.content_hash.is_none() {
9542        diagnostics.push("projection content hash is missing".to_string());
9543    }
9544    Ok(GraphDbFreshnessReport {
9545        status: if fail_closed { "stale" } else { "current" }.to_string(),
9546        fail_closed,
9547        projection_version: Some(version.projection_version),
9548        content_hash: version.content_hash,
9549        source_watermark: version.source_watermark,
9550        diagnostics,
9551    })
9552}
9553
9554pub(crate) fn convex_graph_freshness(
9555    local: &ConvexProjectionRows,
9556    snapshot: &ConvexProjectionRows,
9557    scope: Option<&str>,
9558) -> GraphDbFreshnessReport {
9559    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
9560    GraphDbFreshnessReport {
9561        status: freshness.status,
9562        fail_closed: freshness.fail_closed,
9563        projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
9564        content_hash: freshness.snapshot_hash,
9565        source_watermark: None,
9566        diagnostics: freshness.diagnostics,
9567    }
9568}
9569
9570pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
9571    let (nodes, edges) = store.graph_counts()?;
9572    let files = store.file_count()?;
9573    Ok(GraphDbFreshnessReport {
9574        status: "current".to_string(),
9575        fail_closed: false,
9576        projection_version: Some("tokensave-readonly".to_string()),
9577        content_hash: None,
9578        source_watermark: Some(store.db_path().to_string_lossy().to_string()),
9579        diagnostics: vec![format!(
9580            "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
9581            nodes, edges, files
9582        )],
9583    })
9584}
9585
9586pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
9587    match TokensaveDb::discover(root) {
9588        Ok(Some(store)) => {
9589            report.push_check(GraphDbDoctorCheck {
9590                name: "tokensave_db_open".to_string(),
9591                status: "ok".to_string(),
9592                fail_closed: false,
9593                diagnostics: vec![format!(
9594                    "opened tokensave database at {}",
9595                    store.db_path().display()
9596                )],
9597                repair_commands: Vec::new(),
9598            });
9599            match (store.node_count(), store.edge_count(), store.file_count()) {
9600                (Ok(nodes), Ok(edges), Ok(files)) => {
9601                    report.push_check(GraphDbDoctorCheck {
9602                        name: "tokensave_counts".to_string(),
9603                        status: "ok".to_string(),
9604                        fail_closed: false,
9605                        diagnostics: vec![format!(
9606                            "tokensave contains {} node(s), {} edge(s), {} file(s)",
9607                            nodes, edges, files
9608                        )],
9609                        repair_commands: Vec::new(),
9610                    });
9611                }
9612                (nodes, edges, files) => {
9613                    report.push_check(graph_db_doctor_check(
9614                        "tokensave_counts",
9615                        vec![format!(
9616                            "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
9617                            nodes.err(),
9618                            edges.err(),
9619                            files.err()
9620                        )],
9621                        Vec::new(),
9622                    ));
9623                }
9624            }
9625        }
9626        Ok(None) => report.push_check(graph_db_doctor_check(
9627            "tokensave_db_exists",
9628            vec![format!(
9629                "tokensave database is missing at {}",
9630                root.join(".tokensave").join("tokensave.db").display()
9631            )],
9632            Vec::new(),
9633        )),
9634        Err(err) => report.push_check(graph_db_doctor_check(
9635            "tokensave_db_open",
9636            vec![err.to_string()],
9637            Vec::new(),
9638        )),
9639    }
9640}
9641
9642const GRAPH_DB_EVIDENCE_TARGET_KINDS: &[&str] = &[
9643    "backlog",
9644    "job_packet",
9645    "worker_result",
9646    "worker_context",
9647    "source_handle",
9648];
9649
9650pub(crate) fn graph_db_evidence_preferred_path(root: &Path, path_hint: &Path) -> Option<String> {
9651    hinted_markdown_file(root, path_hint).map(|path| {
9652        relativize_pathbuf(&path, root)
9653            .to_string_lossy()
9654            .replace('\\', "/")
9655    })
9656}
9657
9658fn graph_db_ambiguous_target_message(
9659    target: &str,
9660    kind: &str,
9661    candidates: &[SubstrateGraphNode],
9662) -> String {
9663    let mut by_path = BTreeMap::<String, String>::new();
9664    for candidate in candidates.iter().filter(|node| node.kind == kind) {
9665        let path = candidate
9666            .properties
9667            .get("path")
9668            .cloned()
9669            .unwrap_or_else(|| "<no path>".to_string());
9670        by_path.entry(path).or_insert_with(|| candidate.id.clone());
9671    }
9672    let examples = by_path
9673        .iter()
9674        .take(5)
9675        .map(|(path, node_id)| format!("{node_id} path={path}"))
9676        .collect::<Vec<_>>()
9677        .join(", ");
9678    format!(
9679        "graph-db evidence target {target} is ambiguous across {} {kind} node paths: {examples}; rerun with --path <agent-doc.md> or use an exact graph node id",
9680        by_path.len()
9681    )
9682}
9683
9684pub(crate) fn graph_db_resolve_evidence_target_with_path(
9685    store: &impl GraphStore,
9686    target: &str,
9687    preferred_path: Option<&str>,
9688) -> Result<Option<SubstrateGraphNode>> {
9689    if let Some(node) = store.node(target)? {
9690        return Ok(Some(node));
9691    }
9692    let candidates =
9693        store.evidence_target_candidates(target, GRAPH_DB_EVIDENCE_TARGET_KINDS, preferred_path)?;
9694    if candidates.is_empty() {
9695        return Ok(None);
9696    }
9697    if preferred_path.is_none() {
9698        let first_kind = candidates[0].kind.as_str();
9699        let distinct_paths = candidates
9700            .iter()
9701            .filter(|node| node.kind == first_kind)
9702            .map(|node| {
9703                node.properties
9704                    .get("path")
9705                    .map(String::as_str)
9706                    .unwrap_or("")
9707            })
9708            .collect::<BTreeSet<_>>();
9709        if distinct_paths.len() > 1 {
9710            bail!(
9711                "{}",
9712                graph_db_ambiguous_target_message(target, first_kind, &candidates)
9713            );
9714        }
9715    }
9716    Ok(candidates.into_iter().next())
9717}
9718
9719pub(crate) fn graph_db_resolve_evidence_target(
9720    store: &impl GraphStore,
9721    target: &str,
9722) -> Result<Option<SubstrateGraphNode>> {
9723    graph_db_resolve_evidence_target_with_path(store, target, None)
9724}
9725
9726fn graph_db_reachable_nodes_by_kind(
9727    store: &impl GraphStore,
9728    from_id: &str,
9729    kind: &str,
9730    depth: usize,
9731    limit: usize,
9732) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
9733    store.reachable_nodes_by_kind(from_id, kind, depth, limit)
9734}
9735
9736fn graph_db_evidence_completed_queue_drift_warnings(
9737    store: &impl GraphStore,
9738    target: &SubstrateGraphNode,
9739    worker_results: &[SubstrateGraphNode],
9740) -> Result<Vec<String>> {
9741    let ref_id = target.properties.get("ref_id").map(String::as_str);
9742    let has_completed_result = worker_results.iter().any(|node| {
9743        node.properties.get("status").map(String::as_str) == Some("completed")
9744            && node.properties.get("ref_id").map(String::as_str) == ref_id
9745    });
9746    if !has_completed_result {
9747        return Ok(Vec::new());
9748    }
9749    let active_jobs = store
9750        .nodes_by_kind("job_packet")?
9751        .into_iter()
9752        .filter(|node| {
9753            node.properties.get("ref_id").map(String::as_str) == ref_id
9754                && node.label.starts_with("do #")
9755        })
9756        .collect::<Vec<_>>();
9757    if active_jobs.is_empty() {
9758        return Ok(Vec::new());
9759    }
9760    let repair = match (target.properties.get("path"), ref_id) {
9761        (Some(path), Some(id)) => format!(
9762            "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9763            shell_quote(path),
9764            shell_quote(id),
9765            shell_quote(id)
9766        ),
9767        _ => {
9768            "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9769        }
9770    };
9771    Ok(vec![format!(
9772        "queue-head drift: target {} has {} active queued do packet(s) but already has a completed worker_result; {repair}; do not redispatch or reactivate the completed item",
9773        target.label,
9774        active_jobs.len()
9775    )])
9776}
9777
9778fn graph_db_evidence_next_commands(
9779    root: &Path,
9780    scope: Option<&str>,
9781    target: &SubstrateGraphNode,
9782    worker_context: &[SubstrateGraphNode],
9783    source_handles: &[SubstrateGraphNode],
9784    worker_results: &[SubstrateGraphNode],
9785    semantic_related: &[SubstrateGraphNode],
9786) -> Vec<String> {
9787    let mut commands = BTreeSet::new();
9788    if let Some(expand) = target.properties.get("expand") {
9789        commands.insert(expand.clone());
9790    }
9791    for worker in worker_context {
9792        if let Some(expand) = worker.properties.get("expand") {
9793            commands.insert(expand.clone());
9794        }
9795    }
9796    for source in source_handles {
9797        if let Some(expand) = source.properties.get("expand") {
9798            commands.insert(expand.clone());
9799        }
9800    }
9801    for result in worker_results {
9802        if let Some(expand) = result.properties.get("expand") {
9803            commands.insert(expand.clone());
9804        }
9805    }
9806    for semantic in semantic_related {
9807        if let Some(expand) = semantic.properties.get("expand") {
9808            commands.insert(expand.clone());
9809        }
9810    }
9811    commands.insert(format!(
9812        "tsift graph-db --path {}{} status --json",
9813        shell_quote(root.to_string_lossy().as_ref()),
9814        graph_db_scope_arg(scope)
9815    ));
9816    commands.insert(format!(
9817        "tsift graph-db --path {}{} doctor --json",
9818        shell_quote(root.to_string_lossy().as_ref()),
9819        graph_db_scope_arg(scope)
9820    ));
9821    commands.into_iter().collect()
9822}
9823
9824fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9825    vec![
9826        format!(
9827            "tsift graph-db --path {}{} refresh --json",
9828            shell_quote(root.to_string_lossy().as_ref()),
9829            graph_db_scope_arg(scope)
9830        ),
9831        format!(
9832            "tsift graph-db --path {}{} doctor --json",
9833            shell_quote(root.to_string_lossy().as_ref()),
9834            graph_db_scope_arg(scope)
9835        ),
9836    ]
9837}
9838
9839fn graph_db_evidence_replay_commands(
9840    root: &Path,
9841    scope: Option<&str>,
9842    target: &str,
9843    depth: usize,
9844    limit: usize,
9845) -> Vec<String> {
9846    vec![
9847        format!(
9848            "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9849            shell_quote(root.to_string_lossy().as_ref()),
9850            graph_db_scope_arg(scope),
9851            shell_quote(target),
9852            depth,
9853            limit
9854        ),
9855        format!(
9856            "tsift conflict-matrix --path {} {} --json",
9857            shell_quote(root.to_string_lossy().as_ref()),
9858            shell_quote(target)
9859        ),
9860    ]
9861}
9862
9863fn graph_db_evidence_packet_id(
9864    target: &str,
9865    target_node: &SubstrateGraphNode,
9866    freshness: &GraphDbFreshnessReport,
9867) -> String {
9868    stable_handle(
9869        "gevd",
9870        &format!(
9871            "{}:{}:{}:{}",
9872            GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9873            target,
9874            target_node.id,
9875            freshness.content_hash.as_deref().unwrap_or("no-hash")
9876        ),
9877    )
9878}
9879
9880pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9881    input: GraphDbEvidenceInput<'_, S>,
9882) -> Result<GraphDbEvidenceReport> {
9883    let GraphDbEvidenceInput {
9884        root,
9885        scope,
9886        backend,
9887        target,
9888        preferred_path,
9889        depth,
9890        limit,
9891        cursor,
9892        store,
9893        freshness,
9894        mut warnings,
9895    } = input;
9896    let repair_commands = graph_db_repair_commands(root, scope);
9897    if freshness.fail_closed {
9898        bail!(
9899            "graph database evidence failed closed for {} backend: {}; repair: {}",
9900            backend,
9901            freshness.diagnostics.join("; "),
9902            repair_commands.join("; ")
9903        );
9904    }
9905    let semantic_readiness =
9906        graph_db_semantic_readiness(root, scope, graph_store_semantic_node_count(store).ok());
9907    if semantic_readiness.fail_closed {
9908        warnings.push(format!(
9909            "graph evidence semantic readiness blocked: {} — {}",
9910            semantic_readiness.reason,
9911            semantic_readiness.diagnostics.join("; ")
9912        ));
9913        warnings.push(format!(
9914            "repair: {}",
9915            semantic_readiness.next_commands.join("; then ")
9916        ));
9917    }
9918    let target_node = graph_db_resolve_evidence_target_with_path(store, target, preferred_path)?
9919        .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9920    let max_rows = if limit == 0 { usize::MAX } else { limit };
9921    let mut reachable = store.reachable_nodes_by_kinds(
9922        &target_node.id,
9923        &[
9924            "worker_context",
9925            "source_handle",
9926            "worker_result",
9927            "semantic_concept",
9928            "semantic_entity",
9929        ],
9930        depth,
9931        max_rows,
9932    )?;
9933    let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9934    let source_paths = reachable.remove("source_handle").unwrap_or_default();
9935    let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9936    let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9937    semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9938    semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9939        left_path
9940            .hops
9941            .cmp(&right_path.hops)
9942            .then(left_node.kind.cmp(&right_node.kind))
9943            .then(left_node.label.cmp(&right_node.label))
9944            .then(left_node.id.cmp(&right_node.id))
9945    });
9946    if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9947        semantic_paths.truncate(max_rows);
9948    }
9949
9950    let evidence_nodes = worker_paths
9951        .iter()
9952        .chain(source_paths.iter())
9953        .chain(worker_result_paths.iter())
9954        .chain(semantic_paths.iter())
9955        .map(|(node, _)| node.clone())
9956        .collect::<Vec<_>>();
9957    let evidence_depth_by_id = worker_paths
9958        .iter()
9959        .chain(source_paths.iter())
9960        .chain(worker_result_paths.iter())
9961        .chain(semantic_paths.iter())
9962        .map(|(node, path)| (node.id.clone(), path.hops))
9963        .collect::<BTreeMap<_, _>>();
9964    let target_query = graph_db_node_search_text(&target_node);
9965    let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9966    let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9967        std::slice::from_ref(&target_node.id),
9968        &semantic_scores,
9969        evidence_nodes,
9970        Vec::new(),
9971        Some(limit),
9972        Some(&evidence_depth_by_id),
9973        cursor,
9974    );
9975    let output_budget = budgeted.report;
9976    let truncated = budgeted.truncated;
9977    let next_cursor = budgeted.next_cursor;
9978    let retained_evidence_ids = budgeted
9979        .nodes
9980        .iter()
9981        .map(|node| node.id.as_str())
9982        .collect::<BTreeSet<_>>();
9983    let worker_context = worker_paths
9984        .iter()
9985        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9986        .map(|(node, _)| node.clone())
9987        .collect::<Vec<_>>();
9988    let source_handles = source_paths
9989        .iter()
9990        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9991        .map(|(node, _)| node.clone())
9992        .collect::<Vec<_>>();
9993    let worker_results = worker_result_paths
9994        .iter()
9995        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9996        .map(|(node, _)| node.clone())
9997        .collect::<Vec<_>>();
9998    let semantic_related = semantic_paths
9999        .iter()
10000        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
10001        .map(|(node, _)| node.clone())
10002        .collect::<Vec<_>>();
10003    warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
10004        store,
10005        &target_node,
10006        &worker_results,
10007    )?);
10008    if worker_context.is_empty()
10009        && source_handles.is_empty()
10010        && worker_results.is_empty()
10011        && semantic_related.is_empty()
10012    {
10013        warnings.push(format!(
10014            "graph-db evidence target {} resolved to a {} node but has no projection-linked context rows; add source/file tokens to the backlog text or rerun graph-db refresh after the session document is indexed",
10015            target, target_node.kind
10016        ));
10017    }
10018    let shortest_paths = worker_paths
10019        .iter()
10020        .chain(source_paths.iter())
10021        .chain(worker_result_paths.iter())
10022        .chain(semantic_paths.iter())
10023        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
10024        .map(|(node, path)| GraphDbEvidencePath {
10025            to: node.id.clone(),
10026            kind: node.kind.clone(),
10027            label: node.label.clone(),
10028            path: Some(path.clone()),
10029            expand: node.properties.get("expand").cloned(),
10030        })
10031        .collect::<Vec<_>>();
10032    let next_commands = graph_db_evidence_next_commands(
10033        root,
10034        scope,
10035        &target_node,
10036        &worker_context,
10037        &source_handles,
10038        &worker_results,
10039        &semantic_related,
10040    );
10041    let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
10042    let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
10043    let projection_hash = freshness.content_hash.clone();
10044
10045    Ok(GraphDbEvidenceReport {
10046        root: root.to_string_lossy().to_string(),
10047        scope: scope.map(str::to_string),
10048        backend: backend.to_string(),
10049        contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
10050        target: target.to_string(),
10051        packet_id,
10052        projection_hash,
10053        freshness,
10054        target_node: target_node.into(),
10055        worker_context: worker_context.into_iter().map(Into::into).collect(),
10056        source_handles: source_handles.into_iter().map(Into::into).collect(),
10057        worker_results: worker_results.into_iter().map(Into::into).collect(),
10058        semantic_related: semantic_related.into_iter().map(Into::into).collect(),
10059        shortest_paths,
10060        output_budget: Some(output_budget),
10061        truncated,
10062        next_cursor,
10063        next_commands,
10064        replay_commands,
10065        repair_commands,
10066        fixture_coverage: GraphDbFixtureCoverage {
10067            test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
10068                .to_string(),
10069            fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
10070            assertions: vec![
10071                "backlog id and job packet handle resolve to graph nodes".to_string(),
10072                "worker_context rows are reachable from queued work".to_string(),
10073                "source_handle rows are reachable through bounded shortest paths".to_string(),
10074                "worker_result rows are reachable from completed or blocked work".to_string(),
10075            ],
10076        },
10077        warnings,
10078    })
10079}
10080
10081fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
10082    println!(
10083        "graph-db evidence backend: {} target: {} [{}] packet:{}",
10084        report.backend, report.target_node.id, report.target_node.kind, report.packet_id
10085    );
10086    let page_info = if report.truncated {
10087        let cursor = report.next_cursor.as_deref().unwrap_or("?");
10088        format!(" (truncated, next_cursor: {cursor})")
10089    } else {
10090        String::new()
10091    };
10092    println!(
10093        "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
10094        report.worker_context.len(),
10095        report.source_handles.len(),
10096        report.worker_results.len(),
10097        report.semantic_related.len(),
10098        report.shortest_paths.len()
10099    );
10100    for path in &report.shortest_paths {
10101        if let Some(graph_path) = &path.path {
10102            println!(
10103                "path: {} hop(s) {}",
10104                graph_path.hops,
10105                graph_path.nodes.join(" -> ")
10106            );
10107        }
10108    }
10109    for command in &report.next_commands {
10110        println!("next: {command}");
10111    }
10112    for warning in &report.warnings {
10113        println!("warning: {warning}");
10114    }
10115}
10116
10117pub(crate) fn print_graph_db_evidence_report(
10118    report: &GraphDbEvidenceReport,
10119    format: OutputFormat,
10120) -> Result<()> {
10121    if format.json_output {
10122        let page_info = if report.truncated {
10123            let cursor = report.next_cursor.as_deref().unwrap_or("?");
10124            format!(" (truncated, next_cursor: {cursor})")
10125        } else {
10126            String::new()
10127        };
10128        print_json_or_envelope(
10129            report,
10130            &format,
10131            "graph-db",
10132            "evidence",
10133            ToolEnvelopeSummary {
10134                text: format!(
10135                    "Graph DB evidence for {} returned {} worker context row(s), {} source handle(s), {} worker result row(s), {} semantic row(s), and {} shortest path(s){page_info}",
10136                    report.target,
10137                    report.worker_context.len(),
10138                    report.source_handles.len(),
10139                    report.worker_results.len(),
10140                    report.semantic_related.len(),
10141                    report.shortest_paths.len()
10142                ),
10143                metrics: vec![
10144                    envelope_metric("backend", &report.backend),
10145                    envelope_metric("worker_context", report.worker_context.len()),
10146                    envelope_metric("source_handles", report.source_handles.len()),
10147                    envelope_metric("worker_results", report.worker_results.len()),
10148                    envelope_metric("semantic_related", report.semantic_related.len()),
10149                    envelope_metric("paths", report.shortest_paths.len()),
10150                ],
10151            },
10152            report.truncated,
10153            report.next_commands.clone(),
10154        )
10155    } else {
10156        print_graph_db_evidence_human(report);
10157        Ok(())
10158    }
10159}
10160
10161pub(crate) fn graph_db_report_from_store(
10162    root: &Path,
10163    scope: Option<&str>,
10164    backend: &str,
10165    query: GraphDbQuery,
10166    store: &impl GraphStore,
10167    freshness: GraphDbFreshnessReport,
10168    warnings: Vec<String>,
10169) -> Result<GraphDbReport> {
10170    if freshness.fail_closed {
10171        bail!(
10172            "graph database read failed closed for {} backend: {}",
10173            backend,
10174            freshness.diagnostics.join("; ")
10175        );
10176    }
10177    let mut report = GraphDbReport {
10178        root: root.to_string_lossy().to_string(),
10179        scope: scope.map(str::to_string),
10180        backend: backend.to_string(),
10181        query: format!("{query:?}"),
10182        freshness,
10183        readiness: None,
10184        schema: None,
10185        node: None,
10186        edge: None,
10187        nodes: Vec::new(),
10188        edges: Vec::new(),
10189        ranked_neighbors: Vec::new(),
10190        semantic_related: Vec::new(),
10191        neighborhood_ranking_gate: None,
10192        ranked_neighborhood_comparison: None,
10193        knowledge_retrieval: None,
10194        output_budget: None,
10195        path: None,
10196        page: None,
10197        warnings,
10198    };
10199
10200    match query {
10201        GraphDbQuery::Refresh => {
10202            bail!("graph-db refresh must be handled by the refresh command path");
10203        }
10204        GraphDbQuery::Status => {
10205            bail!("graph-db status must be handled by the status command path");
10206        }
10207        GraphDbQuery::Doctor => {
10208            bail!("graph-db doctor must be handled by the doctor command path");
10209        }
10210        GraphDbQuery::Drift => {
10211            bail!("graph-db drift must be handled by the drift command path");
10212        }
10213        GraphDbQuery::Compact { .. } => {
10214            bail!("graph-db compact must be handled by the compact command path");
10215        }
10216        GraphDbQuery::SnapshotExport { .. } => {
10217            bail!("graph-db snapshot-export must be handled by the snapshot command path");
10218        }
10219        GraphDbQuery::SnapshotImport { .. } => {
10220            bail!("graph-db snapshot-import must be handled by the snapshot command path");
10221        }
10222        GraphDbQuery::BackendEval { .. } => {
10223            bail!("graph-db backend-eval must be handled by the benchmark command path");
10224        }
10225        GraphDbQuery::Evidence { .. } => {
10226            bail!("graph-db evidence must be handled by the evidence command path");
10227        }
10228        GraphDbQuery::Related {
10229            query,
10230            kind,
10231            depth,
10232            seed_limit,
10233            limit,
10234        } => {
10235            let semantic =
10236                semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
10237            let SemanticRelatedReport {
10238                items,
10239                warnings: semantic_warnings,
10240                ..
10241            } = semantic;
10242            let readiness = graph_db_semantic_readiness(
10243                root,
10244                scope,
10245                (!items.is_empty()).then_some(items.len()),
10246            );
10247            report.warnings.extend(semantic_warnings);
10248            let seed_ids = items
10249                .iter()
10250                .map(|item| item.handle.clone())
10251                .collect::<Vec<_>>();
10252            let semantic_scores = items
10253                .iter()
10254                .map(|item| (item.handle.clone(), item.score))
10255                .collect::<BTreeMap<_, _>>();
10256            let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
10257            let seed_count = seed_ids.len();
10258            let mut diagnostics = subgraph.diagnostics;
10259            let budgeted = graph_db_apply_output_budget(
10260                &seed_ids,
10261                &semantic_scores,
10262                subgraph.nodes,
10263                subgraph.edges,
10264                Some(limit),
10265            );
10266            let budget_report = budgeted.report;
10267            let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
10268            diagnostics.extend(budget_report.diagnostics.clone());
10269            diagnostics.extend(readiness.diagnostics.clone());
10270
10271            report.readiness = Some(readiness);
10272            report.semantic_related = items;
10273            if let Some(seed_id) = seed_ids.first() {
10274                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
10275                report.ranked_neighbors = graph_db_ranked_neighbors(
10276                    seed_id,
10277                    &budgeted.nodes,
10278                    &budgeted.edges,
10279                    ranked_neighbor_cap,
10280                );
10281                report.neighborhood_ranking_gate =
10282                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10283            }
10284            report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10285            report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10286            report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
10287                mode: "semantic_seeded_neighborhood".to_string(),
10288                query,
10289                seed_kind: semantic_related_kind_name(kind).to_string(),
10290                seed_limit,
10291                seed_count,
10292                depth,
10293                limit,
10294                node_count: report.nodes.len(),
10295                edge_count: report.edges.len(),
10296                truncated: subgraph.truncated || dropped_by_budget,
10297                traversal: "incident_plus_outgoing_edges".to_string(),
10298                freshness_boundary:
10299                    "semantic rows must come from refreshed summary or tsift-memory graph records"
10300                        .to_string(),
10301                privacy_boundary:
10302                    "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
10303                        .to_string(),
10304                diagnostics,
10305            });
10306            report.output_budget = Some(budget_report);
10307        }
10308        GraphDbQuery::Schema => {
10309            report.schema = Some(graph_db_schema());
10310        }
10311        GraphDbQuery::Node { id } => {
10312            report.node = store.node(&id)?.map(Into::into);
10313        }
10314        GraphDbQuery::Edge { id } => {
10315            report.edge = store.edge(&id)?.map(Into::into);
10316        }
10317        GraphDbQuery::Edges {
10318            edge_kind,
10319            cursor,
10320            limit,
10321            property_filters,
10322        } => {
10323            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10324            let paged = store.paged_edges(
10325                edge_kind.as_deref(),
10326                graph_db_query_options_for_store(&options),
10327            )?;
10328            report.edges = paged.edges.into_iter().map(Into::into).collect();
10329            report.page = Some(graph_db_page_report_from_store(
10330                paged.page,
10331                options.property_filters,
10332            ));
10333        }
10334        GraphDbQuery::Incident {
10335            id,
10336            edge_kind,
10337            cursor,
10338            limit,
10339            property_filters,
10340        } => {
10341            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10342            let paged = store.paged_incident_edges(
10343                &id,
10344                edge_kind.as_deref(),
10345                graph_db_query_options_for_store(&options),
10346            )?;
10347            report.edges = paged.edges.into_iter().map(Into::into).collect();
10348            report.page = Some(graph_db_page_report_from_store(
10349                paged.page,
10350                options.property_filters,
10351            ));
10352        }
10353        GraphDbQuery::Kind {
10354            kind,
10355            cursor,
10356            limit,
10357            property_filters,
10358        } => {
10359            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10360            let paged =
10361                store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
10362            report.nodes = paged.nodes.into_iter().map(Into::into).collect();
10363            report.edges = paged.edges.into_iter().map(Into::into).collect();
10364            report.page = Some(graph_db_page_report_from_store(
10365                paged.page,
10366                options.property_filters,
10367            ));
10368        }
10369        GraphDbQuery::Neighborhood {
10370            id,
10371            depth,
10372            edge_kind,
10373            cursor,
10374            limit,
10375            property_filters,
10376        } => {
10377            let options = graph_db_query_options(cursor, limit, &property_filters)?;
10378            if let Some(paged) = store.paged_neighborhood(
10379                &id,
10380                depth,
10381                edge_kind.as_deref(),
10382                graph_db_query_options_for_store(&options),
10383            )? {
10384                let budgeted = graph_db_apply_output_budget(
10385                    std::slice::from_ref(&id),
10386                    &BTreeMap::new(),
10387                    paged.nodes,
10388                    paged.edges,
10389                    options.limit,
10390                );
10391                let budget_report = budgeted.report;
10392                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
10393                let ranked_neighbors = graph_db_ranked_neighbors(
10394                    &id,
10395                    &budgeted.nodes,
10396                    &budgeted.edges,
10397                    ranked_neighbor_cap,
10398                );
10399                let comparison = graph_db_ranked_neighborhood_comparison(
10400                    &id,
10401                    depth,
10402                    edge_kind.as_deref(),
10403                    options.limit,
10404                    &budgeted.nodes,
10405                    &budgeted.edges,
10406                    store,
10407                )?;
10408                report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10409                report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10410                report.ranked_neighbors = ranked_neighbors;
10411                report.neighborhood_ranking_gate =
10412                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10413                let mut page =
10414                    graph_db_page_report_from_store(paged.page, options.property_filters);
10415                page.returned_nodes = report.nodes.len();
10416                page.returned_edges = report.edges.len();
10417                page.truncated |= !budget_report.dropped_by_budget.is_empty();
10418                page.diagnostics.extend(budget_report.diagnostics.clone());
10419                report.page = Some(page);
10420                report.output_budget = Some(budget_report);
10421                if let Some(comparison) = comparison {
10422                    report.ranked_neighborhood_comparison = Some(comparison);
10423                }
10424            }
10425        }
10426        GraphDbQuery::Path {
10427            from,
10428            to,
10429            edge_kind,
10430            max_hops,
10431        } => {
10432            report.path =
10433                store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
10434            if let Some(max_hops) = max_hops
10435                && report.path.is_none()
10436            {
10437                report.warnings.push(format!(
10438                    "no directed path found within --max-hops {}",
10439                    max_hops
10440                ));
10441            }
10442        }
10443        GraphDbQuery::Map { .. } => {
10444            bail!("graph-db map must be handled by the map command path");
10445        }
10446    }
10447    Ok(report)
10448}
10449
10450pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
10451    if compact {
10452        println!(
10453            "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
10454            report.backend,
10455            report.query,
10456            report.nodes.len() + usize::from(report.node.is_some()),
10457            report.edges.len() + usize::from(report.edge.is_some()),
10458            report.freshness.status
10459        );
10460        return;
10461    }
10462    println!("graph-db backend: {}", report.backend);
10463    println!("freshness: {}", report.freshness.status);
10464    if let Some(readiness) = &report.readiness {
10465        println!(
10466            "readiness: {} reason: {} fail_closed: {}",
10467            readiness.status, readiness.reason, readiness.fail_closed
10468        );
10469        for diagnostic in &readiness.diagnostics {
10470            println!("readiness diagnostic: {diagnostic}");
10471        }
10472        for command in &readiness.next_commands {
10473            println!("readiness next: {command}");
10474        }
10475    }
10476    if let Some(schema) = &report.schema {
10477        println!(
10478            "schema: {} node fields, {} edge fields, {} operations",
10479            schema.node_fields.len(),
10480            schema.edge_fields.len(),
10481            schema.operations.len()
10482        );
10483    }
10484    if let Some(node) = &report.node {
10485        println!("node: {} [{}] {}", node.id, node.kind, node.label);
10486    }
10487    if let Some(edge) = &report.edge {
10488        let edge_full: SubstrateGraphEdge = edge.into();
10489        println!(
10490            "edge: {} {} -{}-> {}",
10491            graph_db_edge_key(&edge_full),
10492            edge.from_id,
10493            edge.kind,
10494            edge.to_id
10495        );
10496    }
10497    if let Some(knowledge) = &report.knowledge_retrieval {
10498        println!(
10499            "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
10500            knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
10501        );
10502    }
10503    for item in &report.semantic_related {
10504        println!(
10505            "semantic_seed: {:.3} [{}] {} ({})",
10506            item.score, item.kind, item.label, item.handle
10507        );
10508    }
10509    for node in &report.nodes {
10510        println!("node: {} [{}] {}", node.id, node.kind, node.label);
10511    }
10512    for edge in &report.edges {
10513        let edge_full: SubstrateGraphEdge = edge.into();
10514        println!(
10515            "edge: {} {} -{}-> {}",
10516            graph_db_edge_key(&edge_full),
10517            edge.from_id,
10518            edge.kind,
10519            edge.to_id
10520        );
10521    }
10522    for neighbor in &report.ranked_neighbors {
10523        println!(
10524            "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
10525            neighbor.rank,
10526            neighbor.score,
10527            neighbor
10528                .depth
10529                .map(|depth| depth.to_string())
10530                .unwrap_or_else(|| "unknown".to_string()),
10531            neighbor.node_id,
10532            neighbor.kind,
10533            neighbor.label
10534        );
10535    }
10536    if let Some(gate) = &report.neighborhood_ranking_gate {
10537        println!(
10538            "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
10539            gate.status, gate.default_order, gate.ranked_output_default
10540        );
10541    }
10542    if let Some(path) = &report.path {
10543        println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
10544    }
10545    if let Some(page) = &report.page {
10546        if let Some(next_cursor) = &page.next_cursor {
10547            println!("next_cursor: {next_cursor}");
10548        }
10549        for diagnostic in &page.diagnostics {
10550            println!("page: {diagnostic}");
10551        }
10552    }
10553    for warning in &report.warnings {
10554        println!("warning: {warning}");
10555    }
10556}
10557
10558pub(crate) fn graph_db_backend_eval_phase_timing(
10559    name: &str,
10560    duration_micros: u128,
10561    detail: &str,
10562) -> GraphDbBackendEvalPhaseTiming {
10563    GraphDbBackendEvalPhaseTiming {
10564        name: name.to_string(),
10565        duration_micros,
10566        detail: detail.to_string(),
10567    }
10568}
10569
10570pub(crate) fn graph_db_backend_eval_timed_phase<T>(
10571    phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
10572    name: &str,
10573    detail: &str,
10574    run: impl FnOnce() -> Result<T>,
10575) -> Result<T> {
10576    let started = Instant::now();
10577    let result = run();
10578    phases.push(graph_db_backend_eval_phase_timing(
10579        name,
10580        started.elapsed().as_micros(),
10581        detail,
10582    ));
10583    result
10584}
10585
10586pub(crate) fn graph_db_backend_eval_refresh_total_micros(
10587    phases: &[GraphDbBackendEvalPhaseTiming],
10588) -> u128 {
10589    phases
10590        .iter()
10591        .filter(|phase| phase.name != "conflict_matrix_preparation")
10592        .map(|phase| phase.duration_micros)
10593        .sum()
10594}
10595
10596pub(crate) fn graph_db_backend_eval_cached_refresh(
10597    root: &Path,
10598    scope: Option<&str>,
10599    source_watermark: Option<&str>,
10600) -> Result<
10601    Option<(
10602        TraversalGraphBuild,
10603        SqliteProjectionRefresh,
10604        Vec<GraphDbBackendEvalPhaseTiming>,
10605    )>,
10606> {
10607    let Some(source_watermark) = source_watermark else {
10608        return Ok(None);
10609    };
10610    let graph_db = graph_substrate_db_path(root, scope);
10611    if !graph_db.exists() {
10612        return Ok(None);
10613    }
10614
10615    let started = Instant::now();
10616    let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
10617        Ok(store) => store,
10618        Err(_) => return Ok(None),
10619    };
10620    if store.has_user_triggers().unwrap_or(true) {
10621        return Ok(None);
10622    }
10623    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
10624    if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
10625        return Ok(None);
10626    }
10627
10628    let phases = vec![
10629        graph_db_backend_eval_phase_timing(
10630            "source_graph_build",
10631            started.elapsed().as_micros(),
10632            "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10633        ),
10634        graph_db_backend_eval_phase_timing(
10635            "projection_rows",
10636            0,
10637            "reused cached provider-neutral projection rows from graph.db",
10638        ),
10639        graph_db_backend_eval_phase_timing(
10640            "sqlite_open",
10641            0,
10642            "reused existing graph.db projection without opening a write transaction",
10643        ),
10644    ];
10645    let refresh = SqliteProjectionRefresh {
10646        scope: scope.unwrap_or("root").to_string(),
10647        projection_version: freshness
10648            .projection_version
10649            .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
10650        source_watermark: Some(source_watermark.to_string()),
10651        tombstoned_nodes: Vec::new(),
10652        tombstoned_edges: Vec::new(),
10653        upserted_nodes: 0,
10654        upserted_edges: 0,
10655        unchanged_nodes: 0,
10656        unchanged_edges: 0,
10657        upserted_properties: 0,
10658        unchanged_properties: 0,
10659        deleted_properties: 0,
10660        deleted_nodes: 0,
10661        deleted_edges: 0,
10662        pruned_tombstones: 0,
10663        file_size_bytes_before: None,
10664        file_size_bytes_after: None,
10665        phase_timings: Vec::new(),
10666    };
10667    Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
10668}
10669
10670pub(crate) fn graph_db_backend_eval_reused_cached_projection(
10671    phases: &[GraphDbBackendEvalPhaseTiming],
10672) -> bool {
10673    phases.iter().any(|phase| {
10674        phase.name == "source_graph_build"
10675            && phase.detail.contains("reused current graph.db projection")
10676    })
10677}
10678
10679pub(crate) fn graph_db_backend_eval_update_source_watermark(
10680    root: &Path,
10681    path_hint: &Path,
10682    scope: Option<&str>,
10683) -> Result<()> {
10684    let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
10685        return Ok(());
10686    };
10687    let graph_db = graph_substrate_db_path(root, scope);
10688    let mut store = SqliteGraphStore::open(&graph_db)?;
10689    store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
10690    Ok(())
10691}
10692
10693pub(crate) fn graph_db_backend_eval_refresh_with_profile(
10694    root: &Path,
10695    path_hint: &Path,
10696    scope: Option<&str>,
10697) -> Result<(
10698    TraversalGraphBuild,
10699    SqliteProjectionRefresh,
10700    Vec<GraphDbBackendEvalPhaseTiming>,
10701)> {
10702    let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
10703    if let Some(cached) =
10704        graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
10705    {
10706        return Ok(cached);
10707    }
10708
10709    let mut phases = Vec::new();
10710    let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
10711        "bounded session projection: index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads; skips global call-edge materialization because full-projection is the complete-call-graph regression guard"
10712    } else {
10713        "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
10714    };
10715    let source_graph = graph_db_backend_eval_timed_phase(
10716        &mut phases,
10717        "source_graph_build",
10718        source_graph_detail,
10719        || build_traversal_graph_source_with_options(root, path_hint, scope, false),
10720    )?;
10721    let projection = graph_db_backend_eval_timed_phase(
10722        &mut phases,
10723        "projection_rows",
10724        "provider-neutral GraphStore node/edge row construction before SQLite persistence",
10725        || traversal_projection_from_graph(root, scope, &source_graph),
10726    )?;
10727    let graph_db = graph_substrate_db_path(root, scope);
10728    let mut store = graph_db_backend_eval_timed_phase(
10729        &mut phases,
10730        "sqlite_open",
10731        "open the local SQLite graph.db with WAL and busy-timeout settings",
10732        || SqliteGraphStore::open(&graph_db),
10733    )?;
10734    let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
10735        .ok()
10736        .flatten();
10737    let refresh = store.replace_projection_with_version(
10738        scope.unwrap_or("root"),
10739        &projection,
10740        Some(GRAPH_PROJECTION_VERSION),
10741        refreshed_source_watermark
10742            .or(source_watermark)
10743            .or_else(|| graph_projection_content_hash(&projection)),
10744    )?;
10745    phases.extend(
10746        refresh
10747            .phase_timings
10748            .iter()
10749            .map(|phase| GraphDbBackendEvalPhaseTiming {
10750                name: phase.name.clone(),
10751                duration_micros: phase.duration_micros,
10752                detail: phase.detail.clone(),
10753            }),
10754    );
10755    Ok((source_graph, refresh, phases))
10756}
10757
10758fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10759    root.join(".tsift/backend-eval-cache")
10760}
10761
10762fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10763    graph_db_backend_eval_disk_cache_dir(root)
10764        .join(kind)
10765        .join(format!("{key}.json.gz"))
10766}
10767
10768fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10769    graph_db_backend_eval_disk_cache_dir(root)
10770        .join(kind)
10771        .join(format!("{key}.json"))
10772}
10773
10774#[derive(Default, Clone)]
10775struct GraphDbBackendEvalDiskCacheReadProfile {
10776    file_read_micros: u128,
10777    gzip_decode_micros: u128,
10778    serde_decode_micros: u128,
10779    legacy: bool,
10780}
10781
10782fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10783    root: &Path,
10784    kind: &str,
10785    key: &str,
10786) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10787    let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10788    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10789    let read_started = Instant::now();
10790    let read_result = fs::read(&path);
10791    profile.file_read_micros = read_started.elapsed().as_micros();
10792    if let Ok(bytes) = read_result {
10793        let decode_started = Instant::now();
10794        let mut decoder = GzDecoder::new(bytes.as_slice());
10795        let mut decoded = Vec::new();
10796        let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10797        profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10798        if decode_ok {
10799            let serde_started = Instant::now();
10800            let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10801            profile.serde_decode_micros = serde_started.elapsed().as_micros();
10802            if let Some(value) = parsed {
10803                return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10804            }
10805        }
10806    }
10807
10808    let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10809    let legacy_started = Instant::now();
10810    let bytes = fs::read(legacy_path).ok()?;
10811    profile.file_read_micros = profile
10812        .file_read_micros
10813        .saturating_add(legacy_started.elapsed().as_micros());
10814    let serde_started = Instant::now();
10815    let value = serde_json::from_slice(&bytes).ok()?;
10816    profile.serde_decode_micros = profile
10817        .serde_decode_micros
10818        .saturating_add(serde_started.elapsed().as_micros());
10819    profile.legacy = true;
10820    Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10821}
10822
10823#[derive(Default, Clone)]
10824struct GraphDbBackendEvalDiskCacheWriteProfile {
10825    serde_encode_micros: u128,
10826    gzip_encode_micros: u128,
10827    file_write_micros: u128,
10828}
10829
10830fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10831    root: &Path,
10832    kind: &str,
10833    key: &str,
10834    value: &T,
10835) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10836    let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10837    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10838    let parent = path.parent()?;
10839    if fs::create_dir_all(parent).is_err() {
10840        return None;
10841    }
10842    let serde_started = Instant::now();
10843    let bytes = serde_json::to_vec(value).ok()?;
10844    profile.serde_encode_micros = serde_started.elapsed().as_micros();
10845    let gzip_started = Instant::now();
10846    let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10847    if encoder.write_all(&bytes).is_err() {
10848        return None;
10849    }
10850    let encoded = encoder.finish().ok()?;
10851    profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10852    let write_started = Instant::now();
10853    if fs::write(&path, &encoded).is_err() {
10854        return None;
10855    }
10856    profile.file_write_micros = write_started.elapsed().as_micros();
10857    Some((encoded.len() as u64, bytes.len() as u64, profile))
10858}
10859
10860fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10861    let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10862    let Ok(entries) = fs::read_dir(dir) else {
10863        return (0, 0);
10864    };
10865    let keep_name = format!("{keep_key}.json.gz");
10866    let mut pruned_files = 0usize;
10867    let mut pruned_bytes = 0u64;
10868    for entry in entries.flatten() {
10869        let path = entry.path();
10870        if !path.is_file() {
10871            continue;
10872        }
10873        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10874            continue;
10875        };
10876        if name == keep_name {
10877            continue;
10878        }
10879        let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10880        if !is_backend_eval_cache {
10881            continue;
10882        }
10883        let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10884        if fs::remove_file(&path).is_ok() {
10885            pruned_files += 1;
10886            pruned_bytes += bytes;
10887        }
10888    }
10889    (pruned_files, pruned_bytes)
10890}
10891
10892fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10893    root: &Path,
10894    source_root: &Path,
10895) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10896    let mut rows = Vec::new();
10897    let mut entries = walk::walk_files(source_root)?;
10898    entries.sort_by(|left, right| left.path.cmp(&right.path));
10899    for entry in entries {
10900        if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10901            continue;
10902        }
10903        if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10904            continue;
10905        }
10906        let bytes = fs::read(&entry.path)
10907            .with_context(|| format!("reading source input {}", entry.path.display()))?;
10908        rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10909            path: traversal_watermark_path(root, &entry.path),
10910            bytes: bytes.len() as u64,
10911            content_hash: content_hash(&bytes)?,
10912        });
10913    }
10914    Ok(rows)
10915}
10916
10917fn graph_db_backend_eval_full_projection_source_watermark(
10918    root: &Path,
10919    scope: Option<&str>,
10920) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10921    let path_hint = root;
10922    let mut detail_parts = Vec::new();
10923    let mut parts = vec![
10924        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10925        format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10926        "watermark_kind:stable_full_projection_inputs".to_string(),
10927        format!("scope:{}", scope.unwrap_or("root")),
10928        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10929    ];
10930
10931    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10932    match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10933        Some(db_path) => {
10934            let db = index::IndexDb::open_read_only_resilient(db_path)?;
10935            parts.push("index_mode:indexed".to_string());
10936            detail_parts.push("mode=indexed".to_string());
10937            parts.push(format!(
10938                "index_source_root:{}",
10939                traversal_watermark_path(root, &gate.source_root)
10940            ));
10941
10942            let symbols = db
10943                .all_symbols()?
10944                .into_iter()
10945                .filter(|symbol| {
10946                    !traversal_path_is_generated_artifact(
10947                        root,
10948                        &gate.source_root,
10949                        Path::new(&symbol.file),
10950                    ) && !traversal_path_is_session_markdown(
10951                        root,
10952                        &gate.source_root,
10953                        Path::new(&symbol.file),
10954                    )
10955                })
10956                .collect::<Vec<_>>();
10957            let symbols_hash = content_hash(&symbols)?;
10958            detail_parts.push(format!("symbols={symbols_hash}"));
10959            parts.push(format!("index_symbols:{symbols_hash}"));
10960
10961            let edges = db
10962                .all_stored_edges()?
10963                .into_iter()
10964                .filter(|edge| {
10965                    !traversal_path_is_generated_artifact(
10966                        root,
10967                        &gate.source_root,
10968                        Path::new(&edge.caller_file),
10969                    ) && !traversal_path_is_session_markdown(
10970                        root,
10971                        &gate.source_root,
10972                        Path::new(&edge.caller_file),
10973                    )
10974                })
10975                .collect::<Vec<_>>();
10976            let edges_hash = content_hash(&edges)?;
10977            detail_parts.push(format!("call_edges={edges_hash}"));
10978            parts.push(format!("index_call_edges:{edges_hash}"));
10979
10980            let routes = db
10981                .all_routes()?
10982                .into_iter()
10983                .filter(|route| {
10984                    !traversal_path_is_generated_artifact(
10985                        root,
10986                        &gate.source_root,
10987                        Path::new(&route.file),
10988                    ) && !traversal_path_is_session_markdown(
10989                        root,
10990                        &gate.source_root,
10991                        Path::new(&route.file),
10992                    )
10993                })
10994                .collect::<Vec<_>>();
10995            let routes_hash = content_hash(&routes)?;
10996            detail_parts.push(format!("routes={routes_hash}"));
10997            parts.push(format!("index_routes:{routes_hash}"));
10998        }
10999        None => {
11000            parts.push("index_mode:raw_fallback".to_string());
11001            detail_parts.push("mode=raw_fallback".to_string());
11002            parts.push(format!(
11003                "raw_source_root:{}",
11004                traversal_watermark_path(root, &gate.source_root)
11005            ));
11006            let raw_rows =
11007                graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
11008            let raw_hash = content_hash(&raw_rows)?;
11009            detail_parts.push(format!("raw_source_files={raw_hash}"));
11010            parts.push(format!("raw_source_files:{raw_hash}"));
11011        }
11012    }
11013
11014    parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
11015    detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
11016    let summaries_start = parts.len();
11017    push_traversal_summaries_watermark_part(root, &mut parts)?;
11018    let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
11019    detail_parts.push(format!("summaries={summaries_hash}"));
11020    let value = content_hash(&parts)?;
11021    detail_parts.push(format!("watermark={value}"));
11022    Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
11023        value,
11024        detail: detail_parts.join(" "),
11025    })
11026}
11027
11028fn graph_db_backend_eval_full_projection_cache_key(
11029    root: &Path,
11030    scope: Option<&str>,
11031) -> Result<(String, String, String)> {
11032    let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
11033    let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
11034        root,
11035        scope,
11036        &source_watermark.value,
11037    )?;
11038    Ok((source_watermark.value, key, source_watermark.detail))
11039}
11040
11041fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
11042    root: &Path,
11043    scope: Option<&str>,
11044    source_watermark: &str,
11045) -> Result<String> {
11046    content_hash(&serde_json::json!({
11047    "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
11048    "root": root.display().to_string(),
11049    "scope": scope.unwrap_or("root"),
11050    "source_watermark": source_watermark,
11051    }))
11052}
11053
11054pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
11055    root: &Path,
11056    scope: Option<&str>,
11057) -> Result<(
11058    GraphProjection,
11059    Vec<String>,
11060    Vec<GraphDbBackendEvalPhaseTiming>,
11061    GraphDbBackendEvalFullProjectionCacheStats,
11062)> {
11063    let (source_watermark, key, source_watermark_detail) =
11064        graph_db_backend_eval_full_projection_cache_key(root, scope)?;
11065    let lookup_started = Instant::now();
11066    if let Some((cached, disk_bytes, json_bytes, read_profile)) =
11067        graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
11068            root,
11069            "full_projection",
11070            &key,
11071        )
11072        && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
11073        && cached.key == key
11074        && cached.source_watermark == source_watermark
11075    {
11076        let lookup_overhead_micros = lookup_started
11077            .elapsed()
11078            .as_micros()
11079            .saturating_sub(read_profile.file_read_micros)
11080            .saturating_sub(read_profile.gzip_decode_micros)
11081            .saturating_sub(read_profile.serde_decode_micros);
11082        let prune_started = Instant::now();
11083        let (pruned_files, pruned_bytes) =
11084            graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
11085        let prune_micros = prune_started.elapsed().as_micros();
11086        let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
11087            hit: true,
11088            disk_bytes,
11089            json_bytes,
11090            pruned_files,
11091            pruned_bytes,
11092        };
11093        let read_detail_suffix = if read_profile.legacy {
11094            " (legacy uncompressed cache path)"
11095        } else {
11096            ""
11097        };
11098        return Ok((
11099            cached.projection,
11100            cached.warnings,
11101            vec![
11102                graph_db_backend_eval_phase_timing(
11103                    "full_projection.cache_lookup",
11104                    lookup_overhead_micros,
11105                    &format!(
11106                        "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
11107                    ),
11108                ),
11109                graph_db_backend_eval_phase_timing(
11110                    "full_projection.cache.file_read",
11111                    read_profile.file_read_micros,
11112                    &format!(
11113                        "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
11114                    ),
11115                ),
11116                graph_db_backend_eval_phase_timing(
11117                    "full_projection.cache.gzip_decode",
11118                    read_profile.gzip_decode_micros,
11119                    "gunzip the compressed projection cache bytes",
11120                ),
11121                graph_db_backend_eval_phase_timing(
11122                    "full_projection.cache.serde_decode",
11123                    read_profile.serde_decode_micros,
11124                    "serde_json deserialize the decoded projection cache payload",
11125                ),
11126                graph_db_backend_eval_phase_timing(
11127                    "full_projection.cache.prune",
11128                    prune_micros,
11129                    "prune sibling cache files older than the current key",
11130                ),
11131                graph_db_backend_eval_phase_timing(
11132                    "full_projection.source_graph_build",
11133                    0,
11134                    "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
11135                ),
11136                graph_db_backend_eval_phase_timing(
11137                    "full_projection.projection_rows",
11138                    0,
11139                    "reused cached provider-neutral full-project projection rows",
11140                ),
11141            ],
11142            cache_stats,
11143        ));
11144    }
11145
11146    let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
11147    let mut phases = vec![graph_db_backend_eval_phase_timing(
11148        "full_projection.cache_lookup",
11149        lookup_started.elapsed().as_micros(),
11150        &format!(
11151            "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
11152        ),
11153    )];
11154    let full_source = graph_db_backend_eval_timed_phase(
11155        &mut phases,
11156        "full_projection.source_graph_build",
11157        "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
11158        || build_traversal_graph_source_with_options(root, root, scope, false),
11159    )?;
11160    let projection = graph_db_backend_eval_timed_phase(
11161        &mut phases,
11162        "full_projection.projection_rows",
11163        "provider-neutral row construction for the opt-in full-project projection dataset",
11164        || traversal_projection_from_graph(root, scope, &full_source),
11165    )?;
11166    let warnings = full_source.warnings;
11167    let refreshed_source_watermark =
11168        graph_db_backend_eval_full_projection_source_watermark(root, scope)
11169            .map(|watermark| watermark.value)
11170            .unwrap_or_else(|_| source_watermark.clone());
11171    let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
11172        root,
11173        scope,
11174        &refreshed_source_watermark,
11175    )?;
11176    let cache = GraphDbBackendEvalFullProjectionCache {
11177        version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
11178        key: write_key.clone(),
11179        source_watermark: refreshed_source_watermark,
11180        projection: projection.clone(),
11181        warnings: warnings.clone(),
11182    };
11183    if let Some((disk_bytes, json_bytes, write_profile)) =
11184        graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
11185    {
11186        cache_stats.disk_bytes = disk_bytes;
11187        cache_stats.json_bytes = json_bytes;
11188        phases.push(graph_db_backend_eval_phase_timing(
11189            "full_projection.cache.serde_encode",
11190            write_profile.serde_encode_micros,
11191            "serde_json serialize the projection cache payload before compression",
11192        ));
11193        phases.push(graph_db_backend_eval_phase_timing(
11194            "full_projection.cache.gzip_encode",
11195            write_profile.gzip_encode_micros,
11196            "gzip-compress the serialized projection cache payload",
11197        ));
11198        phases.push(graph_db_backend_eval_phase_timing(
11199            "full_projection.cache.file_write",
11200            write_profile.file_write_micros,
11201            "write the compressed projection cache bytes to .tsift/backend-eval-cache",
11202        ));
11203    }
11204    let prune_started = Instant::now();
11205    let (pruned_files, pruned_bytes) =
11206        graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
11207    phases.push(graph_db_backend_eval_phase_timing(
11208        "full_projection.cache.prune",
11209        prune_started.elapsed().as_micros(),
11210        "prune sibling cache files older than the current key",
11211    ));
11212    cache_stats.pruned_files = pruned_files;
11213    cache_stats.pruned_bytes = pruned_bytes;
11214    Ok((projection, warnings, phases, cache_stats))
11215}
11216
11217fn graph_db_backend_eval_timed(
11218    name: &str,
11219    run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
11220) -> (
11221    GraphDbBackendEvalOperation,
11222    Option<GraphDbBackendEvalSignature>,
11223) {
11224    let started = Instant::now();
11225    match run() {
11226        Ok((rows, value)) => (
11227            GraphDbBackendEvalOperation {
11228                name: name.to_string(),
11229                supported: true,
11230                status: "ok".to_string(),
11231                duration_micros: started.elapsed().as_micros(),
11232                rows,
11233                error: None,
11234            },
11235            Some(GraphDbBackendEvalSignature {
11236                operation: name.to_string(),
11237                value,
11238            }),
11239        ),
11240        Err(err) => (
11241            GraphDbBackendEvalOperation {
11242                name: name.to_string(),
11243                supported: false,
11244                status: "error".to_string(),
11245                duration_micros: started.elapsed().as_micros(),
11246                rows: None,
11247                error: Some(format!("{err:#}")),
11248            },
11249            None,
11250        ),
11251    }
11252}
11253
11254fn graph_db_backend_eval_parity(
11255    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11256    candidate_signatures: &[GraphDbBackendEvalSignature],
11257) -> GraphDbBackendEvalParity {
11258    let Some(sqlite_signatures) = sqlite_signatures else {
11259        return GraphDbBackendEvalParity {
11260            matches_sqlite: true,
11261            diagnostics: Vec::new(),
11262        };
11263    };
11264    let sqlite = sqlite_signatures
11265        .iter()
11266        .map(|signature| (signature.operation.as_str(), &signature.value))
11267        .collect::<BTreeMap<_, _>>();
11268    let candidate = candidate_signatures
11269        .iter()
11270        .map(|signature| (signature.operation.as_str(), &signature.value))
11271        .collect::<BTreeMap<_, _>>();
11272    let mut diagnostics = Vec::new();
11273    for (operation, sqlite_value) in sqlite {
11274        match candidate.get(operation) {
11275            Some(candidate_value) if *candidate_value == sqlite_value => {}
11276            Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
11277            None => diagnostics.push(format!(
11278                "{operation} did not complete for candidate backend"
11279            )),
11280        }
11281    }
11282    GraphDbBackendEvalParity {
11283        matches_sqlite: diagnostics.is_empty(),
11284        diagnostics,
11285    }
11286}
11287
11288pub(crate) fn graph_db_backend_eval_targets(
11289    store: &impl GraphStore,
11290    requested: &[String],
11291) -> Result<Vec<String>> {
11292    let requested = requested
11293        .iter()
11294        .filter_map(|target| normalize_conflict_target(target))
11295        .collect::<Vec<_>>();
11296    if !requested.is_empty() {
11297        return Ok(requested);
11298    }
11299
11300    for kind in ["backlog", "job_packet"] {
11301        let nodes = store.nodes_by_kind(kind)?;
11302        if let Some(node) = nodes.first() {
11303            if let Some(ref_id) = node.properties.get("ref_id") {
11304                return Ok(vec![ref_id.clone()]);
11305            }
11306            return Ok(vec![node.id.clone()]);
11307        }
11308    }
11309    Ok(Vec::new())
11310}
11311
11312fn graph_db_backend_eval_path_targets(
11313    store: &impl GraphStore,
11314    max_hops: usize,
11315) -> Result<Option<(String, String, usize)>> {
11316    let synthetic_from = "gsym-synthetic-0000";
11317    let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
11318    if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
11319        let outgoing = store.outgoing_edges(synthetic_from, None)?;
11320        if outgoing.len() > 1
11321            && let Some(edge) = outgoing.first()
11322        {
11323            return Ok(Some((
11324                edge.from_id.clone(),
11325                edge.to_id.clone(),
11326                GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11327            )));
11328        }
11329        return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
11330    }
11331
11332    Ok(store.sample_edge(None)?.map(|edge| {
11333        (
11334            edge.from_id,
11335            edge.to_id,
11336            GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11337        )
11338    }))
11339}
11340
11341fn graph_db_backend_eval_path_operation<S: GraphStore>(
11342    store: &S,
11343    configured_max_hops: usize,
11344) -> (
11345    GraphDbBackendEvalOperation,
11346    Option<GraphDbBackendEvalSignature>,
11347) {
11348    let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11349        "path_max_hops".to_string()
11350    } else {
11351        format!("path_max_hops_{configured_max_hops}")
11352    };
11353    graph_db_backend_eval_timed(&operation_name, || {
11354        let (from, to, effective_max_hops) =
11355            graph_db_backend_eval_path_targets(store, configured_max_hops)?
11356                .context("backend-eval path probe requires at least one traversable edge")?;
11357        let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
11358        let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11359            Some(format!(
11360                "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
11361                GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
11362            ))
11363        } else if path.is_none() && effective_max_hops == configured_max_hops {
11364            Some(format!(
11365                "path probe truncated at {configured_max_hops} hops before a route was found"
11366            ))
11367        } else {
11368            None
11369        };
11370        Ok((
11371            path.as_ref().map(|path| path.nodes.len()),
11372            serde_json::json!({
11373                "from": from,
11374                "to": to,
11375                "configured_max_hops": configured_max_hops,
11376                "effective_max_hops": effective_max_hops,
11377                "hops": path.as_ref().map(|path| path.hops),
11378                "nodes": path.as_ref().map(|path| &path.nodes),
11379                "found": path.is_some(),
11380                "warning": warning,
11381            }),
11382        ))
11383    })
11384}
11385
11386fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
11387    store: &S,
11388    depth: usize,
11389    limit: usize,
11390) -> (
11391    GraphDbBackendEvalOperation,
11392    Option<GraphDbBackendEvalSignature>,
11393) {
11394    graph_db_backend_eval_timed("neighborhood", || {
11395        let edge = match store.sample_edge(Some("calls"))? {
11396            Some(edge) => edge,
11397            None => store.sample_edge(None)?.context(
11398                "backend-eval neighborhood probe requires at least one traversable edge",
11399            )?,
11400        };
11401        let page = store
11402            .paged_neighborhood(
11403                &edge.from_id,
11404                depth,
11405                Some(&edge.kind),
11406                GraphQueryOptions {
11407                    limit: Some(limit.max(1)),
11408                    ..GraphQueryOptions::default()
11409                },
11410            )?
11411            .with_context(|| {
11412                format!(
11413                    "backend-eval neighborhood target not found: {}",
11414                    edge.from_id
11415                )
11416            })?;
11417        Ok((
11418            Some(page.nodes.len() + page.edges.len()),
11419            serde_json::json!({
11420                "center": edge.from_id,
11421                "kind": edge.kind,
11422                "depth": depth,
11423                "limit": limit.max(1),
11424                "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11425                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11426                "truncated": page.page.truncated,
11427            }),
11428        ))
11429    })
11430}
11431
11432fn graph_db_backend_eval_related_operation<S: GraphStore>(
11433    root: &Path,
11434    scope: Option<&str>,
11435    store: &S,
11436    depth: usize,
11437    limit: usize,
11438) -> (
11439    GraphDbBackendEvalOperation,
11440    Option<GraphDbBackendEvalSignature>,
11441) {
11442    graph_db_backend_eval_timed("related", || {
11443        let query = "backend evaluation";
11444        let semantic = semantic_related_report_from_store(
11445            root,
11446            scope,
11447            query,
11448            3,
11449            SemanticRelatedKind::All,
11450            store,
11451        )?;
11452        let seed_ids = semantic
11453            .items
11454            .iter()
11455            .map(|item| item.handle.clone())
11456            .collect::<Vec<_>>();
11457        let subgraph =
11458            graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
11459        Ok((
11460            Some(subgraph.nodes.len() + subgraph.edges.len()),
11461            serde_json::json!({
11462                "query": query,
11463                "seed_ids": seed_ids,
11464                "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11465                "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11466                "truncated": subgraph.truncated,
11467                "warnings": semantic.warnings,
11468                "diagnostics": subgraph.diagnostics,
11469            }),
11470        ))
11471    })
11472}
11473
11474fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
11475    serde_json::json!({
11476        "target": report.target,
11477        "target_node_id": report.target_node.id,
11478        "target_kind": report.target_node.kind,
11479        "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
11480        "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
11481        "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
11482        "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
11483        "path_count": report.shortest_paths.len(),
11484    })
11485}
11486
11487fn graph_db_backend_eval_target_resolution_signature(
11488    resolved: &[(String, SubstrateGraphNode)],
11489) -> serde_json::Value {
11490    serde_json::json!({
11491        "targets": resolved.iter().map(|(target, node)| {
11492            serde_json::json!({
11493                "target": target,
11494                "target_node_id": node.id,
11495                "target_kind": node.kind,
11496                "target_label": node.label,
11497            })
11498        }).collect::<Vec<_>>(),
11499    })
11500}
11501
11502fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
11503    serde_json::json!({
11504        "targets": report.targets,
11505        "can_parallel": report.can_parallel,
11506        "fail_closed": report.fail_closed,
11507        "cross_target_parallel_safe": report.cross_target_parallel_safe,
11508        "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
11509        "candidates": report.candidates.iter().map(|candidate| {
11510            serde_json::json!({
11511                "target": candidate.target,
11512                "risk": conflict_risk_label(candidate.risk),
11513                "owned_files": candidate.owned_files,
11514                "owned_symbols": candidate.owned_symbols,
11515                "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
11516                "previously_completed": candidate.previously_completed,
11517                "parallel_safe": candidate.parallel_safe,
11518            })
11519        }).collect::<Vec<_>>(),
11520        "conflicts": report.conflicts.iter().map(|pair| {
11521            serde_json::json!({
11522                "left": pair.left,
11523                "right": pair.right,
11524                "risk": conflict_risk_label(pair.risk),
11525            })
11526        }).collect::<Vec<_>>(),
11527    })
11528}
11529
11530fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
11531    serde_json::json!({
11532        "targets": report.targets,
11533        "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11534        "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
11535        "evidence_packet_ids": report.evidence_packet_ids,
11536        "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
11537        "truncated": report.truncated,
11538    })
11539}
11540
11541fn graph_db_backend_eval_edge_scan_probe(
11542    store: &impl GraphStore,
11543) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
11544    if let Some((edge, filter)) = store.sample_edge_with_property()? {
11545        return Ok((edge, vec![filter]));
11546    }
11547    let edge = store
11548        .sample_edge(None)?
11549        .context("backend-eval edge scan requires at least one edge")?;
11550    Ok((edge, Vec::new()))
11551}
11552
11553#[allow(clippy::too_many_arguments)]
11554fn graph_db_backend_eval_report_for_store<S: GraphStore>(
11555    backend: &str,
11556    adapter: &str,
11557    read_only: bool,
11558    root: &Path,
11559    path: &Path,
11560    scope: Option<&str>,
11561    targets: &[String],
11562    depth: usize,
11563    limit: usize,
11564    impact_limit: usize,
11565    store: &S,
11566    freshness: GraphDbFreshnessReport,
11567    refresh_operation: GraphDbBackendEvalOperation,
11568    refresh_signature: Option<GraphDbBackendEvalSignature>,
11569    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11570    extra_warnings: Vec<String>,
11571    prepared: &ConflictMatrixPreparedInputs,
11572    projection_load: &str,
11573    lock_behavior: &str,
11574    install_portability: &str,
11575) -> (
11576    GraphDbBackendEvalBackendReport,
11577    Vec<GraphDbBackendEvalSignature>,
11578) {
11579    let mut operations = vec![refresh_operation];
11580    let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
11581
11582    let (operation, signature) = graph_db_backend_eval_timed("status", || {
11583        let (nodes, edges) = store.graph_counts()?;
11584        Ok((
11585            Some(nodes + edges),
11586            serde_json::json!({
11587                "freshness": freshness.status,
11588                "nodes": nodes,
11589                "edges": edges,
11590            }),
11591        ))
11592    });
11593    operations.push(operation);
11594    signatures.extend(signature);
11595
11596    let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
11597        let edge = store
11598            .sample_edge(None)?
11599            .context("backend-eval edge lookup requires at least one edge")?;
11600        let edge_id = graph_db_edge_key(&edge);
11601        let found = store
11602            .edge(&edge_id)?
11603            .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
11604        Ok((
11605            Some(1),
11606            serde_json::json!({
11607                "edge_id": edge_id,
11608                "from_id": found.from_id,
11609                "to_id": found.to_id,
11610                "kind": found.kind,
11611            }),
11612        ))
11613    });
11614    operations.push(operation);
11615    signatures.extend(signature);
11616
11617    let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
11618        let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
11619        let page = store.paged_edges(
11620            Some(&edge.kind),
11621            GraphQueryOptions {
11622                limit: Some(limit.max(1)),
11623                property_filters: filters.clone(),
11624                ..GraphQueryOptions::default()
11625            },
11626        )?;
11627        Ok((
11628            Some(page.edges.len()),
11629            serde_json::json!({
11630                "kind": edge.kind,
11631                "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
11632                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11633                "truncated": page.page.truncated,
11634            }),
11635        ))
11636    });
11637    operations.push(operation);
11638    signatures.extend(signature);
11639
11640    let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
11641        let edge = store
11642            .sample_edge(None)?
11643            .context("backend-eval incident edge scan requires at least one edge")?;
11644        let page = store.paged_incident_edges(
11645            &edge.from_id,
11646            Some(&edge.kind),
11647            GraphQueryOptions {
11648                limit: Some(limit.max(1)),
11649                ..GraphQueryOptions::default()
11650            },
11651        )?;
11652        Ok((
11653            Some(page.edges.len()),
11654            serde_json::json!({
11655                "node_id": edge.from_id,
11656                "kind": edge.kind,
11657                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11658                "truncated": page.page.truncated,
11659            }),
11660        ))
11661    });
11662    operations.push(operation);
11663    signatures.extend(signature);
11664
11665    let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
11666    operations.push(operation);
11667    signatures.extend(signature);
11668
11669    let (operation, signature) =
11670        graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
11671    operations.push(operation);
11672    signatures.extend(signature);
11673
11674    for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
11675        .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
11676    {
11677        let (operation, signature) =
11678            graph_db_backend_eval_path_operation(store, configured_max_hops);
11679        operations.push(operation);
11680        signatures.extend(signature);
11681    }
11682
11683    let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
11684        let resolved = targets
11685            .iter()
11686            .map(|target| {
11687                let node = graph_db_resolve_evidence_target(store, target)?
11688                    .with_context(|| format!("backend-eval target not found: {target}"))?;
11689                Ok((target.clone(), node))
11690            })
11691            .collect::<Result<Vec<_>>>()?;
11692        let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
11693        Ok((Some(resolved.len()), signature))
11694    });
11695    operations.push(operation);
11696    signatures.extend(signature);
11697
11698    let mut evidence_for_report = None;
11699    let mut graph_snapshot_for_trace = None;
11700    let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
11701        let resolved_targets =
11702            resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
11703        let evidence = collect_conflict_matrix_evidence_packets(
11704            root,
11705            scope,
11706            backend,
11707            &resolved_targets,
11708            depth,
11709            limit,
11710            store,
11711            freshness.clone(),
11712        )?;
11713        let report = &evidence
11714            .first()
11715            .context("backend-eval evidence requires at least one target")?
11716            .report;
11717        let rows = evidence
11718            .iter()
11719            .map(|entry| {
11720                entry.report.worker_context.len()
11721                    + entry.report.source_handles.len()
11722                    + entry.report.worker_results.len()
11723                    + entry.report.semantic_related.len()
11724            })
11725            .sum();
11726        let signature = graph_db_backend_eval_evidence_signature(report);
11727        evidence_for_report = Some((resolved_targets, evidence));
11728        Ok((Some(rows), signature))
11729    });
11730    operations.push(operation);
11731    signatures.extend(signature);
11732
11733    let mut conflict_for_trace = None;
11734    let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
11735        let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
11736            let graph =
11737                conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
11738            let shared_preparation =
11739                conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
11740            ConflictMatrixGraphPreparedInputs {
11741                targets,
11742                graph,
11743                evidence,
11744                shared_preparation,
11745            }
11746        } else {
11747            prepare_conflict_matrix_graph_orchestration(
11748                root,
11749                scope,
11750                backend,
11751                targets,
11752                prepared,
11753                depth,
11754                limit,
11755                store,
11756                freshness.clone(),
11757            )?
11758        };
11759        let report = build_conflict_matrix_report_from_prepared_graph(
11760            root,
11761            path,
11762            scope,
11763            depth,
11764            limit,
11765            impact_limit,
11766            freshness.clone(),
11767            extra_warnings.clone(),
11768            prepared,
11769            &graph_prepared,
11770        )?;
11771        let signature = graph_db_backend_eval_conflict_signature(&report);
11772        let rows = report.candidates.len() + report.conflicts.len();
11773        conflict_for_trace = Some(report);
11774        graph_snapshot_for_trace = Some(graph_prepared.graph);
11775        Ok((Some(rows), signature))
11776    });
11777    operations.push(operation);
11778    signatures.extend(signature);
11779
11780    let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11781        let conflict = conflict_for_trace
11782            .take()
11783            .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11784        let graph = graph_snapshot_for_trace
11785            .take()
11786            .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11787        let report = build_dispatch_trace_report_from_conflict_snapshot(
11788            root,
11789            scope,
11790            conflict,
11791            graph.nodes,
11792            graph.edges,
11793            depth,
11794            limit,
11795            Vec::new(),
11796        )?;
11797        Ok((
11798            Some(report.nodes.len() + report.edges.len()),
11799            graph_db_backend_eval_dispatch_signature(&report),
11800        ))
11801    });
11802    operations.push(operation);
11803    signatures.extend(signature);
11804
11805    let total_micros = operations
11806        .iter()
11807        .map(|operation| operation.duration_micros)
11808        .sum();
11809    let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11810    (
11811        GraphDbBackendEvalBackendReport {
11812            backend: backend.to_string(),
11813            adapter: adapter.to_string(),
11814            read_only,
11815            projection_load: projection_load.to_string(),
11816            operations,
11817            total_micros,
11818            parity,
11819            lock_behavior: lock_behavior.to_string(),
11820            install_portability: install_portability.to_string(),
11821        },
11822        signatures,
11823    )
11824}
11825
11826pub(crate) fn graph_db_backend_eval_refresh_operation(
11827    duration_micros: u128,
11828    rows: usize,
11829    value: serde_json::Value,
11830) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11831    (
11832        GraphDbBackendEvalOperation {
11833            name: "refresh".to_string(),
11834            supported: true,
11835            status: "ok".to_string(),
11836            duration_micros,
11837            rows: Some(rows),
11838            error: None,
11839        },
11840        GraphDbBackendEvalSignature {
11841            operation: "refresh".to_string(),
11842            value,
11843        },
11844    )
11845}
11846
11847pub(crate) fn graph_db_backend_eval_synthetic_projection(
11848    nodes: usize,
11849    fanout: usize,
11850) -> GraphProjection {
11851    let nodes = nodes.max(12);
11852    let symbol_count = nodes.saturating_sub(9).max(1);
11853    let source = GraphProvenance::new("backend-eval", "synthetic");
11854    let mut projection_nodes = vec![
11855        SubstrateGraphNode::new(
11856            "projection:tsift-traversal:synthetic",
11857            GRAPH_PROJECTION_META_KIND,
11858            "synthetic projection",
11859        )
11860        .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11861        .with_property(
11862            "content_hash",
11863            format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11864        )
11865        .with_provenance(source.clone()),
11866        SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11867            .with_property("ref_id", "synthetic-session"),
11868        SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11869            .with_property("ref_id", "synthetic")
11870            .with_property("path", "tasks/software/synthetic.md")
11871            .with_property("line", "1")
11872            .with_property(
11873                "expand",
11874                "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11875            ),
11876        SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11877            .with_property("ref_id", "synthetic"),
11878        SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11879            .with_property("target", "synthetic")
11880            .with_property("summary", "Synthetic worker owns synthetic.rs")
11881            .with_property(
11882                "expand",
11883                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11884            ),
11885        SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11886            .with_property("file", "synthetic.rs")
11887            .with_property("start", "1")
11888            .with_property("end", "80")
11889            .with_property(
11890                "expand",
11891                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11892            ),
11893        SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11894            .with_property("path", "synthetic.rs"),
11895        SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11896            .with_property("handle", "gsem-synthetic")
11897            .with_property("label", "backend evaluation")
11898            .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11899            .with_property(
11900                "embedding",
11901                semantic_embedding_property("backend evaluation"),
11902            ),
11903        SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11904            .with_property("ref_id", "synthetic")
11905            .with_property("status", "completed")
11906            .with_property("touched_files", "synthetic.rs")
11907            .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11908    ];
11909    for idx in 0..symbol_count {
11910        projection_nodes.push(
11911            SubstrateGraphNode::new(
11912                format!("gsym-synthetic-{idx:04}"),
11913                "symbol",
11914                format!("synthetic_symbol_{idx:04}"),
11915            )
11916            .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11917            .with_property("path", "synthetic.rs")
11918            .with_property("line", (idx + 1).to_string()),
11919        );
11920    }
11921
11922    let mut projection_edges = vec![
11923        SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11924        SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11925        SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11926        SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11927        SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11928        SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11929        SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11930        SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11931        SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11932    ];
11933    for idx in 0..symbol_count {
11934        let from = format!("gsym-synthetic-{idx:04}");
11935        for offset in 1..=fanout.max(1).min(symbol_count) {
11936            let to_idx = (idx + offset) % symbol_count;
11937            if to_idx != idx {
11938                projection_edges.push(SubstrateGraphEdge::new(
11939                    from.clone(),
11940                    format!("gsym-synthetic-{to_idx:04}"),
11941                    "calls",
11942                ));
11943            }
11944        }
11945    }
11946
11947    GraphProjection {
11948        nodes: projection_nodes,
11949        edges: projection_edges
11950            .into_iter()
11951            .map(|edge| {
11952                edge.with_property("dataset", "synthetic")
11953                    .with_provenance(source.clone())
11954            })
11955            .collect(),
11956    }
11957}
11958
11959pub(crate) fn graph_db_backend_eval_promotion(
11960    datasets: &[GraphDbBackendEvalDataset],
11961    candidates: &[GraphDbExperimentalBackend],
11962) -> Vec<GraphDbBackendPromotionDecision> {
11963    let mut decisions = Vec::new();
11964    for candidate in candidates {
11965        let mut reasons = Vec::new();
11966        let mut faster_everywhere = true;
11967        let mut parity_everywhere = true;
11968        for dataset in datasets {
11969            let Some(sqlite_report) = dataset
11970                .backends
11971                .iter()
11972                .find(|backend| backend.backend == "sqlite")
11973            else {
11974                parity_everywhere = false;
11975                faster_everywhere = false;
11976                reasons.push(format!(
11977                    "{} dataset is missing SQLite baseline",
11978                    dataset.name
11979                ));
11980                continue;
11981            };
11982            let sqlite_total = sqlite_report.total_micros;
11983            let Some(candidate_report) = dataset
11984                .backends
11985                .iter()
11986                .find(|backend| backend.backend == candidate.name())
11987            else {
11988                parity_everywhere = false;
11989                reasons.push(format!("{} dataset did not run", dataset.name));
11990                continue;
11991            };
11992            if !candidate_report.parity.matches_sqlite {
11993                parity_everywhere = false;
11994                reasons.push(format!("{} parity differed from SQLite", dataset.name));
11995            }
11996            if candidate_report.total_micros >= sqlite_total {
11997                faster_everywhere = false;
11998                reasons.push(format!(
11999                    "{} total {}us did not beat SQLite {}us",
12000                    dataset.name, candidate_report.total_micros, sqlite_total
12001                ));
12002            }
12003            let sqlite_operations = sqlite_report
12004                .operations
12005                .iter()
12006                .map(|operation| (operation.name.as_str(), operation.duration_micros))
12007                .collect::<BTreeMap<_, _>>();
12008            for operation in &candidate_report.operations {
12009                if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
12010                    && operation.duration_micros >= *sqlite_duration
12011                {
12012                    faster_everywhere = false;
12013                    reasons.push(format!(
12014                        "{} {} operation {}us did not beat SQLite {}us",
12015                        dataset.name, operation.name, operation.duration_micros, sqlite_duration
12016                    ));
12017                }
12018            }
12019            if candidate_report
12020                .operations
12021                .iter()
12022                .any(|operation| operation.status != "ok")
12023            {
12024                parity_everywhere = false;
12025                reasons.push(format!("{} has failed benchmark operations", dataset.name));
12026            }
12027        }
12028        let decision = if let Some(reason) = candidate.prototype_hold_reason() {
12029            reasons.push(reason.to_string());
12030            reasons.push(
12031                "current bounded prototype timings are benchmark evidence, not a backend switch approval"
12032                    .to_string(),
12033            );
12034            "hold"
12035        } else if parity_everywhere && faster_everywhere {
12036            reasons.push(
12037                "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
12038                    .to_string(),
12039            );
12040            "eligible"
12041        } else {
12042            reasons.push(
12043                "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
12044                    .to_string(),
12045            );
12046            "hold"
12047        };
12048        decisions.push(GraphDbBackendPromotionDecision {
12049            backend: candidate.name().to_string(),
12050            decision: decision.to_string(),
12051            reasons: dedupe_preserve_order(reasons),
12052            gate: candidate.promotion_gate(),
12053        });
12054    }
12055    decisions
12056}
12057
12058pub(crate) fn graph_db_backend_eval_metrics(
12059    datasets: &[GraphDbBackendEvalDataset],
12060) -> BTreeMap<String, f64> {
12061    let mut metrics = BTreeMap::new();
12062    for dataset in datasets {
12063        let graph_rows = graph_db_backend_eval_graph_rows(dataset);
12064        metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
12065        metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
12066        metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
12067        for backend in &dataset.backends {
12068            let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
12069            metrics.insert(
12070                format!("{prefix}.total_duration_micros"),
12071                backend.total_micros as f64,
12072            );
12073            append_graph_db_backend_eval_normalized_duration_metric(
12074                &mut metrics,
12075                &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
12076                backend.total_micros,
12077                graph_rows,
12078            );
12079            for operation in &backend.operations {
12080                metrics.insert(
12081                    format!("{prefix}.{}.duration_micros", operation.name),
12082                    operation.duration_micros as f64,
12083                );
12084                append_graph_db_backend_eval_normalized_duration_metric(
12085                    &mut metrics,
12086                    &format!(
12087                        "{prefix}.{}.duration_micros_per_1k_graph_rows",
12088                        operation.name
12089                    ),
12090                    operation.duration_micros,
12091                    graph_rows,
12092                );
12093                if let Some(rows) = operation.rows {
12094                    metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
12095                }
12096            }
12097        }
12098    }
12099    metrics
12100}
12101
12102pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
12103    dataset.nodes + dataset.edges
12104}
12105
12106pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
12107    metrics: &mut BTreeMap<String, f64>,
12108    key: &str,
12109    duration_micros: u128,
12110    graph_rows: usize,
12111) {
12112    if graph_rows == 0 {
12113        return;
12114    }
12115    metrics.insert(
12116        key.to_string(),
12117        duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
12118    );
12119}
12120
12121pub(crate) fn append_graph_db_backend_eval_phase_metrics(
12122    metrics: &mut BTreeMap<String, f64>,
12123    dataset: &str,
12124    graph_rows: usize,
12125    phases: &[GraphDbBackendEvalPhaseTiming],
12126) {
12127    for phase in phases {
12128        metrics.insert(
12129            format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
12130            phase.duration_micros as f64,
12131        );
12132        append_graph_db_backend_eval_normalized_duration_metric(
12133            metrics,
12134            &format!(
12135                "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
12136                phase.name
12137            ),
12138            phase.duration_micros,
12139            graph_rows,
12140        );
12141    }
12142}
12143
12144fn graph_db_backend_eval_base_command(
12145    root: &Path,
12146    scope: Option<&str>,
12147    full_projection: bool,
12148) -> String {
12149    let full_projection_arg = if full_projection {
12150        " --full-projection"
12151    } else {
12152        ""
12153    };
12154    format!(
12155        "tsift graph-db --path {}{} --json backend-eval{}",
12156        shell_quote(root.to_string_lossy().as_ref()),
12157        graph_db_scope_arg(scope),
12158        full_projection_arg
12159    )
12160}
12161
12162pub(crate) fn graph_db_backend_eval_metric_digest_command(
12163    root: &Path,
12164    scope: Option<&str>,
12165    full_projection: bool,
12166) -> String {
12167    format!(
12168        "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12169        graph_db_backend_eval_base_command(root, scope, full_projection)
12170    )
12171}
12172
12173fn graph_db_backend_eval_repeated_sample_command(
12174    root: &Path,
12175    scope: Option<&str>,
12176    full_projection: bool,
12177) -> String {
12178    format!(
12179        "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12180        graph_db_backend_eval_base_command(root, scope, full_projection)
12181    )
12182}
12183
12184fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
12185    let mut required_metrics = Vec::new();
12186    for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
12187        required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
12188        required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
12189        for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
12190            required_metrics.push(format!(
12191                "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
12192            ));
12193            required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
12194        }
12195    }
12196    GraphDbHopCapPromotionGate {
12197        status: "hold_64_default_until_gate_passes".to_string(),
12198        current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
12199        candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
12200        required_backend: perf_gate::BASELINE_BACKEND.to_string(),
12201        required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
12202            .iter()
12203            .map(|workload| (*workload).to_string())
12204            .collect(),
12205        required_metrics,
12206        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12207        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12208        decision_rule:
12209            "keep 64 as the user-facing default until each candidate tier has repeated real, full_projection, and synthetic_deep_chain SQLite samples within the latency-regression budget and returning useful path rows; full_projection samples are binding only after a cold populate leg proves a cache-hit leg"
12210                .to_string(),
12211    }
12212}
12213
12214fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
12215    let candidate_backends = [
12216        GraphDbExperimentalBackend::Falkordb,
12217        GraphDbExperimentalBackend::Kuzu,
12218        GraphDbExperimentalBackend::Surrealdb,
12219    ]
12220    .into_iter()
12221    .map(|backend| GraphDbBackendAdapterSpikeCandidate {
12222        backend: backend.name().to_string(),
12223        adapter_label: backend.adapter_label().to_string(),
12224        projection_load: backend.projection_load().to_string(),
12225        lock_behavior: backend.lock_behavior().to_string(),
12226        install_portability: backend.install_portability().to_string(),
12227    })
12228    .collect();
12229
12230    GraphDbBackendAdapterSpikeGate {
12231        status: "hold_real_optional_adapter_required".to_string(),
12232        candidate_backends,
12233        required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
12234            .iter()
12235            .map(|workload| (*workload).to_string())
12236            .collect(),
12237        required_checks: vec![
12238            "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
12239            "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
12240            "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
12241            "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
12242            "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
12243                .to_string(),
12244            "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
12245            "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
12246        ],
12247        decision_rule:
12248            "do not promote a read-only prototype; FalkorDB, Kuzu, or SurrealDB can only advance after a real optional adapter proves projection writes/load, lock semantics, install portability, full parity, and faster-than-SQLite results across every required workload"
12249                .to_string(),
12250        evidence_plan: "plans/gback-evidence.md".to_string(),
12251    }
12252}
12253
12254pub(crate) fn graph_db_backend_eval_performance_gate(
12255    root: &Path,
12256    scope: Option<&str>,
12257    full_projection: bool,
12258) -> GraphDbBackendEvalPerformanceGate {
12259    let mut required_metrics = vec![
12260        "real.sqlite.refresh.duration_micros".to_string(),
12261        "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
12262        "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
12263        "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
12264        "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12265        "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12266        "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
12267        "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
12268        "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12269        "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
12270        "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
12271        "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
12272        "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
12273        "real.sqlite.conflict_matrix.duration_micros".to_string(),
12274        "real.sqlite.dispatch_trace.duration_micros".to_string(),
12275        "real.sqlite.path_max_hops.duration_micros".to_string(),
12276        "real.sqlite.path_max_hops_128.duration_micros".to_string(),
12277        "real.sqlite.path_max_hops_256.duration_micros".to_string(),
12278        "real.sqlite.path_max_hops_512.duration_micros".to_string(),
12279        "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
12280        "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
12281        "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
12282        "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
12283        "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12284        "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12285        "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
12286            .to_string(),
12287        "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12288            .to_string(),
12289        "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12290        "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12291        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
12292        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
12293        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
12294        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
12295        "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12296            .to_string(),
12297        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
12298        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
12299            .to_string(),
12300        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
12301            .to_string(),
12302        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
12303            .to_string(),
12304    ];
12305    if full_projection {
12306        required_metrics.extend([
12307            "full_projection.cache.hit".to_string(),
12308            "full_projection.cache.disk_bytes".to_string(),
12309            "full_projection.cache.compression_ratio".to_string(),
12310            "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
12311            "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12312            "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
12313                .to_string(),
12314            "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
12315                .to_string(),
12316            "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
12317            "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
12318            "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
12319            "full_projection.sqlite.neighborhood.duration_micros".to_string(),
12320            "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
12321            "full_projection.sqlite.evidence.duration_micros".to_string(),
12322            "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
12323            "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
12324            "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
12325            "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
12326            "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
12327            "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
12328        ]);
12329    }
12330    GraphDbBackendEvalPerformanceGate {
12331        baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
12332        ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
12333            .to_string(),
12334        opt_in_real_profile:
12335            "pass --full-projection to add the full-project dataset when checking for large projection regressions"
12336                .to_string(),
12337        full_projection_cache_hit_gate: if full_projection {
12338            "binding full_projection performance evidence requires a cold populate leg followed by cache-leg samples with full_projection.cache.hit=1; cache-miss samples are diagnostics, not backend or hop-cap promotion proof"
12339                .to_string()
12340        } else {
12341            "not evaluated until --full-projection is enabled".to_string()
12342        },
12343        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12344        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12345        normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
12346        required_metrics,
12347        digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
12348        repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
12349            root,
12350            scope,
12351            full_projection,
12352        ),
12353        hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
12354        backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
12355    }
12356}
12357
12358#[cfg(feature = "backend-surrealdb")]
12359fn graph_db_backend_eval_path_segment(value: &str) -> String {
12360    value
12361        .chars()
12362        .map(|ch| {
12363            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
12364                ch
12365            } else {
12366                '_'
12367            }
12368        })
12369        .collect()
12370}
12371
12372#[cfg(feature = "backend-surrealdb")]
12373fn graph_db_backend_eval_surrealdb_store_path(
12374    root: &Path,
12375    scope: Option<&str>,
12376    dataset: &str,
12377) -> PathBuf {
12378    root.join(".tsift/backend-eval-cache/surrealdb")
12379        .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
12380        .join(graph_db_backend_eval_path_segment(dataset))
12381        .join("surrealkv")
12382}
12383
12384pub(crate) struct GraphDbBackendEvalOptions<'a> {
12385    path: &'a Path,
12386    scope: Option<&'a str>,
12387    candidates: &'a [String],
12388    targets: &'a [String],
12389    full_projection: bool,
12390}
12391
12392#[allow(clippy::too_many_arguments)]
12393pub(crate) fn graph_db_backend_eval_dataset(
12394    name: &str,
12395    root: &Path,
12396    path: &Path,
12397    scope: Option<&str>,
12398    targets: &[String],
12399    depth: usize,
12400    limit: usize,
12401    impact_limit: usize,
12402    candidates: &[GraphDbExperimentalBackend],
12403    sqlite_store: &SqliteGraphStore,
12404    sqlite_freshness: GraphDbFreshnessReport,
12405    sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
12406    sqlite_rows: ConvexProjectionRows,
12407    extra_warnings: Vec<String>,
12408    prepared: &ConflictMatrixPreparedInputs,
12409) -> Result<GraphDbBackendEvalDataset> {
12410    let (nodes, edges) = sqlite_store.graph_counts()?;
12411    let (sqlite_operation, sqlite_signature) = sqlite_refresh;
12412    let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
12413        "sqlite",
12414        "SQLite GraphStore correctness baseline",
12415        false,
12416        root,
12417        path,
12418        scope,
12419        targets,
12420        depth,
12421        limit,
12422        impact_limit,
12423        sqlite_store,
12424        sqlite_freshness,
12425        sqlite_operation,
12426        Some(sqlite_signature),
12427        None,
12428        extra_warnings.clone(),
12429        prepared,
12430        "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
12431        "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
12432        "bundled rusqlite baseline; no external service or runtime required",
12433    );
12434
12435    let mut backends = vec![sqlite_report];
12436    for candidate in candidates {
12437        #[cfg(feature = "backend-surrealdb")]
12438        if *candidate == GraphDbExperimentalBackend::Surrealdb {
12439            let started = Instant::now();
12440            let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
12441            let (store, warm_start) =
12442                SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
12443            let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12444            let rows = candidate_nodes + candidate_edges;
12445            let mut refresh_meta = serde_json::json!({
12446                "nodes": candidate_nodes,
12447                "edges": candidate_edges,
12448            });
12449            if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
12450                refresh_meta["warm_start"] = serde_json::json!("cache_hit");
12451            }
12452            let refresh = graph_db_backend_eval_refresh_operation(
12453                started.elapsed().as_micros(),
12454                rows,
12455                refresh_meta,
12456            );
12457            let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12458            let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12459                candidate.name(),
12460                "SurrealDB SurrealKV optional adapter spike",
12461                false,
12462                root,
12463                path,
12464                scope,
12465                targets,
12466                depth,
12467                limit,
12468                impact_limit,
12469                &store,
12470                freshness,
12471                refresh.0,
12472                Some(refresh.1),
12473                Some(&sqlite_signatures),
12474                extra_warnings.clone(),
12475                prepared,
12476                "provider-neutral rows written into an embedded/file-backed SurrealDB SurrealKV store through the optional tsift-surrealdb adapter; warm-start reuses existing store when row hash matches",
12477                "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
12478                "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
12479            );
12480            backends.push(candidate_report);
12481            continue;
12482        }
12483        let started = Instant::now();
12484        let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
12485        let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12486        let rows = candidate_nodes + candidate_edges;
12487        let refresh = graph_db_backend_eval_refresh_operation(
12488            started.elapsed().as_micros(),
12489            rows,
12490            serde_json::json!({
12491                "nodes": candidate_nodes,
12492                "edges": candidate_edges,
12493            }),
12494        );
12495        let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12496        let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12497            candidate.name(),
12498            candidate.adapter_label(),
12499            true,
12500            root,
12501            path,
12502            scope,
12503            targets,
12504            depth,
12505            limit,
12506            impact_limit,
12507            &store,
12508            freshness,
12509            refresh.0,
12510            Some(refresh.1),
12511            Some(&sqlite_signatures),
12512            extra_warnings.clone(),
12513            prepared,
12514            candidate.projection_load(),
12515            candidate.lock_behavior(),
12516            candidate.install_portability(),
12517        );
12518        backends.push(candidate_report);
12519    }
12520
12521    Ok(GraphDbBackendEvalDataset {
12522        name: name.to_string(),
12523        target_count: targets.len(),
12524        nodes,
12525        edges,
12526        backends,
12527    })
12528}
12529
12530pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
12531    println!(
12532        "graph-db backend-eval baseline:{} candidates:{}",
12533        report.baseline_backend,
12534        report.candidates.join(", ")
12535    );
12536    for phase in &report.phase_timings {
12537        println!(
12538            "phase:{} {}us {}",
12539            phase.name, phase.duration_micros, phase.detail
12540        );
12541    }
12542    for dataset in &report.datasets {
12543        println!(
12544            "dataset:{} targets:{} rows:{}",
12545            dataset.name,
12546            dataset.target_count,
12547            dataset.nodes + dataset.edges
12548        );
12549        for backend in &dataset.backends {
12550            println!(
12551                "  backend:{} total:{}us parity:{}",
12552                backend.backend, backend.total_micros, backend.parity.matches_sqlite
12553            );
12554            println!("    projection-load: {}", backend.projection_load);
12555            println!("    lock-behavior: {}", backend.lock_behavior);
12556            println!("    install-portability: {}", backend.install_portability);
12557            for operation in &backend.operations {
12558                println!(
12559                    "    {} {} {}us",
12560                    operation.name, operation.status, operation.duration_micros
12561                );
12562            }
12563            for diagnostic in &backend.parity.diagnostics {
12564                println!("    parity: {diagnostic}");
12565            }
12566        }
12567    }
12568    for decision in &report.promotion {
12569        println!("promotion {}: {}", decision.backend, decision.decision);
12570        println!("  gate: {}", decision.gate.status);
12571        for reason in &decision.reasons {
12572            println!("  reason: {reason}");
12573        }
12574        for check in &decision.gate.required_checks {
12575            println!("  check: {check}");
12576        }
12577    }
12578    println!("metric-digest: {}", report.metric_digest_command);
12579    println!(
12580        "repeat-samples: {}",
12581        report.performance_gate.repeated_sample_command
12582    );
12583}
12584
12585fn traversal_expand_command(root: &Path, handle: &str) -> String {
12586    format!(
12587        "tsift traverse {} --path {} --depth 1 --limit 50",
12588        shell_quote(handle),
12589        shell_quote(root.to_string_lossy().as_ref())
12590    )
12591}
12592
12593fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
12594    let display = relativize(file, root);
12595    let handle = stable_handle("gfil", &format!("file:{display}"));
12596    TraversalNode {
12597        handle: handle.clone(),
12598        kind: "file".to_string(),
12599        label: display.clone(),
12600        ref_id: Some(display.clone()),
12601        path: Some(display),
12602        line: None,
12603        detail: None,
12604        properties: BTreeMap::new(),
12605        expand: traversal_expand_command(root, &handle),
12606    }
12607}
12608
12609fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
12610    let mut node = traversal_file_node(root, file);
12611    if let Some(path) = node.path.clone() {
12612        node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
12613        node.expand = source_read_command(root, &path, 1, 80);
12614    }
12615    node
12616}
12617
12618fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
12619    let file = relativize(&symbol.file, root);
12620    let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
12621    let handle = stable_handle("gsym", &key);
12622    TraversalNode {
12623        handle: handle.clone(),
12624        kind: "symbol".to_string(),
12625        label: symbol.name.clone(),
12626        ref_id: Some(symbol.name.clone()),
12627        path: Some(file),
12628        line: Some(symbol.line),
12629        detail: Some(format!("{} {}", symbol.language, symbol.kind)),
12630        properties: BTreeMap::new(),
12631        expand: traversal_expand_command(root, &handle),
12632    }
12633}
12634
12635fn traversal_ast_span_expand_command(
12636    root: &Path,
12637    file: &str,
12638    symbol: &index::StoredSymbol,
12639    span: &AstSpanPreview,
12640) -> String {
12641    if symbol.language == "markdown" {
12642        markdown_ast_command(root, file, Some(&span.handle))
12643    } else {
12644        let line_count = span
12645            .end_line
12646            .saturating_sub(span.start_line)
12647            .saturating_add(1)
12648            .max(1);
12649        source_read_command(root, file, span.start_line, line_count)
12650    }
12651}
12652
12653fn traversal_ast_span_node(
12654    root: &Path,
12655    symbol: &index::StoredSymbol,
12656    source: &[u8],
12657    symbols: &[&index::StoredSymbol],
12658) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
12659    let span = stored_symbol_ast_span_in_file(symbol, source, symbols, usize::MAX)?;
12660    let file = relativize(&symbol.file, root);
12661    let mut properties = BTreeMap::new();
12662    properties.insert("layer".to_string(), "ast_navigation".to_string());
12663    properties.insert("language".to_string(), symbol.language.clone());
12664    properties.insert("symbol_kind".to_string(), symbol.kind.clone());
12665    properties.insert("node_kind".to_string(), span.node_kind.clone());
12666    properties.insert("start_byte".to_string(), span.start_byte.to_string());
12667    properties.insert("end_byte".to_string(), span.end_byte.to_string());
12668    properties.insert("end_line".to_string(), span.end_line.to_string());
12669    if let Some(body_start_byte) = span.body_start_byte {
12670        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12671    }
12672    if let Some(body_end_byte) = span.body_end_byte {
12673        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12674    }
12675    if let Some(body_start_line) = span.body_start_line {
12676        properties.insert("body_start_line".to_string(), body_start_line.to_string());
12677    }
12678    if let Some(body_end_line) = span.body_end_line {
12679        properties.insert("body_end_line".to_string(), body_end_line.to_string());
12680    }
12681    if let Some(parent_handle) = &span.parent_handle {
12682        properties.insert("parent_handle".to_string(), parent_handle.clone());
12683    }
12684    if !span.child_handles.is_empty() {
12685        properties.insert("child_handles".to_string(), span.child_handles.join(","));
12686    }
12687    if let Some(parent_module) = &symbol.parent_module {
12688        properties.insert("parent_module".to_string(), parent_module.clone());
12689    }
12690    if let Some(markdown) = &span.markdown {
12691        properties.insert(
12692            "markdown_block_kind".to_string(),
12693            markdown_ast_block_kind(&symbol.kind),
12694        );
12695        if let Some(heading_level) = markdown.heading_level {
12696            properties.insert("heading_level".to_string(), heading_level.to_string());
12697        }
12698        if !markdown.section_path.is_empty() {
12699            properties.insert(
12700                "section_path".to_string(),
12701                markdown.section_path.join(" > "),
12702            );
12703        }
12704        if let Some(section_handle) = &markdown.section_handle {
12705            properties.insert("section_handle".to_string(), section_handle.clone());
12706        }
12707        if let Some(list_depth) = markdown.list_depth {
12708            properties.insert("list_depth".to_string(), list_depth.to_string());
12709        }
12710        if let Some(fence_language) = &markdown.fence_language {
12711            properties.insert("fence_language".to_string(), fence_language.clone());
12712        }
12713    }
12714
12715    let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
12716    let node = TraversalNode {
12717        handle: span.handle.clone(),
12718        kind: "ast_span".to_string(),
12719        label: symbol.name.clone(),
12720        ref_id: Some(symbol.name.clone()),
12721        path: Some(file.clone()),
12722        line: Some(line),
12723        detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
12724        properties,
12725        expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
12726    };
12727    let entry = TraversalAstSpanIndexEntry {
12728        handle: span.handle,
12729        symbol_handle: String::new(),
12730        file_handle: None,
12731        file,
12732        name: symbol.name.clone(),
12733        kind: symbol.kind.clone(),
12734        language: symbol.language.clone(),
12735        node_kind: span.node_kind,
12736        start_byte: span.start_byte,
12737        end_byte: span.end_byte,
12738        parent_module: symbol.parent_module.clone(),
12739        markdown: span.markdown,
12740    };
12741    Some((node, entry))
12742}
12743
12744fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
12745    let handle = stable_handle("gsym", &format!("symbol:{name}"));
12746    TraversalNode {
12747        handle: handle.clone(),
12748        kind: "symbol".to_string(),
12749        label: name.to_string(),
12750        ref_id: Some(name.to_string()),
12751        path: None,
12752        line: None,
12753        detail: Some("unresolved call target".to_string()),
12754        properties: BTreeMap::new(),
12755        expand: traversal_expand_command(root, &handle),
12756    }
12757}
12758
12759fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12760    let file = relativize(&route.file, root);
12761    let method = route.method.as_deref().unwrap_or("any");
12762    let key = format!(
12763        "route:{file}:{}:{}:{}",
12764        route.line, method, route.route_path
12765    );
12766    let handle = stable_handle("grte", &key);
12767    TraversalNode {
12768        handle: handle.clone(),
12769        kind: "route".to_string(),
12770        label: format!("{} {}", method.to_uppercase(), route.route_path),
12771        ref_id: Some(route.route_path.clone()),
12772        path: Some(file),
12773        line: Some(route.line),
12774        detail: Some(format!(
12775            "{} route handled by {}",
12776            route.framework, route.handler_name
12777        )),
12778        properties: BTreeMap::new(),
12779        expand: traversal_expand_command(root, &handle),
12780    }
12781}
12782
12783fn traversal_cargo_workspace_node(
12784    root: &Path,
12785    workspace: &multiplicity::CargoWorkspaceInfo,
12786) -> TraversalNode {
12787    let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12788        .to_string_lossy()
12789        .replace('\\', "/");
12790    let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12791        .to_string_lossy()
12792        .replace('\\', "/");
12793    let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12794    let mut properties = BTreeMap::new();
12795    properties.insert("layer".to_string(), "cargo_workspace".to_string());
12796    properties.insert("workspace_root".to_string(), workspace_root.clone());
12797    properties.insert("members".to_string(), workspace.members.join(","));
12798    properties.insert(
12799        "default_members".to_string(),
12800        workspace.default_members.join(","),
12801    );
12802    TraversalNode {
12803        handle: handle.clone(),
12804        kind: "cargo_workspace".to_string(),
12805        label: if workspace_root.is_empty() {
12806            "root cargo workspace".to_string()
12807        } else {
12808            workspace_root
12809        },
12810        ref_id: Some(workspace.id.clone()),
12811        path: Some(manifest),
12812        line: None,
12813        detail: Some("Cargo workspace manifest".to_string()),
12814        properties,
12815        expand: traversal_expand_command(root, &handle),
12816    }
12817}
12818
12819fn traversal_cargo_package_node(
12820    root: &Path,
12821    package: &multiplicity::CargoPackageInfo,
12822) -> TraversalNode {
12823    let manifest = relativize_pathbuf(&package.manifest_path, root)
12824        .to_string_lossy()
12825        .replace('\\', "/");
12826    let package_root = relativize_pathbuf(&package.package_root, root)
12827        .to_string_lossy()
12828        .replace('\\', "/");
12829    let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12830        .to_string_lossy()
12831        .replace('\\', "/");
12832    let handle = stable_handle(
12833        "gcpk",
12834        &format!("cargo-package:{manifest}:{}", package.name),
12835    );
12836    let mut properties = BTreeMap::new();
12837    properties.insert("layer".to_string(), "cargo_package".to_string());
12838    properties.insert("package_name".to_string(), package.name.clone());
12839    properties.insert(
12840        "normalized_name".to_string(),
12841        package.normalized_name.clone(),
12842    );
12843    properties.insert("package_root".to_string(), package_root.clone());
12844    properties.insert("workspace_root".to_string(), workspace_root);
12845    properties.insert("features".to_string(), package.features.join(","));
12846    properties.insert("targets".to_string(), package.targets.join(","));
12847    properties.insert(
12848        "dependencies".to_string(),
12849        package
12850            .dependencies
12851            .iter()
12852            .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12853            .collect::<Vec<_>>()
12854            .join(","),
12855    );
12856    TraversalNode {
12857        handle: handle.clone(),
12858        kind: "cargo_package".to_string(),
12859        label: package.name.clone(),
12860        ref_id: Some(package.scope_id.clone()),
12861        path: Some(manifest),
12862        line: None,
12863        detail: Some(format!(
12864            "Cargo package in {}",
12865            if package_root.is_empty() {
12866                "."
12867            } else {
12868                package_root.as_str()
12869            }
12870        )),
12871        properties,
12872        expand: traversal_expand_command(root, &handle),
12873    }
12874}
12875
12876fn traversal_session_node(
12877    root: &Path,
12878    markdown_path: &Path,
12879    session_id: Option<&str>,
12880) -> TraversalNode {
12881    let display = relativize_pathbuf(markdown_path, root)
12882        .to_string_lossy()
12883        .replace('\\', "/");
12884    let handle = stable_handle("gses", &format!("session:{display}"));
12885    TraversalNode {
12886        handle: handle.clone(),
12887        kind: "session".to_string(),
12888        label: session_id.unwrap_or(&display).to_string(),
12889        ref_id: session_id.map(str::to_string),
12890        path: Some(display),
12891        line: None,
12892        detail: Some("agent-doc session artifact".to_string()),
12893        properties: BTreeMap::new(),
12894        expand: traversal_expand_command(root, &handle),
12895    }
12896}
12897
12898fn traversal_backlog_node(
12899    root: &Path,
12900    markdown_path: &Path,
12901    id: &str,
12902    text: &str,
12903    line: i64,
12904) -> TraversalNode {
12905    let display = relativize_pathbuf(markdown_path, root)
12906        .to_string_lossy()
12907        .replace('\\', "/");
12908    let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12909    TraversalNode {
12910        handle: handle.clone(),
12911        kind: "backlog".to_string(),
12912        label: format!("#{id}"),
12913        ref_id: Some(id.to_string()),
12914        path: Some(display),
12915        line: Some(line),
12916        detail: Some(text.to_string()),
12917        properties: BTreeMap::new(),
12918        expand: traversal_expand_command(root, &handle),
12919    }
12920}
12921
12922fn traversal_job_packet_node(
12923    root: &Path,
12924    markdown_path: &Path,
12925    label: &str,
12926    ref_id: Option<&str>,
12927    detail: &str,
12928    line: i64,
12929) -> TraversalNode {
12930    let display = relativize_pathbuf(markdown_path, root)
12931        .to_string_lossy()
12932        .replace('\\', "/");
12933    let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12934    TraversalNode {
12935        handle: handle.clone(),
12936        kind: "job_packet".to_string(),
12937        label: label.to_string(),
12938        ref_id: ref_id.map(str::to_string),
12939        path: Some(display),
12940        line: Some(line),
12941        detail: Some(detail.to_string()),
12942        properties: BTreeMap::new(),
12943        expand: traversal_expand_command(root, &handle),
12944    }
12945}
12946
12947#[derive(Clone, Debug)]
12948struct ParsedWorkerResult {
12949    id: String,
12950    status: String,
12951    touched_files: Vec<String>,
12952    tests: Vec<String>,
12953    follow_up_ids: Vec<String>,
12954}
12955
12956fn traversal_worker_result_node(
12957    root: &Path,
12958    markdown_path: &Path,
12959    parsed: &ParsedWorkerResult,
12960    line_text: &str,
12961    line: i64,
12962) -> TraversalNode {
12963    let display = relativize_pathbuf(markdown_path, root)
12964        .to_string_lossy()
12965        .replace('\\', "/");
12966    let handle = stable_handle(
12967        "wres",
12968        &format!(
12969            "worker-result:{display}:{}:{}:{}",
12970            parsed.id, parsed.status, line
12971        ),
12972    );
12973    let mut properties = BTreeMap::new();
12974    properties.insert("status".to_string(), parsed.status.clone());
12975    if !parsed.touched_files.is_empty() {
12976        properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12977    }
12978    if !parsed.tests.is_empty() {
12979        properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12980    }
12981    if !parsed.follow_up_ids.is_empty() {
12982        properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12983    }
12984    TraversalNode {
12985        handle: handle.clone(),
12986        kind: "worker_result".to_string(),
12987        label: format!("{} #{}", parsed.status, parsed.id),
12988        ref_id: Some(parsed.id.clone()),
12989        path: Some(display),
12990        line: Some(line),
12991        detail: Some(line_text.trim().to_string()),
12992        properties,
12993        expand: traversal_expand_command(root, &handle),
12994    }
12995}
12996
12997fn traversal_tokens(input: &str) -> BTreeSet<String> {
12998    input
12999        .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
13000        .flat_map(|part| part.split(['_', '-']))
13001        .map(str::trim)
13002        .filter(|part| part.len() >= 3)
13003        .map(|part| part.to_ascii_lowercase())
13004        .collect()
13005}
13006
13007fn traversal_ast_span_contains(
13008    parent: &TraversalAstSpanIndexEntry,
13009    child: &TraversalAstSpanIndexEntry,
13010) -> bool {
13011    parent.handle != child.handle
13012        && parent.file == child.file
13013        && parent.start_byte <= child.start_byte
13014        && parent.end_byte >= child.end_byte
13015}
13016
13017fn traversal_ast_parent_handle<'a>(
13018    entry: &TraversalAstSpanIndexEntry,
13019    entries: &'a [TraversalAstSpanIndexEntry],
13020) -> Option<&'a str> {
13021    entries
13022        .iter()
13023        .filter(|candidate| traversal_ast_span_contains(candidate, entry))
13024        .min_by_key(|candidate| {
13025            (
13026                candidate.end_byte.saturating_sub(candidate.start_byte),
13027                candidate.start_byte,
13028                candidate.end_byte,
13029                candidate.kind.as_str(),
13030                candidate.name.as_str(),
13031                candidate.node_kind.as_str(),
13032            )
13033        })
13034        .map(|candidate| candidate.handle.as_str())
13035}
13036
13037fn traversal_ast_enclosing_module_handle<'a>(
13038    entry: &TraversalAstSpanIndexEntry,
13039    entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
13040    parent_by_handle: &BTreeMap<String, String>,
13041) -> Option<&'a str> {
13042    let mut current = parent_by_handle.get(&entry.handle);
13043    while let Some(handle) = current {
13044        let Some(parent) = entries_by_handle.get(handle) else {
13045            break;
13046        };
13047        if matches!(parent.kind.as_str(), "module" | "mod")
13048            || entry
13049                .parent_module
13050                .as_deref()
13051                .is_some_and(|module| module == parent.name)
13052        {
13053            return Some(parent.handle.as_str());
13054        }
13055        current = parent_by_handle.get(&parent.handle);
13056    }
13057    None
13058}
13059
13060fn link_ast_navigation_edges(
13061    graph: &mut TraversalGraphBuild,
13062    entries: &[TraversalAstSpanIndexEntry],
13063) {
13064    let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
13065    let entries_by_handle = entries
13066        .iter()
13067        .map(|entry| (entry.handle.clone(), entry.clone()))
13068        .collect::<BTreeMap<_, _>>();
13069    let mut parent_by_handle = BTreeMap::<String, String>::new();
13070    let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
13071
13072    for entry in entries {
13073        entries_by_file
13074            .entry(entry.file.clone())
13075            .or_default()
13076            .push(entry.clone());
13077    }
13078
13079    for file_entries in entries_by_file.values() {
13080        for entry in file_entries {
13081            let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
13082            if let Some(parent) = &parent {
13083                parent_by_handle.insert(entry.handle.clone(), parent.clone());
13084            }
13085            let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
13086            children_by_parent
13087                .entry(sibling_key)
13088                .or_default()
13089                .push(entry.clone());
13090        }
13091    }
13092
13093    for entry in entries {
13094        let parent = parent_by_handle.get(&entry.handle);
13095        if let Some(parent) = parent {
13096            graph.add_edge(
13097                parent,
13098                &entry.handle,
13099                "contains",
13100                Some("AST parent contains child span".to_string()),
13101                1,
13102            );
13103            graph.add_edge(
13104                parent,
13105                &entry.handle,
13106                "child",
13107                Some("AST child span".to_string()),
13108                1,
13109            );
13110            graph.add_edge(
13111                &entry.handle,
13112                parent,
13113                "parent",
13114                Some("AST parent span".to_string()),
13115                1,
13116            );
13117        } else if let Some(file_handle) = &entry.file_handle {
13118            graph.add_edge(
13119                file_handle,
13120                &entry.handle,
13121                "contains",
13122                Some("file contains top-level AST span".to_string()),
13123                1,
13124            );
13125        }
13126
13127        if let Some(module_handle) =
13128            traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
13129        {
13130            graph.add_edge(
13131                &entry.handle,
13132                module_handle,
13133                "enclosing_module",
13134                Some("nearest enclosing module AST span".to_string()),
13135                1,
13136            );
13137        }
13138
13139        if entry.language == "markdown"
13140            && let Some(markdown) = &entry.markdown
13141            && let Some(section_handle) = &markdown.section_handle
13142            && section_handle != &entry.handle
13143        {
13144            graph.add_edge(
13145                section_handle,
13146                &entry.handle,
13147                "contains_markdown_block",
13148                Some("Markdown section contains block".to_string()),
13149                1,
13150            );
13151            graph.add_edge(
13152                &entry.handle,
13153                section_handle,
13154                "enclosing_section",
13155                Some("Markdown enclosing section".to_string()),
13156                1,
13157            );
13158        }
13159    }
13160
13161    for siblings in children_by_parent.values_mut() {
13162        siblings.sort_by(|left, right| {
13163            left.start_byte
13164                .cmp(&right.start_byte)
13165                .then(left.end_byte.cmp(&right.end_byte))
13166                .then(left.kind.cmp(&right.kind))
13167                .then(left.name.cmp(&right.name))
13168                .then(left.node_kind.cmp(&right.node_kind))
13169                .then(left.handle.cmp(&right.handle))
13170        });
13171        for pair in siblings.windows(2) {
13172            let previous = &pair[0];
13173            let next = &pair[1];
13174            graph.add_edge(
13175                &previous.handle,
13176                &next.handle,
13177                "next_sibling",
13178                Some("next AST sibling span".to_string()),
13179                1,
13180            );
13181            graph.add_edge(
13182                &next.handle,
13183                &previous.handle,
13184                "previous_sibling",
13185                Some("previous AST sibling span".to_string()),
13186                1,
13187            );
13188        }
13189    }
13190}
13191
13192fn traversal_markdown_embedded_symbol_node(
13193    root: &Path,
13194    entry: &TraversalAstSpanIndexEntry,
13195    markdown: &MarkdownSpanMetadata,
13196    embedded: &MarkdownEmbeddedSymbol,
13197) -> TraversalNode {
13198    let mut properties = BTreeMap::new();
13199    properties.insert("layer".to_string(), "embedded_code".to_string());
13200    properties.insert("embedded".to_string(), "true".to_string());
13201    properties.insert("language".to_string(), embedded.language.clone());
13202    properties.insert("symbol_kind".to_string(), embedded.kind.clone());
13203    properties.insert("node_kind".to_string(), embedded.node_kind.clone());
13204    properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
13205    properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
13206    properties.insert("end_line".to_string(), embedded.end_line.to_string());
13207    properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
13208    properties.insert(
13209        "markdown_block_kind".to_string(),
13210        markdown_ast_block_kind(&entry.kind),
13211    );
13212    if let Some(body_start_byte) = embedded.body_start_byte {
13213        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
13214    }
13215    if let Some(body_end_byte) = embedded.body_end_byte {
13216        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
13217    }
13218    if let Some(body_start_line) = embedded.body_start_line {
13219        properties.insert("body_start_line".to_string(), body_start_line.to_string());
13220    }
13221    if let Some(body_end_line) = embedded.body_end_line {
13222        properties.insert("body_end_line".to_string(), body_end_line.to_string());
13223    }
13224    if let Some(fence_language) = &markdown.fence_language {
13225        properties.insert("fence_language".to_string(), fence_language.clone());
13226    }
13227    if !markdown.section_path.is_empty() {
13228        properties.insert(
13229            "section_path".to_string(),
13230            markdown.section_path.join(" > "),
13231        );
13232    }
13233    if let Some(section_handle) = &markdown.section_handle {
13234        properties.insert("section_handle".to_string(), section_handle.clone());
13235    }
13236    let line_count = embedded
13237        .end_line
13238        .saturating_sub(embedded.start_line)
13239        .saturating_add(1)
13240        .max(1);
13241    TraversalNode {
13242        handle: embedded.handle.clone(),
13243        kind: "ast_span".to_string(),
13244        label: embedded.name.clone(),
13245        ref_id: Some(embedded.name.clone()),
13246        path: Some(entry.file.clone()),
13247        line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
13248        detail: Some(format!(
13249            "{} {} embedded in Markdown fence",
13250            embedded.language, embedded.kind
13251        )),
13252        properties,
13253        expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
13254    }
13255}
13256
13257fn link_markdown_embedded_code_edges(
13258    graph: &mut TraversalGraphBuild,
13259    root: &Path,
13260    entries: &[TraversalAstSpanIndexEntry],
13261) {
13262    for entry in entries {
13263        let Some(markdown) = &entry.markdown else {
13264            continue;
13265        };
13266        for embedded in &markdown.embedded_symbols {
13267            let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
13268            graph.add_node(node);
13269            graph.add_edge(
13270                &entry.handle,
13271                &embedded.handle,
13272                "contains",
13273                Some("Markdown fence contains embedded AST symbol".to_string()),
13274                1,
13275            );
13276            graph.add_edge(
13277                &entry.handle,
13278                &embedded.handle,
13279                "child",
13280                Some("embedded code symbol".to_string()),
13281                1,
13282            );
13283            graph.add_edge(
13284                &entry.handle,
13285                &embedded.handle,
13286                "contains_embedded_symbol",
13287                Some("Markdown fence contains embedded code symbol".to_string()),
13288                1,
13289            );
13290            graph.add_edge(
13291                &embedded.handle,
13292                &entry.handle,
13293                "parent",
13294                Some("Markdown fence parent span".to_string()),
13295                1,
13296            );
13297            graph.add_edge(
13298                &embedded.handle,
13299                &entry.handle,
13300                "embedded_in_fence",
13301                Some("embedded code symbol belongs to Markdown fence".to_string()),
13302                1,
13303            );
13304            if let Some(section_handle) = &markdown.section_handle
13305                && section_handle != &entry.handle
13306            {
13307                graph.add_edge(
13308                    section_handle,
13309                    &embedded.handle,
13310                    "contains_embedded_code",
13311                    Some("Markdown section contains embedded code symbol".to_string()),
13312                    1,
13313                );
13314                graph.add_edge(
13315                    &embedded.handle,
13316                    section_handle,
13317                    "enclosing_section",
13318                    Some("Markdown enclosing section".to_string()),
13319                    1,
13320                );
13321            }
13322        }
13323    }
13324}
13325
13326fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
13327    let mut tokens = traversal_tokens(&node.label);
13328    if let Some(ref_id) = &node.ref_id {
13329        tokens.extend(traversal_tokens(ref_id));
13330    }
13331    if let Some(path) = &node.path {
13332        tokens.extend(traversal_tokens(path));
13333    }
13334    if let Some(detail) = &node.detail {
13335        tokens.extend(traversal_tokens(detail));
13336    }
13337    tokens
13338}
13339
13340fn markdown_code_spans(input: &str) -> Vec<String> {
13341    input
13342        .split('`')
13343        .enumerate()
13344        .filter(|(idx, _)| idx % 2 == 1)
13345        .map(|(_, part)| part.trim().to_string())
13346        .filter(|part| !part.is_empty())
13347        .collect()
13348}
13349
13350fn push_traversal_token_index(
13351    index: &mut HashMap<String, Vec<usize>>,
13352    tokens: &BTreeSet<String>,
13353    entry_index: usize,
13354) {
13355    for token in tokens {
13356        index.entry(token.clone()).or_default().push(entry_index);
13357    }
13358}
13359
13360impl<'a> TraversalCodeLookup<'a> {
13361    fn new(
13362        symbols: &'a [TraversalSymbolIndexEntry],
13363        files: &'a [TraversalFileIndexEntry],
13364        routes: &'a [TraversalRouteIndexEntry],
13365        multiplicities: &'a [TraversalMultiplicityIndexEntry],
13366    ) -> Self {
13367        let mut symbol_index = HashMap::new();
13368        for (idx, entry) in symbols.iter().enumerate() {
13369            push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
13370        }
13371        let mut file_index = HashMap::new();
13372        let mut file_path_index = HashMap::new();
13373        for (idx, entry) in files.iter().enumerate() {
13374            push_traversal_token_index(&mut file_index, &entry.tokens, idx);
13375            if let Some(path) = entry.node.path.as_ref() {
13376                file_path_index.insert(path.clone(), path.clone());
13377            }
13378        }
13379        let mut route_index = HashMap::new();
13380        for (idx, entry) in routes.iter().enumerate() {
13381            push_traversal_token_index(&mut route_index, &entry.tokens, idx);
13382        }
13383        let mut multiplicity_index = HashMap::new();
13384        for (idx, entry) in multiplicities.iter().enumerate() {
13385            push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
13386        }
13387        Self {
13388            symbols,
13389            files,
13390            routes,
13391            multiplicities,
13392            symbol_index,
13393            file_index,
13394            route_index,
13395            multiplicity_index,
13396            file_path_index,
13397        }
13398    }
13399
13400    fn touched_files_for_line(&self, line: &str) -> Vec<String> {
13401        let mut touched_files = BTreeSet::new();
13402        for candidate in markdown_code_spans(line)
13403            .into_iter()
13404            .chain(line.split_whitespace().map(str::to_string))
13405        {
13406            for path in traversal_path_candidates(&candidate) {
13407                if let Some(file) = self.file_path_index.get(&path) {
13408                    touched_files.insert(file.clone());
13409                }
13410            }
13411        }
13412        touched_files.into_iter().collect()
13413    }
13414}
13415
13416fn traversal_path_candidates(candidate: &str) -> Vec<String> {
13417    let trimmed = candidate.trim_matches(|ch: char| {
13418        matches!(
13419            ch,
13420            '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
13421        )
13422    });
13423    if trimmed.is_empty() {
13424        return Vec::new();
13425    }
13426    let mut candidates = vec![trimmed.to_string()];
13427    if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
13428        && !path.is_empty()
13429        && line_suffix.chars().all(|ch| ch.is_ascii_digit())
13430    {
13431        candidates.push(path.to_string());
13432    }
13433    candidates
13434}
13435
13436fn parse_worker_result_line(
13437    line: &str,
13438    lookup: &TraversalCodeLookup<'_>,
13439) -> Vec<ParsedWorkerResult> {
13440    if line.trim_start().starts_with("- [") {
13441        return Vec::new();
13442    }
13443    let lower = line.to_ascii_lowercase();
13444    let status =
13445        if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
13446        {
13447            "completed"
13448        } else if lower.contains("blocked") || lower.contains("externally blocked") {
13449            "blocked"
13450        } else {
13451            return Vec::new();
13452        };
13453    let result_prefix_end = ["follow-up", "follow up", "next:"]
13454        .iter()
13455        .filter_map(|marker| lower.find(marker))
13456        .min()
13457        .unwrap_or(line.len());
13458    let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
13459    if ids.is_empty() {
13460        return Vec::new();
13461    }
13462    let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
13463    let all_ids = extract_conflict_target_refs(line);
13464
13465    let touched_files = lookup.touched_files_for_line(line);
13466    let tests = markdown_code_spans(line)
13467        .into_iter()
13468        .filter(|span| span.to_ascii_lowercase().contains("test"))
13469        .collect::<Vec<_>>();
13470
13471    ids.iter()
13472        .map(|id| ParsedWorkerResult {
13473            id: id.clone(),
13474            status: status.to_string(),
13475            touched_files: touched_files.clone(),
13476            tests: tests.clone(),
13477            follow_up_ids: all_ids
13478                .iter()
13479                .filter(|other| *other != id && !result_ids.contains(*other))
13480                .cloned()
13481                .collect(),
13482        })
13483        .collect()
13484}
13485
13486fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
13487    let hinted_path = if path_hint.is_absolute() {
13488        path_hint.to_path_buf()
13489    } else {
13490        root.join(path_hint)
13491    };
13492    if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
13493        return Some(hinted_path);
13494    }
13495    None
13496}
13497
13498fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
13499    let candidate = if path.is_absolute() {
13500        path.to_path_buf()
13501    } else {
13502        source_root.join(path)
13503    };
13504    if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
13505        return false;
13506    }
13507    if !matches!(
13508        candidate.extension().and_then(|ext| ext.to_str()),
13509        Some("md" | "mdx")
13510    ) {
13511        return false;
13512    }
13513    fs::read_to_string(&candidate)
13514        .map(|content| session_markdown::markdown_content_looks_like_agent_doc_session(&content))
13515        .unwrap_or(false)
13516}
13517
13518fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
13519    if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
13520        return Ok(vec![hinted_path]);
13521    }
13522    let mut files = Vec::new();
13523    let walker = ignore::WalkBuilder::new(root)
13524        .hidden(true)
13525        .git_ignore(true)
13526        .git_global(true)
13527        .git_exclude(true)
13528        .build();
13529    for result in walker {
13530        let entry =
13531            result.with_context(|| format!("walking markdown files under {}", root.display()))?;
13532        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
13533            continue;
13534        }
13535        if traversal_path_is_generated_artifact(root, root, entry.path()) {
13536            continue;
13537        }
13538        if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
13539            files.push(entry.path().to_path_buf());
13540        }
13541    }
13542    files.sort();
13543    Ok(files)
13544}
13545
13546fn traversal_watermark_path(root: &Path, path: &Path) -> String {
13547    path.strip_prefix(root)
13548        .unwrap_or(path)
13549        .to_string_lossy()
13550        .replace('\\', "/")
13551}
13552
13553fn push_traversal_metadata_watermark_part(
13554    root: &Path,
13555    path: &Path,
13556    label: &str,
13557    parts: &mut Vec<String>,
13558) {
13559    let display = traversal_watermark_path(root, path);
13560    match fs::metadata(path) {
13561        Ok(metadata) => {
13562            let (secs, nanos) = metadata
13563                .modified()
13564                .ok()
13565                .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
13566                .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
13567                .unwrap_or((0, 0));
13568            parts.push(format!(
13569                "{label}:{display}:len={}:mtime={secs}.{nanos}",
13570                metadata.len()
13571            ));
13572        }
13573        Err(_) => parts.push(format!("{label}:{display}:missing")),
13574    }
13575}
13576
13577#[derive(Serialize)]
13578struct TraversalSummaryWatermarkRow<'a> {
13579    symbol_name: &'a str,
13580    file_path: &'a str,
13581    entities: &'a Option<Vec<summarize::Entity>>,
13582    relationships: &'a Option<Vec<summarize::Relationship>>,
13583    concept_labels: &'a Option<Vec<String>>,
13584}
13585
13586fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
13587    let summaries_db = root.join(".tsift/summaries.db");
13588    if !summaries_db.exists() {
13589        parts.push("summaries_db:absent".to_string());
13590        return Ok(());
13591    }
13592
13593    match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
13594        .and_then(|summary_db| summary_db.all())
13595    {
13596        Ok(summaries) => {
13597            let rows = summaries
13598                .iter()
13599                .map(|summary| TraversalSummaryWatermarkRow {
13600                    symbol_name: &summary.symbol_name,
13601                    file_path: &summary.file_path,
13602                    entities: &summary.entities,
13603                    relationships: &summary.relationships,
13604                    concept_labels: &summary.concept_labels,
13605                })
13606                .collect::<Vec<_>>();
13607            parts.push(format!(
13608                "summaries_db:rows={}:semantic_hash={}",
13609                rows.len(),
13610                content_hash(&rows)?
13611            ));
13612        }
13613        Err(_) => {
13614            push_traversal_metadata_watermark_part(
13615                root,
13616                &summaries_db,
13617                "summaries_db_unreadable",
13618                parts,
13619            );
13620        }
13621    }
13622    Ok(())
13623}
13624
13625#[cfg(test)]
13626fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
13627    resolution::relative_path_is_generated_artifact(relative)
13628}
13629
13630fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
13631    resolution::path_is_generated_artifact(root, source_root, path)
13632}
13633
13634fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
13635    resolution::index_snapshot_part_is_generated(root, source_root, part)
13636}
13637
13638pub(crate) fn traversal_source_watermark(
13639    root: &Path,
13640    path_hint: &Path,
13641    scope: Option<&str>,
13642    session_only: bool,
13643) -> Result<Option<String>> {
13644    let mut parts = vec![
13645        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
13646        format!("scope:{}", scope.unwrap_or("root")),
13647        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
13648        format!("session_only:{session_only}"),
13649    ];
13650
13651    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13652        let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13653            Ok(targets) => targets,
13654            Err(_) => return Ok(None),
13655        };
13656        let Some(target) = targets.into_iter().next() else {
13657            return Ok(None);
13658        };
13659        let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
13660            Ok(db) => db,
13661            Err(_) => return Ok(None),
13662        };
13663        parts.push(format!("index_label:{}", target.label));
13664        parts.push(format!(
13665            "index_scope:{}",
13666            target.scope_name.as_deref().unwrap_or("root")
13667        ));
13668        parts.push(format!(
13669            "index_source_root:{}",
13670            traversal_watermark_path(root, &target.source_root)
13671        ));
13672        let mut snapshot_rows = 0usize;
13673        for part in db.source_snapshot_parts()? {
13674            if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
13675                continue;
13676            }
13677            snapshot_rows += 1;
13678            parts.push(format!("index_snapshot:{part}"));
13679        }
13680        parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
13681    }
13682
13683    let markdown_files = markdown_files_for_traversal(root, path_hint)?;
13684    parts.push(format!("markdown_count:{}", markdown_files.len()));
13685    for markdown_path in markdown_files {
13686        push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
13687    }
13688
13689    push_traversal_summaries_watermark_part(root, &mut parts)?;
13690
13691    Ok(Some(content_hash(&parts)?))
13692}
13693
13694fn ranked_symbol_matches<'a>(
13695    query_tokens: &BTreeSet<String>,
13696    entries: &'a [TraversalSymbolIndexEntry],
13697    index: &HashMap<String, Vec<usize>>,
13698) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13699    let mut scores = BTreeMap::<usize, usize>::new();
13700    for token in query_tokens {
13701        if let Some(indices) = index.get(token) {
13702            for idx in indices {
13703                *scores.entry(*idx).or_default() += 1;
13704            }
13705        }
13706    }
13707    let mut matches = scores
13708        .into_iter()
13709        .map(|(idx, score)| (score, &entries[idx]))
13710        .collect::<Vec<_>>();
13711    matches.sort_by(|(left_score, left), (right_score, right)| {
13712        right_score
13713            .cmp(left_score)
13714            .then_with(|| left.node.label.cmp(&right.node.label))
13715            .then_with(|| left.handle.cmp(&right.handle))
13716    });
13717    matches
13718}
13719
13720fn ranked_file_matches<'a>(
13721    query_tokens: &BTreeSet<String>,
13722    entries: &'a [TraversalFileIndexEntry],
13723    index: &HashMap<String, Vec<usize>>,
13724) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13725    let mut scores = BTreeMap::<usize, usize>::new();
13726    for token in query_tokens {
13727        if let Some(indices) = index.get(token) {
13728            for idx in indices {
13729                *scores.entry(*idx).or_default() += 1;
13730            }
13731        }
13732    }
13733    let mut matches = scores
13734        .into_iter()
13735        .map(|(idx, score)| (score, &entries[idx]))
13736        .collect::<Vec<_>>();
13737    matches.sort_by(|(left_score, left), (right_score, right)| {
13738        right_score
13739            .cmp(left_score)
13740            .then_with(|| left.node.label.cmp(&right.node.label))
13741            .then_with(|| left.handle.cmp(&right.handle))
13742    });
13743    matches
13744}
13745
13746fn ranked_route_matches<'a>(
13747    query_tokens: &BTreeSet<String>,
13748    entries: &'a [TraversalRouteIndexEntry],
13749    index: &HashMap<String, Vec<usize>>,
13750) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13751    let mut scores = BTreeMap::<usize, usize>::new();
13752    for token in query_tokens {
13753        if let Some(indices) = index.get(token) {
13754            for idx in indices {
13755                *scores.entry(*idx).or_default() += 1;
13756            }
13757        }
13758    }
13759    let mut matches = scores
13760        .into_iter()
13761        .map(|(idx, score)| (score, &entries[idx]))
13762        .collect::<Vec<_>>();
13763    matches.sort_by(|(left_score, left), (right_score, right)| {
13764        right_score
13765            .cmp(left_score)
13766            .then_with(|| left.node.label.cmp(&right.node.label))
13767            .then_with(|| left.handle.cmp(&right.handle))
13768    });
13769    matches
13770}
13771
13772fn ranked_multiplicity_matches<'a>(
13773    query_tokens: &BTreeSet<String>,
13774    entries: &'a [TraversalMultiplicityIndexEntry],
13775    index: &HashMap<String, Vec<usize>>,
13776) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13777    let mut scores = BTreeMap::<usize, usize>::new();
13778    for token in query_tokens {
13779        if let Some(indices) = index.get(token) {
13780            for idx in indices {
13781                *scores.entry(*idx).or_default() += 1;
13782            }
13783        }
13784    }
13785    let mut matches = scores
13786        .into_iter()
13787        .map(|(idx, score)| (score, &entries[idx]))
13788        .collect::<Vec<_>>();
13789    matches.sort_by(|(left_score, left), (right_score, right)| {
13790        right_score
13791            .cmp(left_score)
13792            .then_with(|| left.node.kind.cmp(&right.node.kind))
13793            .then_with(|| left.node.label.cmp(&right.node.label))
13794            .then_with(|| left.handle.cmp(&right.handle))
13795    });
13796    matches
13797}
13798
13799fn link_backlog_to_code_nodes(
13800    graph: &mut TraversalGraphBuild,
13801    backlog: &TraversalNode,
13802    text: &str,
13803    lookup: &TraversalCodeLookup<'_>,
13804    limit: usize,
13805) {
13806    let mut query_tokens = traversal_tokens(text);
13807    if let Some(ref_id) = &backlog.ref_id {
13808        query_tokens.extend(traversal_tokens(ref_id));
13809    }
13810    if query_tokens.is_empty() {
13811        return;
13812    }
13813
13814    for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13815        .into_iter()
13816        .take(limit)
13817    {
13818        graph.add_edge(
13819            &backlog.handle,
13820            &entry.handle,
13821            "mentions",
13822            Some("backlog text matches symbol tokens".to_string()),
13823            score,
13824        );
13825    }
13826
13827    for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13828        .into_iter()
13829        .take(limit.min(5))
13830    {
13831        graph.add_edge(
13832            &backlog.handle,
13833            &entry.handle,
13834            "mentions",
13835            Some("backlog text matches file tokens".to_string()),
13836            score,
13837        );
13838    }
13839
13840    for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13841        .into_iter()
13842        .take(limit.min(5))
13843    {
13844        graph.add_edge(
13845            &backlog.handle,
13846            &entry.handle,
13847            "mentions",
13848            Some("backlog text matches route tokens".to_string()),
13849            score,
13850        );
13851    }
13852
13853    for (score, entry) in ranked_multiplicity_matches(
13854        &query_tokens,
13855        lookup.multiplicities,
13856        &lookup.multiplicity_index,
13857    )
13858    .into_iter()
13859    .take(limit.min(5))
13860    {
13861        graph.add_edge(
13862            &backlog.handle,
13863            &entry.handle,
13864            "mentions",
13865            Some("backlog text matches multiplicity tokens".to_string()),
13866            score,
13867        );
13868    }
13869}
13870
13871fn load_agent_doc_traversal_nodes(
13872    root: &Path,
13873    path_hint: &Path,
13874    graph: &mut TraversalGraphBuild,
13875    lookup: &TraversalCodeLookup<'_>,
13876) -> Result<()> {
13877    for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13878        let content = match fs::read_to_string(&markdown_path) {
13879            Ok(content) => content,
13880            Err(err) => {
13881                graph.warnings.push(format!(
13882                    "session artifact unavailable: {}: {err}",
13883                    markdown_path.display()
13884                ));
13885                continue;
13886            }
13887        };
13888        let Some(document) = AgentDocSessionDocument::parse_if_session(&content) else {
13889            continue;
13890        };
13891
13892        let session = traversal_session_node(root, &markdown_path, document.session_id.as_deref());
13893        graph.add_node(session.clone());
13894        let lines = content.lines().collect::<Vec<_>>();
13895        let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13896        for item in &document.backlog_items {
13897            let backlog = traversal_backlog_node(
13898                root,
13899                &markdown_path,
13900                &item.id,
13901                &item.text,
13902                item.line as i64,
13903            );
13904            graph.add_node(backlog.clone());
13905            backlog_by_id.insert(item.id.clone(), backlog.clone());
13906            graph.add_edge(
13907                &session.handle,
13908                &backlog.handle,
13909                "contains",
13910                Some("session backlog item".to_string()),
13911                1,
13912            );
13913            link_backlog_to_code_nodes(graph, &backlog, &item.text, lookup, 8);
13914        }
13915
13916        let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13917        for item in &document.queue_items {
13918            match item {
13919                AgentDocQueueItem::Dispatch { value, line }
13920                | AgentDocQueueItem::Preset { value, line } => {
13921                    let dispatch_ref = value.strip_prefix('#').unwrap_or(value.as_str());
13922                    let node = traversal_job_packet_node(
13923                        root,
13924                        &markdown_path,
13925                        &format!("dispatch {value}"),
13926                        Some(dispatch_ref),
13927                        "agent-doc dispatch preset",
13928                        *line as i64,
13929                    );
13930                    graph.add_node(node.clone());
13931                    graph.add_edge(
13932                        &session.handle,
13933                        &node.handle,
13934                        "contains",
13935                        Some("session queued dispatch".to_string()),
13936                        1,
13937                    );
13938                }
13939                AgentDocQueueItem::Do { id, line } => {
13940                    let detail = backlog_by_id
13941                        .get(id)
13942                        .and_then(|node| node.detail.clone())
13943                        .unwrap_or_else(|| "queued backlog item".to_string());
13944                    let node = traversal_job_packet_node(
13945                        root,
13946                        &markdown_path,
13947                        &format!("do #{id}"),
13948                        Some(id),
13949                        &detail,
13950                        *line as i64,
13951                    );
13952                    graph.add_node(node.clone());
13953                    graph.add_edge(
13954                        &session.handle,
13955                        &node.handle,
13956                        "contains",
13957                        Some("session queued job packet".to_string()),
13958                        1,
13959                    );
13960                    if let Some(backlog) = backlog_by_id.get(id) {
13961                        graph.add_edge(
13962                            &node.handle,
13963                            &backlog.handle,
13964                            "targets",
13965                            Some("queued backlog item".to_string()),
13966                            1,
13967                        );
13968                    }
13969                    job_by_id.insert(id.clone(), node);
13970                }
13971            }
13972        }
13973
13974        let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13975        for (idx, line) in lines.iter().enumerate() {
13976            for parsed in parse_worker_result_line(line, lookup) {
13977                let line_no = idx as i64 + 1;
13978                if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13979                    continue;
13980                }
13981                let result =
13982                    traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13983                graph.add_node(result.clone());
13984                graph.add_edge(
13985                    &session.handle,
13986                    &result.handle,
13987                    "contains",
13988                    Some("session worker result".to_string()),
13989                    1,
13990                );
13991                if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13992                    graph.add_edge(
13993                        &backlog.handle,
13994                        &result.handle,
13995                        "has_result",
13996                        Some(format!("worker result {}", parsed.status)),
13997                        1,
13998                    );
13999                }
14000                if let Some(job) = job_by_id.get(&parsed.id) {
14001                    graph.add_edge(
14002                        &job.handle,
14003                        &result.handle,
14004                        "has_result",
14005                        Some(format!("queued worker result {}", parsed.status)),
14006                        1,
14007                    );
14008                }
14009                let mut result_text = line.to_string();
14010                if !parsed.touched_files.is_empty() {
14011                    result_text.push(' ');
14012                    result_text.push_str(&parsed.touched_files.join(" "));
14013                }
14014                link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
14015            }
14016        }
14017    }
14018    Ok(())
14019}
14020
14021#[derive(Debug, Clone)]
14022struct AgentDocIndexGate {
14023    db_path: Option<PathBuf>,
14024    source_root: PathBuf,
14025    diagnostics: Vec<String>,
14026}
14027
14028#[derive(Clone, Hash, PartialEq, Eq)]
14029struct AgentDocIndexGateCacheKey {
14030    root: PathBuf,
14031    path_hint: PathBuf,
14032    scope: Option<String>,
14033    packet_label: String,
14034}
14035
14036fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
14037    std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
14038> {
14039    static CACHE: std::sync::OnceLock<
14040        std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
14041    > = std::sync::OnceLock::new();
14042    CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
14043}
14044
14045fn prepare_agent_doc_index_gate_cached(
14046    root: &Path,
14047    path_hint: &Path,
14048    scope: Option<&str>,
14049    packet_label: &str,
14050) -> (AgentDocIndexGate, String) {
14051    let key = AgentDocIndexGateCacheKey {
14052        root: root.to_path_buf(),
14053        path_hint: path_hint.to_path_buf(),
14054        scope: scope.map(str::to_string),
14055        packet_label: packet_label.to_string(),
14056    };
14057    if let Ok(cache) = agent_doc_index_gate_cache().lock()
14058        && let Some(cached) = cache.get(&key)
14059    {
14060        return (
14061            cached.clone(),
14062            "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
14063        );
14064    }
14065    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
14066    if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
14067        cache.insert(key, gate.clone());
14068    }
14069    (
14070        gate,
14071        "fresh inspection/refresh — cache miss on this preparation key".to_string(),
14072    )
14073}
14074
14075fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
14076    match state {
14077        SearchIndexState::Fresh => None,
14078        SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
14079        SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
14080    }
14081}
14082
14083fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
14084    rebuild_search_target_detail(&RebuildSearchTarget {
14085        label: target.label.clone(),
14086        reason,
14087        reindex_cmd: target.reindex_cmd.clone(),
14088    })
14089}
14090
14091fn index_refresh_diagnostic(
14092    target: &SearchIndexTarget,
14093    reason: RebuildSearchReason,
14094    summary: &index::IndexSummary,
14095    packet_label: &str,
14096) -> String {
14097    let changed = summary.new + summary.modified + summary.deleted;
14098    format!(
14099        "index refreshed: {}; updated {} changed file{} before {}",
14100        index_reason_detail(target, reason),
14101        changed,
14102        if changed == 1 { "" } else { "s" },
14103        packet_label
14104    )
14105}
14106
14107fn index_refresh_fallback_diagnostic(
14108    target: &SearchIndexTarget,
14109    reason: RebuildSearchReason,
14110    err: &anyhow::Error,
14111    packet_label: &str,
14112) -> String {
14113    format!(
14114        "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
14115        index_reason_detail(target, reason),
14116        packet_label
14117    )
14118}
14119
14120fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
14121    if let Some(scope_name) = scope
14122        && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
14123    {
14124        return scope.source_root;
14125    }
14126    if let Some(scope_name) = scope
14127        && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
14128    {
14129        return package.package_root;
14130    }
14131    if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
14132        return scope.source_root;
14133    }
14134    if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
14135        return package.package_root;
14136    }
14137    if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
14138        return scope.source_root;
14139    }
14140    root.to_path_buf()
14141}
14142
14143fn prepare_agent_doc_index_gate(
14144    root: &Path,
14145    path_hint: &Path,
14146    scope: Option<&str>,
14147    packet_label: &str,
14148) -> AgentDocIndexGate {
14149    let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
14150    let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
14151        Ok(targets) => targets,
14152        Err(err) => {
14153            return AgentDocIndexGate {
14154                db_path: None,
14155                source_root: fallback_source_root,
14156                diagnostics: vec![format!(
14157                    "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14158                )],
14159            };
14160        }
14161    };
14162    let Some(target) = targets.into_iter().next() else {
14163        return AgentDocIndexGate {
14164            db_path: None,
14165            source_root: fallback_source_root,
14166            diagnostics: vec![format!(
14167                "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
14168            )],
14169        };
14170    };
14171
14172    let state = match inspect_search_index(&target) {
14173        Ok(state) => state,
14174        Err(err) => {
14175            return AgentDocIndexGate {
14176                db_path: None,
14177                source_root: target.source_root,
14178                diagnostics: vec![format!(
14179                    "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14180                )],
14181            };
14182        }
14183    };
14184
14185    let Some(reason) = index_reason_for_state(state) else {
14186        return AgentDocIndexGate {
14187            db_path: Some(target.db_path),
14188            source_root: target.source_root,
14189            diagnostics: Vec::new(),
14190        };
14191    };
14192
14193    match apply_search_index_update(root, &target) {
14194        Ok(summary) => {
14195            // #gdbgatecold: the index was just rewritten, so any cached
14196            // pre-refresh inspection result for this scope (held by the
14197            // active lazily-backed `InspectScopeGuard`) is stale. Invalidate
14198            // the scope epoch so the next `inspect_read_only` re-reads the
14199            // fresh index.
14200            index::inspect_scope_invalidate_all();
14201            let diagnostics = vec![index_refresh_diagnostic(
14202                &target,
14203                reason,
14204                &summary,
14205                packet_label,
14206            )];
14207            AgentDocIndexGate {
14208                db_path: Some(target.db_path),
14209                source_root: target.source_root,
14210                diagnostics,
14211            }
14212        }
14213        Err(err) => {
14214            let diagnostics = vec![index_refresh_fallback_diagnostic(
14215                &target,
14216                reason,
14217                &err,
14218                packet_label,
14219            )];
14220            AgentDocIndexGate {
14221                db_path: None,
14222                source_root: target.source_root,
14223                diagnostics,
14224            }
14225        }
14226    }
14227}
14228
14229fn add_raw_source_file_nodes(
14230    root: &Path,
14231    source_root: &Path,
14232    graph: &mut TraversalGraphBuild,
14233    file_entries: &mut Vec<TraversalFileIndexEntry>,
14234) -> Result<()> {
14235    let mut entries = walk::walk_files(source_root)?;
14236    entries.sort_by(|left, right| left.path.cmp(&right.path));
14237    for entry in entries {
14238        let file = entry.path.to_string_lossy();
14239        let node = traversal_raw_source_file_node(root, file.as_ref());
14240        let entry = TraversalFileIndexEntry {
14241            handle: node.handle.clone(),
14242            tokens: traversal_node_tokens(&node),
14243            node: node.clone(),
14244        };
14245        graph.add_node(node);
14246        file_entries.push(entry);
14247    }
14248    Ok(())
14249}
14250
14251fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
14252    if scope_root.is_empty() {
14253        return true;
14254    }
14255    path == scope_root || path.starts_with(&format!("{scope_root}/"))
14256}
14257
14258fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
14259    let path = Path::new(file);
14260    if path.is_absolute() {
14261        return path.to_path_buf();
14262    }
14263    let source_candidate = source_root.join(path);
14264    if source_candidate.exists() {
14265        source_candidate
14266    } else {
14267        root.join(path)
14268    }
14269}
14270
14271fn cargo_import_alias_from_line(line: &str) -> Option<String> {
14272    let trimmed = line.trim();
14273    let rest = trimmed
14274        .strip_prefix("pub use ")
14275        .or_else(|| trimmed.strip_prefix("use "))
14276        .or_else(|| trimmed.strip_prefix("extern crate "))?;
14277    let alias = rest
14278        .split([':', ';', ' ', '\t'])
14279        .next()
14280        .unwrap_or_default()
14281        .trim();
14282    (!alias.is_empty()).then(|| alias.to_string())
14283}
14284
14285fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
14286    let mut aliases = BTreeSet::new();
14287    for entry in walk::walk_files(&package.package_root)? {
14288        if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
14289            continue;
14290        }
14291        let content = fs::read_to_string(&entry.path)
14292            .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
14293        aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
14294    }
14295    Ok(aliases)
14296}
14297
14298fn load_multiplicity_traversal_nodes(
14299    root: &Path,
14300    source_root: &Path,
14301    graph: &mut TraversalGraphBuild,
14302    file_handle_by_path: &HashMap<String, String>,
14303    multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
14304) -> Result<()> {
14305    let inventory = multiplicity::discover_cargo_inventory(source_root)?;
14306    let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
14307    for workspace in &inventory.workspaces {
14308        let node = traversal_cargo_workspace_node(root, workspace);
14309        workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
14310        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14311            handle: node.handle.clone(),
14312            tokens: traversal_node_tokens(&node),
14313            node: node.clone(),
14314        });
14315        graph.add_node(node);
14316    }
14317
14318    let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
14319    let mut package_nodes = Vec::new();
14320    for package in &inventory.packages {
14321        let node = traversal_cargo_package_node(root, package);
14322        package_handle_by_name
14323            .entry(package.name.clone())
14324            .or_default()
14325            .push(node.handle.clone());
14326        package_handle_by_name
14327            .entry(package.normalized_name.clone())
14328            .or_default()
14329            .push(node.handle.clone());
14330        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14331            handle: node.handle.clone(),
14332            tokens: traversal_node_tokens(&node),
14333            node: node.clone(),
14334        });
14335        graph.add_node(node.clone());
14336        package_nodes.push((package, node));
14337    }
14338
14339    for (package, node) in &package_nodes {
14340        if let Some(workspace_handle) =
14341            workspace_handle_by_root.get(&package.relative_workspace_root)
14342        {
14343            graph.add_edge(
14344                workspace_handle,
14345                &node.handle,
14346                "contains_package",
14347                Some("Cargo workspace member package".to_string()),
14348                1,
14349            );
14350        }
14351        let package_root = relativize_pathbuf(&package.package_root, root)
14352            .to_string_lossy()
14353            .replace('\\', "/");
14354        for (file, handle) in file_handle_by_path {
14355            if relative_path_inside_scope(file, &package_root) {
14356                graph.add_edge(
14357                    &node.handle,
14358                    handle,
14359                    "owns_file",
14360                    Some("Cargo package owns source file".to_string()),
14361                    1,
14362                );
14363            }
14364        }
14365        for dependency in &package.dependencies {
14366            if let Some(handles) = package_handle_by_name.get(&dependency.name)
14367                && handles.len() == 1
14368            {
14369                graph.add_edge(
14370                    &node.handle,
14371                    &handles[0],
14372                    "declares_dependency",
14373                    Some(format!("{} Cargo dependency", dependency.kind)),
14374                    1,
14375                );
14376            }
14377        }
14378        for alias in cargo_import_aliases(package)? {
14379            if let Some(handles) = package_handle_by_name.get(&alias)
14380                && handles.len() == 1
14381                && handles[0] != node.handle
14382            {
14383                graph.add_edge(
14384                    &node.handle,
14385                    &handles[0],
14386                    "uses_crate",
14387                    Some("Rust use/extern crate reference".to_string()),
14388                    1,
14389                );
14390                graph.add_edge(
14391                    &node.handle,
14392                    &handles[0],
14393                    "imports",
14394                    Some("Rust use/extern crate import".to_string()),
14395                    1,
14396                );
14397            }
14398        }
14399    }
14400
14401    Ok(())
14402}
14403
14404fn build_traversal_graph_source_with_options(
14405    root: &Path,
14406    path_hint: &Path,
14407    scope: Option<&str>,
14408    session_only: bool,
14409) -> Result<TraversalGraphBuild> {
14410    let mut graph = TraversalGraphBuild::default();
14411    let mut symbol_entries = Vec::new();
14412    let mut file_entries = Vec::new();
14413    let mut route_entries = Vec::new();
14414    let mut multiplicity_entries = Vec::new();
14415    let mut file_handle_by_path = HashMap::<String, String>::new();
14416    let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
14417    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
14418        let (gate, _cache_detail) =
14419            prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
14420        graph.warnings.extend(gate.diagnostics);
14421        let gate_source_root = gate.source_root.clone();
14422
14423        match gate.db_path {
14424            Some(db_path) if db_path.exists() => {
14425                let db = index::IndexDb::open_read_only_resilient(&db_path)?;
14426                let file_paths = db.file_paths()?;
14427                for file in file_paths {
14428                    if traversal_path_is_generated_artifact(
14429                        root,
14430                        &gate_source_root,
14431                        Path::new(&file),
14432                    ) {
14433                        continue;
14434                    }
14435                    let node = traversal_file_node(root, &file);
14436                    let entry = TraversalFileIndexEntry {
14437                        handle: node.handle.clone(),
14438                        tokens: traversal_node_tokens(&node),
14439                        node: node.clone(),
14440                    };
14441                    if let Some(path) = entry.node.path.as_ref() {
14442                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
14443                    }
14444                    graph.add_node(node);
14445                    file_entries.push(entry);
14446                }
14447
14448                let symbols = db.all_symbols()?;
14449                // AST parent/child lookup is per-file; using the root symbol set
14450                // here turns large graph refreshes into quadratic full-index scans.
14451                let mut symbols_by_file = HashMap::<String, Vec<&index::StoredSymbol>>::new();
14452                for symbol in &symbols {
14453                    symbols_by_file
14454                        .entry(symbol.file.clone())
14455                        .or_default()
14456                        .push(symbol);
14457                }
14458                let mut symbol_by_file_name_line = HashMap::new();
14459                let mut span_by_file_name_line = HashMap::new();
14460                let mut first_symbol_by_name = BTreeMap::<String, String>::new();
14461                let mut first_span_by_name = BTreeMap::<String, String>::new();
14462                let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
14463                let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
14464                for symbol in symbols.iter().filter(|symbol| {
14465                    !traversal_path_is_generated_artifact(
14466                        root,
14467                        &gate_source_root,
14468                        Path::new(&symbol.file),
14469                    )
14470                }) {
14471                    let node = traversal_symbol_node(root, symbol);
14472                    let file = relativize(&symbol.file, root);
14473                    symbol_by_file_name_line.insert(
14474                        format!("{file}:{}:{}", symbol.line, symbol.name),
14475                        node.handle.clone(),
14476                    );
14477                    first_symbol_by_name
14478                        .entry(symbol.name.clone())
14479                        .or_insert_with(|| node.handle.clone());
14480                    let entry = TraversalSymbolIndexEntry {
14481                        handle: node.handle.clone(),
14482                        tokens: traversal_node_tokens(&node),
14483                        node: node.clone(),
14484                    };
14485                    graph.add_node(node.clone());
14486                    if let Some(file_handle) = file_handle_by_path.get(&file) {
14487                        graph.add_edge(
14488                            file_handle,
14489                            &node.handle,
14490                            "defines",
14491                            Some("file defines symbol".to_string()),
14492                            1,
14493                        );
14494                    }
14495                    if !source_by_file.contains_key(&symbol.file) {
14496                        let source_path =
14497                            traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
14498                        source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
14499                    }
14500                    if let Some(Some(source)) = source_by_file.get(&symbol.file)
14501                        && let Some((ast_node, mut ast_entry)) =
14502                            traversal_ast_span_node(
14503                                root,
14504                                symbol,
14505                                source,
14506                                symbols_by_file
14507                                    .get(&symbol.file)
14508                                    .map(Vec::as_slice)
14509                                    .unwrap_or(&[]),
14510                            )
14511                    {
14512                        ast_entry.symbol_handle = node.handle.clone();
14513                        ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
14514                        span_by_file_name_line.insert(
14515                            format!("{file}:{}:{}", symbol.line, symbol.name),
14516                            ast_node.handle.clone(),
14517                        );
14518                        first_span_by_name
14519                            .entry(symbol.name.clone())
14520                            .or_insert_with(|| ast_node.handle.clone());
14521                        graph.add_node(ast_node.clone());
14522                        graph.add_edge(
14523                            &node.handle,
14524                            &ast_node.handle,
14525                            "has_ast_span",
14526                            Some("symbol projects to indexed AST span".to_string()),
14527                            1,
14528                        );
14529                        graph.add_edge(
14530                            &ast_node.handle,
14531                            &node.handle,
14532                            "represents_symbol",
14533                            Some("AST span represents indexed symbol".to_string()),
14534                            1,
14535                        );
14536                        ast_entries.push(ast_entry);
14537                    }
14538                    symbol_entries.push(entry);
14539                }
14540                link_ast_navigation_edges(&mut graph, &ast_entries);
14541                link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
14542
14543                if !bounded_session_projection {
14544                    for edge in db.all_stored_edges()? {
14545                        if traversal_path_is_generated_artifact(
14546                            root,
14547                            &gate_source_root,
14548                            Path::new(&edge.caller_file),
14549                        ) {
14550                            continue;
14551                        }
14552                        let caller_file = relativize(&edge.caller_file, root);
14553                        let caller_key =
14554                            format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
14555                        let Some(caller_handle) =
14556                            symbol_by_file_name_line.get(&caller_key).cloned()
14557                        else {
14558                            continue;
14559                        };
14560                        let callee_handle = if let Some(handle) =
14561                            first_symbol_by_name.get(&edge.callee_name)
14562                        {
14563                            handle.clone()
14564                        } else {
14565                            let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
14566                            let handle = node.handle.clone();
14567                            graph.add_node(node);
14568                            handle
14569                        };
14570                        graph.add_edge(
14571                            &caller_handle,
14572                            &callee_handle,
14573                            "calls",
14574                            Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
14575                            1,
14576                        );
14577                        if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
14578                            && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
14579                        {
14580                            graph.add_edge(
14581                                caller_span,
14582                                callee_span,
14583                                "calls",
14584                                Some(format!(
14585                                    "AST call site {}:{}",
14586                                    caller_file, edge.call_site_line
14587                                )),
14588                                1,
14589                            );
14590                        }
14591                    }
14592                }
14593
14594                for route in db.all_routes()? {
14595                    if traversal_path_is_generated_artifact(
14596                        root,
14597                        &gate_source_root,
14598                        Path::new(&route.file),
14599                    ) {
14600                        continue;
14601                    }
14602                    let node = traversal_route_node(root, &route);
14603                    let entry = TraversalRouteIndexEntry {
14604                        handle: node.handle.clone(),
14605                        tokens: traversal_node_tokens(&node),
14606                        node: node.clone(),
14607                    };
14608                    graph.add_node(node.clone());
14609                    if let Some(path) = node.path.as_ref()
14610                        && let Some(file_handle) = file_handle_by_path.get(path)
14611                    {
14612                        graph.add_edge(
14613                            file_handle,
14614                            &node.handle,
14615                            "defines_route",
14616                            Some("file declares route".to_string()),
14617                            1,
14618                        );
14619                    }
14620                    let handler_handle =
14621                        if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
14622                            handle.clone()
14623                        } else {
14624                            let node = traversal_unresolved_symbol_node(root, &route.handler_name);
14625                            let handle = node.handle.clone();
14626                            graph.add_node(node);
14627                            handle
14628                        };
14629                    graph.add_edge(
14630                        &entry.handle,
14631                        &handler_handle,
14632                        "handled_by",
14633                        Some("route handler reference".to_string()),
14634                        1,
14635                    );
14636                    if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
14637                        graph.add_edge(
14638                            &entry.handle,
14639                            handler_span,
14640                            "handled_by",
14641                            Some("route handler AST span".to_string()),
14642                            1,
14643                        );
14644                        graph.add_edge(
14645                            handler_span,
14646                            &entry.handle,
14647                            "handles_route",
14648                            Some("AST span handles route".to_string()),
14649                            1,
14650                        );
14651                    }
14652                    route_entries.push(entry);
14653                }
14654            }
14655            _ => {
14656                add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
14657                    .with_context(|| {
14658                    format!(
14659                        "loading raw source fallback nodes from {}",
14660                        gate_source_root.display()
14661                    )
14662                })?;
14663                for entry in &file_entries {
14664                    if let Some(path) = entry.node.path.as_ref() {
14665                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
14666                    }
14667                }
14668            }
14669        }
14670        load_multiplicity_traversal_nodes(
14671            root,
14672            &gate_source_root,
14673            &mut graph,
14674            &file_handle_by_path,
14675            &mut multiplicity_entries,
14676        )?;
14677    }
14678
14679    let code_lookup = TraversalCodeLookup::new(
14680        &symbol_entries,
14681        &file_entries,
14682        &route_entries,
14683        &multiplicity_entries,
14684    );
14685    load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
14686    Ok(graph)
14687}
14688
14689#[cfg(test)]
14690fn build_traversal_graph_source(
14691    root: &Path,
14692    path_hint: &Path,
14693    scope: Option<&str>,
14694) -> Result<TraversalGraphBuild> {
14695    build_traversal_graph_source_with_options(root, path_hint, scope, false)
14696}
14697
14698/// Bounded acquire deadline for the graph-db cross-process write lock. flock
14699/// releases automatically if the holder dies, so a live writer is the only thing
14700/// that can hold this; the bound serializes brief contention and fails closed
14701/// with a clear diagnostic on a wedged holder rather than hanging forever.
14702const GRAPH_DB_WRITE_LOCK_TIMEOUT: Duration = Duration::from_secs(15);
14703const GRAPH_DB_WRITE_LOCK_POLL: Duration = Duration::from_millis(50);
14704
14705/// RAII guard for the graph-db advisory write lock; unlocks on drop.
14706pub(crate) struct GraphDbWriteLock {
14707    file: std::fs::File,
14708}
14709
14710impl Drop for GraphDbWriteLock {
14711    fn drop(&mut self) {
14712        let _ = fs4::fs_std::FileExt::unlock(&self.file);
14713    }
14714}
14715
14716pub(crate) fn graph_db_write_lock_path(graph_db: &Path) -> PathBuf {
14717    let stem = graph_db
14718        .file_stem()
14719        .and_then(|stem| stem.to_str())
14720        .unwrap_or("graph");
14721    graph_db.with_file_name(format!("{stem}.write.lock"))
14722}
14723
14724/// Acquire the cross-process advisory write lock guarding graph-db refresh/write
14725/// and snapshot-import against concurrent agent processes. SQLite's busy_timeout
14726/// alone left the write transaction and the snapshot-import rename window racing
14727/// concurrent writers (#gdbwritelock): a refresh could create `-wal`/`-shm`
14728/// sidecars mid-rename and corrupt the freshly imported db, and parallel
14729/// refreshes spuriously failed `database is locked`. This serializes them.
14730pub(crate) fn acquire_graph_db_write_lock(graph_db: &Path) -> Result<GraphDbWriteLock> {
14731    acquire_graph_db_write_lock_with_timeout(graph_db, GRAPH_DB_WRITE_LOCK_TIMEOUT)
14732}
14733
14734pub(crate) fn acquire_graph_db_write_lock_with_timeout(
14735    graph_db: &Path,
14736    timeout: Duration,
14737) -> Result<GraphDbWriteLock> {
14738    use fs4::fs_std::FileExt;
14739
14740    let lock_path = graph_db_write_lock_path(graph_db);
14741    if let Some(parent) = lock_path.parent() {
14742        fs::create_dir_all(parent)
14743            .with_context(|| format!("creating graph-db lock dir: {}", parent.display()))?;
14744    }
14745    let file = std::fs::OpenOptions::new()
14746        .read(true)
14747        .write(true)
14748        .create(true)
14749        .truncate(false)
14750        .open(&lock_path)
14751        .with_context(|| format!("opening graph-db write lock {}", lock_path.display()))?;
14752
14753    let deadline = Instant::now() + timeout;
14754    loop {
14755        match file.try_lock_exclusive() {
14756            Ok(true) => return Ok(GraphDbWriteLock { file }),
14757            Ok(false) => {
14758                if Instant::now() >= deadline {
14759                    bail!(
14760                        "another tsift graph-db writer is active for {} (lock: {}); \
14761                         a concurrent graph-db refresh or snapshot-import is in progress, \
14762                         wait for it to finish before retrying",
14763                        graph_db.display(),
14764                        lock_path.display()
14765                    );
14766                }
14767                std::thread::sleep(GRAPH_DB_WRITE_LOCK_POLL);
14768            }
14769            Err(err) => {
14770                return Err(err).with_context(|| {
14771                    format!("locking graph-db write lock {}", lock_path.display())
14772                });
14773            }
14774        }
14775    }
14776}
14777
14778pub(crate) fn write_traversal_graph_store_with_options(
14779    root: &Path,
14780    path_hint: &Path,
14781    scope: Option<&str>,
14782    session_only: bool,
14783) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14784    let source_graph =
14785        build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14786    let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14787    let graph_db = graph_substrate_db_path(root, scope);
14788    // Serialize the write against concurrent refresh/snapshot-import (#gdbwritelock).
14789    let _write_lock = acquire_graph_db_write_lock(&graph_db)?;
14790    let mut store = SqliteGraphStore::open(&graph_db)?;
14791    let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14792        .ok()
14793        .flatten()
14794        .or_else(|| graph_projection_content_hash(&projection));
14795    let refresh = store.replace_projection_with_version(
14796        scope.unwrap_or("root"),
14797        &projection,
14798        Some(GRAPH_PROJECTION_VERSION),
14799        source_watermark,
14800    )?;
14801    Ok((source_graph, refresh))
14802}
14803
14804pub(crate) fn write_traversal_graph_store(
14805    root: &Path,
14806    path_hint: &Path,
14807    scope: Option<&str>,
14808) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14809    write_traversal_graph_store_with_options(root, path_hint, scope, false)
14810}
14811
14812fn refresh_traversal_graph_store_with_options(
14813    root: &Path,
14814    path_hint: &Path,
14815    scope: Option<&str>,
14816    session_only: bool,
14817) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14818    let (source_graph, refresh) =
14819        write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14820    let graph_db = graph_substrate_db_path(root, scope);
14821    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14822    let mut graph = traversal_graph_from_store(root, &store)?;
14823    graph.warnings = source_graph.warnings;
14824    Ok((graph, refresh))
14825}
14826
14827fn refresh_traversal_graph_store(
14828    root: &Path,
14829    path_hint: &Path,
14830    scope: Option<&str>,
14831) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14832    refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14833}
14834
14835pub(crate) fn build_traversal_graph(
14836    root: &Path,
14837    path_hint: &Path,
14838    scope: Option<&str>,
14839) -> Result<TraversalGraphBuild> {
14840    let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14841    Ok(graph)
14842}
14843
14844fn traversal_query_kind_priority(kind: &str) -> usize {
14845    match kind {
14846        "backlog" => 0,
14847        "job_packet" => 1,
14848        "worker_result" => 2,
14849        "symbol" => 3,
14850        "ast_span" => 4,
14851        "file" => 5,
14852        "route" => 6,
14853        "cargo_package" => 7,
14854        "cargo_workspace" => 8,
14855        "session" => 9,
14856        "semantic_concept" => 10,
14857        "semantic_entity" => 11,
14858        _ => 12,
14859    }
14860}
14861
14862fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14863    let trimmed = query.trim();
14864    if trimmed.is_empty() {
14865        return None;
14866    }
14867    let kind_priority = traversal_query_kind_priority(&node.kind);
14868    if node.handle == trimmed {
14869        return Some((0, kind_priority, node.handle.clone()));
14870    }
14871    if node.path.as_deref() == Some(trimmed) {
14872        let path_priority = if node.kind == "file" {
14873            0
14874        } else {
14875            kind_priority.saturating_add(1)
14876        };
14877        return Some((1, path_priority, node.handle.clone()));
14878    }
14879    let normalized_backlog = trimmed.trim_start_matches('#');
14880    if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14881    {
14882        return Some((2, kind_priority, node.handle.clone()));
14883    }
14884    if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14885        return Some((3, kind_priority, node.handle.clone()));
14886    }
14887    None
14888}
14889
14890fn resolve_traversal_node<'a>(
14891    graph: &'a TraversalGraphBuild,
14892    query: &str,
14893) -> Option<&'a TraversalNode> {
14894    graph
14895        .nodes
14896        .values()
14897        .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14898        .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14899        .map(|(_, node)| node)
14900}
14901
14902fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14903    let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14904    for edge in edges {
14905        adj.entry(edge.from.clone())
14906            .or_default()
14907            .insert(edge.to.clone());
14908        adj.entry(edge.to.clone())
14909            .or_default()
14910            .insert(edge.from.clone());
14911    }
14912    adj.into_iter()
14913        .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14914        .collect()
14915}
14916
14917fn traversal_shortest_handles(
14918    edges: &[TraversalEdge],
14919    from: &str,
14920    to: &str,
14921) -> Option<Vec<String>> {
14922    if from == to {
14923        return Some(vec![from.to_string()]);
14924    }
14925    let adj = traversal_adjacency(edges);
14926    if !adj.contains_key(from) || !adj.contains_key(to) {
14927        return None;
14928    }
14929    let mut visited = BTreeSet::new();
14930    let mut queue = VecDeque::new();
14931    let mut parent = BTreeMap::<String, String>::new();
14932    visited.insert(from.to_string());
14933    queue.push_back(from.to_string());
14934    while let Some(current) = queue.pop_front() {
14935        if let Some(neighbors) = adj.get(&current) {
14936            for neighbor in neighbors {
14937                if visited.insert(neighbor.clone()) {
14938                    parent.insert(neighbor.clone(), current.clone());
14939                    if neighbor == to {
14940                        let mut path = vec![to.to_string()];
14941                        let mut cursor = to.to_string();
14942                        while let Some(prev) = parent.get(&cursor) {
14943                            path.push(prev.clone());
14944                            cursor = prev.clone();
14945                        }
14946                        path.reverse();
14947                        return Some(path);
14948                    }
14949                    queue.push_back(neighbor.clone());
14950                }
14951            }
14952        }
14953    }
14954    None
14955}
14956
14957fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14958    let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14959    for edge in edges {
14960        let neighbor = if edge.from == current {
14961            edge.to.as_str()
14962        } else if edge.to == current {
14963            edge.from.as_str()
14964        } else {
14965            continue;
14966        };
14967        let score = traversal_relation_score(edge, current);
14968        best_score_by_neighbor
14969            .entry(neighbor.to_string())
14970            .and_modify(|best| *best = (*best).max(score))
14971            .or_insert(score);
14972    }
14973    let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14974    ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14975        right_score
14976            .cmp(left_score)
14977            .then_with(|| left_handle.cmp(right_handle))
14978    });
14979    ranked.into_iter().map(|(handle, _)| handle).collect()
14980}
14981
14982fn traversal_neighborhood_handles(
14983    edges: &[TraversalEdge],
14984    origin: &str,
14985    depth: usize,
14986    limit: usize,
14987) -> BTreeSet<String> {
14988    let mut seen = BTreeSet::new();
14989    let mut queue = VecDeque::new();
14990    seen.insert(origin.to_string());
14991    queue.push_back((origin.to_string(), 0usize));
14992    while let Some((current, current_depth)) = queue.pop_front() {
14993        if current_depth >= depth {
14994            continue;
14995        }
14996        for neighbor in traversal_scored_neighbors(edges, &current) {
14997            if limit > 0 && seen.len() >= limit {
14998                return seen;
14999            }
15000            if seen.insert(neighbor.clone()) {
15001                queue.push_back((neighbor, current_depth + 1));
15002            }
15003        }
15004    }
15005    seen
15006}
15007
15008fn traversal_edges_between(
15009    handles: &BTreeSet<String>,
15010    edges: &[TraversalEdge],
15011) -> Vec<TraversalEdge> {
15012    edges
15013        .iter()
15014        .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
15015        .cloned()
15016        .collect()
15017}
15018
15019fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
15020    let mut result = Vec::new();
15021    for pair in path.windows(2) {
15022        if let Some(edge) = edges.iter().find(|edge| {
15023            (edge.from == pair[0] && edge.to == pair[1])
15024                || (edge.from == pair[1] && edge.to == pair[0])
15025        }) {
15026            result.push(edge.clone());
15027        }
15028    }
15029    result
15030}
15031
15032fn sorted_traversal_nodes<'a>(
15033    nodes: impl IntoIterator<Item = &'a TraversalNode>,
15034) -> Vec<TraversalNode> {
15035    let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
15036    nodes.sort_by(|left, right| {
15037        left.kind
15038            .cmp(&right.kind)
15039            .then_with(|| left.label.cmp(&right.label))
15040            .then_with(|| left.path.cmp(&right.path))
15041            .then_with(|| left.handle.cmp(&right.handle))
15042    });
15043    nodes
15044}
15045
15046fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
15047    let base = match edge.relation.as_str() {
15048        "mentions" => 100,
15049        "contains" => 80,
15050        "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
15051        "contains_embedded_symbol" | "embedded_in_fence" => 77,
15052        "contains_markdown_block"
15053        | "contains_embedded_code"
15054        | "enclosing_module"
15055        | "enclosing_section" => 76,
15056        "calls" => {
15057            if edge.from == origin {
15058                70
15059            } else {
15060                65
15061            }
15062        }
15063        "handled_by" | "handles_route" => 68,
15064        "defines_route" => 62,
15065        "imports" => 62,
15066        "previous_sibling" | "next_sibling" => 54,
15067        "mentions_concept" | "mentions_entity" => 66,
15068        "semantic_relation" => 64,
15069        "tagged_concept" | "related_concept" => 58,
15070        "defines" => {
15071            if edge.from == origin {
15072                60
15073            } else {
15074                55
15075            }
15076        }
15077        _ => 10,
15078    };
15079    base + edge.weight
15080}
15081
15082fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
15083    match edge.relation.as_str() {
15084        "mentions" => "matched from backlog/session text".to_string(),
15085        "contains" => "contained in the selected session artifact".to_string(),
15086        "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
15087        "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
15088        "parent" => "parent AST span".to_string(),
15089        "child" => "child AST span".to_string(),
15090        "previous_sibling" => "previous AST sibling".to_string(),
15091        "next_sibling" => "next AST sibling".to_string(),
15092        "contains_markdown_block" => "Markdown section block".to_string(),
15093        "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
15094        "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
15095        "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
15096        "enclosing_module" => "nearest enclosing module".to_string(),
15097        "enclosing_section" => "nearest enclosing Markdown section".to_string(),
15098        "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
15099        "defines" => "file that defines the selected symbol".to_string(),
15100        "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
15101        "defines_route" => "file that declares the selected route".to_string(),
15102        "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
15103        "handled_by" => "route handled by the selected symbol".to_string(),
15104        "handles_route" => "route handled by the selected AST span".to_string(),
15105        "imports" => "import dependency from the selected package".to_string(),
15106        "mentions_concept" => "cached summary concept for the selected source".to_string(),
15107        "mentions_entity" => "cached summary entity for the selected source".to_string(),
15108        "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
15109        "tagged_concept" => "concept label attached to the selected entity".to_string(),
15110        "related_concept" => "co-occurring cached summary concept".to_string(),
15111        "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
15112        "calls" => "caller of the selected symbol".to_string(),
15113        other => format!("connected by {other}"),
15114    }
15115}
15116
15117fn traversal_recommendations(
15118    graph: &TraversalGraphBuild,
15119    origin: Option<&str>,
15120    shortest_path: Option<&[String]>,
15121    limit: usize,
15122) -> Vec<TraversalRecommendation> {
15123    let Some(origin) = origin else {
15124        return Vec::new();
15125    };
15126    let mut recommendations = Vec::new();
15127    let mut seen = BTreeSet::new();
15128
15129    if let Some(path) = shortest_path
15130        && path.len() > 1
15131        && path.first().is_some_and(|handle| handle == origin)
15132        && let Some(next) = graph.nodes.get(&path[1])
15133    {
15134        seen.insert(next.handle.clone());
15135        recommendations.push(TraversalRecommendation {
15136            handle: next.handle.clone(),
15137            kind: next.kind.clone(),
15138            label: next.label.clone(),
15139            reason: "next hop on shortest path".to_string(),
15140            score: 1_000,
15141            expand: next.expand.clone(),
15142        });
15143    }
15144
15145    let mut candidates = graph
15146        .edges
15147        .iter()
15148        .filter_map(|edge| {
15149            let neighbor = if edge.from == origin {
15150                edge.to.as_str()
15151            } else if edge.to == origin {
15152                edge.from.as_str()
15153            } else {
15154                return None;
15155            };
15156            let node = graph.nodes.get(neighbor)?;
15157            Some((traversal_relation_score(edge, origin), edge, node))
15158        })
15159        .collect::<Vec<_>>();
15160    candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
15161        right_score
15162            .cmp(left_score)
15163            .then_with(|| left.kind.cmp(&right.kind))
15164            .then_with(|| left.label.cmp(&right.label))
15165            .then_with(|| left.handle.cmp(&right.handle))
15166    });
15167
15168    let max = if limit == 0 { usize::MAX } else { limit };
15169    for (score, edge, node) in candidates {
15170        if recommendations.len() >= max {
15171            break;
15172        }
15173        if seen.insert(node.handle.clone()) {
15174            recommendations.push(TraversalRecommendation {
15175                handle: node.handle.clone(),
15176                kind: node.kind.clone(),
15177                label: node.label.clone(),
15178                reason: traversal_recommendation_reason(edge, origin),
15179                score,
15180                expand: node.expand.clone(),
15181            });
15182        }
15183    }
15184
15185    recommendations
15186}
15187
15188fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
15189    let scale = nodes.saturating_add(edges);
15190    if scale <= 80 {
15191        ExplorationBudget {
15192            project_size: "small".to_string(),
15193            max_source_windows: 8,
15194            lines_per_window: 96,
15195            relationship_limit: 40,
15196        }
15197    } else if scale <= 800 {
15198        ExplorationBudget {
15199            project_size: "medium".to_string(),
15200            max_source_windows: 6,
15201            lines_per_window: 80,
15202            relationship_limit: 32,
15203        }
15204    } else {
15205        ExplorationBudget {
15206            project_size: "large".to_string(),
15207            max_source_windows: 4,
15208            lines_per_window: 64,
15209            relationship_limit: 24,
15210        }
15211    }
15212}
15213
15214fn exploration_node_label(node: &TraversalNode) -> String {
15215    format!("{}:{}", node.kind, node.label)
15216}
15217
15218fn exploration_source_window_for_node(
15219    root: &Path,
15220    node: &TraversalNode,
15221    budget: &ExplorationBudget,
15222) -> Option<ExplorationSourceWindow> {
15223    let file = node.path.as_ref()?;
15224    let anchor = node
15225        .line
15226        .and_then(|line| usize::try_from(line).ok())
15227        .and_then(|line| line.checked_add(1))
15228        .unwrap_or(1);
15229    let context_before = budget.lines_per_window / 3;
15230    let start = anchor.saturating_sub(context_before).max(1);
15231    let end = start
15232        .saturating_add(budget.lines_per_window)
15233        .saturating_sub(1);
15234    let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
15235    Some(ExplorationSourceWindow {
15236        handle,
15237        file: file.clone(),
15238        start,
15239        end,
15240        reason: format!("cluster around {}", exploration_node_label(node)),
15241        expand: source_read_command(root, file, start, budget.lines_per_window),
15242    })
15243}
15244
15245fn build_exploration_packet(
15246    root: &Path,
15247    totals: &TraversalTotals,
15248    selected_nodes: &[TraversalNode],
15249    selected_edges: &[TraversalEdge],
15250) -> ExplorationPacket {
15251    let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
15252    let node_by_handle = selected_nodes
15253        .iter()
15254        .map(|node| (node.handle.as_str(), node))
15255        .collect::<BTreeMap<_, _>>();
15256    let relationship_map = selected_edges
15257        .iter()
15258        .take(budget.relationship_limit)
15259        .filter_map(|edge| {
15260            let from = node_by_handle.get(edge.from.as_str())?;
15261            let to = node_by_handle.get(edge.to.as_str())?;
15262            Some(ExplorationRelation {
15263                from: exploration_node_label(from),
15264                relation: edge.relation.clone(),
15265                to: exploration_node_label(to),
15266                label: edge.label.clone(),
15267            })
15268        })
15269        .collect::<Vec<_>>();
15270
15271    let mut seen_windows = BTreeSet::new();
15272    let mut source_windows = Vec::new();
15273    for node in selected_nodes {
15274        if source_windows.len() >= budget.max_source_windows {
15275            break;
15276        }
15277        let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
15278            continue;
15279        };
15280        let key = (window.file.clone(), window.start, window.end);
15281        if seen_windows.insert(key) {
15282            source_windows.push(window);
15283        }
15284    }
15285
15286    ExplorationPacket {
15287        budget,
15288        relationship_map,
15289        source_windows,
15290        worker_context: Vec::new(),
15291        no_reread_guidance:
15292            "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
15293                .to_string(),
15294    }
15295}
15296
15297pub(crate) fn traversal_report(
15298    root: &Path,
15299    scope: Option<&str>,
15300    graph: TraversalGraphBuild,
15301    query: Option<&str>,
15302    target: Option<&str>,
15303    depth: usize,
15304    limit: usize,
15305) -> Result<TraversalReport> {
15306    let totals = TraversalTotals {
15307        nodes: graph.nodes.len(),
15308        edges: graph.edges.len(),
15309    };
15310    let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
15311    let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
15312    if let Some(query) = query
15313        && origin_node.is_none()
15314    {
15315        bail!("traversal node not found: {}", query);
15316    }
15317    if let Some(target) = target
15318        && target_node.is_none()
15319    {
15320        bail!("traversal target not found: {}", target);
15321    }
15322
15323    let (mode, selected_nodes, selected_edges, shortest_path) =
15324        if let (Some(origin), Some(target)) = (origin_node, target_node) {
15325            if let Some(handles) =
15326                traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
15327            {
15328                let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
15329                let nodes = handles
15330                    .iter()
15331                    .filter_map(|handle| graph.nodes.get(handle).cloned())
15332                    .collect::<Vec<_>>();
15333                let edges = traversal_path_edges(&handles, &graph.edges);
15334                let path = TraversalPathReport {
15335                    from: origin.clone(),
15336                    to: target.clone(),
15337                    hops: handles.len().saturating_sub(1),
15338                    nodes: nodes.clone(),
15339                    edges: edges.clone(),
15340                };
15341                (
15342                    "path".to_string(),
15343                    nodes,
15344                    traversal_edges_between(&handle_set, &graph.edges),
15345                    Some(path),
15346                )
15347            } else {
15348                (
15349                    "path".to_string(),
15350                    vec![origin.clone(), target.clone()],
15351                    Vec::new(),
15352                    None,
15353                )
15354            }
15355        } else if let Some(origin) = origin_node {
15356            let handles =
15357                traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
15358            let nodes =
15359                sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
15360            let edges = traversal_edges_between(&handles, &graph.edges);
15361            ("neighborhood".to_string(), nodes, edges, None)
15362        } else {
15363            let mut nodes = sorted_traversal_nodes(graph.nodes.values());
15364            let truncated_nodes = limit > 0 && nodes.len() > limit;
15365            if truncated_nodes {
15366                nodes.truncate(limit);
15367            }
15368            let handles = nodes
15369                .iter()
15370                .map(|node| node.handle.clone())
15371                .collect::<BTreeSet<_>>();
15372            let mut edges = traversal_edges_between(&handles, &graph.edges);
15373            let truncated_edges = limit > 0 && edges.len() > limit;
15374            if truncated_edges {
15375                edges.truncate(limit);
15376            }
15377            ("export".to_string(), nodes, edges, None)
15378        };
15379
15380    let shortest_handles = shortest_path.as_ref().map(|path| {
15381        path.nodes
15382            .iter()
15383            .map(|node| node.handle.clone())
15384            .collect::<Vec<_>>()
15385    });
15386    let recommendations = traversal_recommendations(
15387        &graph,
15388        origin_node.map(|node| node.handle.as_str()),
15389        shortest_handles.as_deref(),
15390        if limit == 0 { 10 } else { limit.min(10) },
15391    );
15392    let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
15393    let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
15394
15395    Ok(TraversalReport {
15396        root: root.to_string_lossy().to_string(),
15397        scope: scope.map(str::to_string),
15398        mode,
15399        totals,
15400        query: query.map(str::to_string),
15401        target: target.map(str::to_string),
15402        nodes: selected_nodes,
15403        edges: selected_edges,
15404        shortest_path,
15405        recommendations,
15406        exploration,
15407        truncated,
15408        warnings: graph.warnings,
15409    })
15410}
15411
15412fn html_escape(input: &str) -> String {
15413    input
15414        .replace('&', "&amp;")
15415        .replace('<', "&lt;")
15416        .replace('>', "&gt;")
15417        .replace('"', "&quot;")
15418        .replace('\'', "&#39;")
15419}
15420
15421pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
15422    let json = serde_json::to_string(report)?.replace("</", "<\\/");
15423    let mut html = String::new();
15424    html.push_str(
15425        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
15426    );
15427    html.push_str(
15428        r#"<style>
15429:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
15430@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
15431*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif;line-height:1.4}.page{max-width:1280px;margin:0 auto;padding:20px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:14px}.top h1{font-size:22px;margin:0}.meta{color:var(--muted);font-size:13px}.toolbar{display:flex;gap:8px;align-items:center}.toolbar input{min-width:220px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--text);padding:8px 10px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:14px;min-height:650px}.graph-panel,.side{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.graph-panel{position:relative}.legend{position:absolute;left:12px;top:12px;display:flex;flex-wrap:wrap;gap:6px;max-width:calc(100% - 24px)}.legend span{font-size:12px;background:color-mix(in srgb,var(--panel) 86%,transparent);border:1px solid var(--line);border-radius:999px;padding:4px 8px}.side{padding:14px;overflow:auto}.side h2{font-size:15px;margin:0 0 8px}.selected{border-top:1px solid var(--line);margin-top:12px;padding-top:12px}.list{display:grid;gap:8px}.row{border:1px solid var(--line);border-radius:6px;padding:8px;cursor:pointer}.row:hover{border-color:var(--accent)}.kind{font-size:11px;text-transform:uppercase;color:var(--muted);letter-spacing:.04em}.label{font-weight:650;overflow-wrap:anywhere}.handle,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted)}svg{width:100%;height:650px;display:block}.edge{stroke:var(--edge);stroke-width:1.4;opacity:.72}.edge.semantic{stroke:var(--semantic);stroke-width:1.8}.node{stroke:var(--panel);stroke-width:2;cursor:pointer}.node.semantic{stroke:var(--semantic);stroke-width:2.5}.node-label{font-size:12px;paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round;fill:var(--text);pointer-events:none}.hidden{display:none}@media(max-width:900px){.top{display:block}.toolbar{margin-top:12px}.layout{grid-template-columns:1fr}.side{max-height:360px}svg{height:560px}}
15432</style>"#,
15433    );
15434    html.push_str("</head><body>");
15435    html.push_str("<div class=\"page\">");
15436    html.push_str(&format!(
15437        "<header class=\"top\"><div><h1>tsift traversal graph</h1><div class=\"meta\">mode <code>{}</code> | nodes <code>{}</code>/<code>{}</code> | edges <code>{}</code>/<code>{}</code></div></div><div class=\"toolbar\"><input id=\"filter\" type=\"search\" placeholder=\"Filter nodes\"></div></header>",
15438        html_escape(&report.mode),
15439        report.nodes.len(),
15440        report.totals.nodes,
15441        report.edges.len(),
15442        report.totals.edges
15443    ));
15444    html.push_str(
15445        r#"<main class="layout"><section class="graph-panel"><div id="legend" class="legend"></div><svg id="graph-canvas" role="img" aria-label="Traversal graph"></svg></section><aside class="side"><h2>Nodes</h2><div id="node-list" class="list"></div><div id="selected" class="selected"></div></aside></main>"#,
15446    );
15447    html.push_str("<script id=\"graph-data\" type=\"application/json\">");
15448    html.push_str(&json);
15449    html.push_str(
15450        r##"</script><script>
15451const report = JSON.parse(document.getElementById("graph-data").textContent);
15452const svg = document.getElementById("graph-canvas");
15453const list = document.getElementById("node-list");
15454const selected = document.getElementById("selected");
15455const filter = document.getElementById("filter");
15456const legend = document.getElementById("legend");
15457const nodes = report.nodes.map((node, index) => ({...node, index}));
15458const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
15459const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
15460const colorByKind = new Map([
15461  ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
15462  ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
15463  ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
15464  ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
15465]);
15466function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
15467function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
15468function text(value){ return value == null ? "" : String(value); }
15469function matches(node, query){
15470  if (!query) return true;
15471  const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
15472  return haystack.includes(query);
15473}
15474function layout(){
15475  const rect = svg.getBoundingClientRect();
15476  const width = rect.width || 900;
15477  const height = rect.height || 650;
15478  const cx = width / 2;
15479  const cy = height / 2;
15480  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15481  const counts = new Map();
15482  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
15483  const offsets = new Map();
15484  for (const node of nodes) {
15485    const group = kinds.indexOf(node.kind);
15486    const index = offsets.get(node.kind) || 0;
15487    offsets.set(node.kind, index + 1);
15488    const groupCount = counts.get(node.kind) || 1;
15489    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
15490    const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
15491    node.x = cx + Math.cos(angle) * ring;
15492    node.y = cy + Math.sin(angle) * ring;
15493  }
15494}
15495function draw(){
15496  const query = filter.value.trim().toLowerCase();
15497  const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
15498  svg.innerHTML = "";
15499  for (const edge of edges) {
15500    if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
15501    const from = nodeByHandle.get(edge.from);
15502    const to = nodeByHandle.get(edge.to);
15503    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
15504    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
15505    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
15506    line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
15507    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
15508    svg.appendChild(line);
15509  }
15510  for (const node of nodes) {
15511    if (!visible.has(node.handle)) continue;
15512    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
15513    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
15514    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
15515    circle.setAttribute("fill", color(node.kind));
15516    circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
15517    circle.addEventListener("click", () => selectNode(node));
15518    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
15519    svg.appendChild(circle);
15520    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
15521    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
15522    label.setAttribute("class", "node-label");
15523    label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
15524    svg.appendChild(label);
15525  }
15526  renderList(query);
15527}
15528function renderLegend(){
15529  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15530  legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">&#9679;</b> ${kind}</span>`).join("");
15531}
15532function renderList(query){
15533  const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
15534  list.innerHTML = rows.map(node => `<div class="row" data-handle="${node.handle}"><div class="kind">${node.kind}</div><div class="label">${escapeHtml(node.label)}</div><div class="handle">${node.handle}</div></div>`).join("");
15535  for (const row of list.querySelectorAll(".row")) {
15536    row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
15537  }
15538}
15539function selectNode(node){
15540  const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
15541  selected.innerHTML = `<h2>${escapeHtml(node.label)}</h2><div class="kind">${node.kind}</div><p class="handle">${node.handle}</p>${node.path ? `<p>${escapeHtml(node.path)}${node.line != null ? ":" + node.line : ""}</p>` : ""}${node.detail ? `<p>${escapeHtml(node.detail)}</p>` : ""}<p><code>${escapeHtml(node.expand)}</code></p><h2>Edges</h2><div class="list">${adjacent.map(edge => `<div class="row"><div class="kind">${edge.relation}</div><div>${escapeHtml(edge.from)} -> ${escapeHtml(edge.to)}</div>${edge.label ? `<div>${escapeHtml(edge.label)}</div>` : ""}</div>`).join("") || "<div class=\"meta\">No visible edges.</div>"}</div>`;
15542}
15543function escapeHtml(value){
15544  return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));
15545}
15546filter.addEventListener("input", draw);
15547window.addEventListener("resize", () => { layout(); draw(); });
15548renderLegend();
15549layout();
15550draw();
15551if (nodes.length) selectNode(nodes[0]);
15552</script></div></body></html>"##,
15553    );
15554    Ok(html)
15555}
15556
15557fn semantic_related_report_from_store(
15558    root: &Path,
15559    scope: Option<&str>,
15560    query: &str,
15561    limit: usize,
15562    kind: SemanticRelatedKind,
15563    store: &impl GraphStore,
15564) -> Result<SemanticRelatedReport> {
15565    if query.trim().is_empty() {
15566        bail!("semantic query cannot be empty");
15567    }
15568
15569    let query_embedding = semantic_embedding(query);
15570    let node_kinds: &[&str] = match kind {
15571        SemanticRelatedKind::Concept => &["semantic_concept"],
15572        SemanticRelatedKind::Entity => &["semantic_entity"],
15573        SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
15574    };
15575
15576    let items = store
15577        .semantic_top_candidates(&query_embedding, node_kinds, limit)?
15578        .into_iter()
15579        .map(|candidate| {
15580            let node = candidate.node;
15581            SemanticRelatedItem {
15582                handle: node
15583                    .properties
15584                    .get("handle")
15585                    .cloned()
15586                    .unwrap_or_else(|| node.id.clone()),
15587                kind: node.kind,
15588                label: node.label,
15589                score: candidate.score,
15590                file_path: node
15591                    .properties
15592                    .get("source_file")
15593                    .or_else(|| node.properties.get("path"))
15594                    .cloned(),
15595                source_symbol: node.properties.get("source_symbol").cloned(),
15596                detail: node
15597                    .properties
15598                    .get("description")
15599                    .or_else(|| node.properties.get("detail"))
15600                    .cloned(),
15601                expand: node
15602                    .properties
15603                    .get("expand")
15604                    .cloned()
15605                    .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
15606            }
15607        })
15608        .collect::<Vec<_>>();
15609
15610    let mut warnings = Vec::new();
15611    if items.is_empty() {
15612        warnings.push(
15613            "no semantic graph rows found; run `tsift summarize --extract <path>` first"
15614                .to_string(),
15615        );
15616    }
15617
15618    Ok(SemanticRelatedReport {
15619        root: root.to_string_lossy().to_string(),
15620        scope: scope.map(str::to_string),
15621        query: query.to_string(),
15622        embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
15623        count: items.len(),
15624        items,
15625        warnings,
15626    })
15627}
15628
15629fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
15630    Ok(store.nodes_by_kind("semantic_concept")?.len()
15631        + store.nodes_by_kind("semantic_entity")?.len())
15632}
15633
15634fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
15635    if limit == 0 {
15636        return 0;
15637    }
15638    limit.saturating_mul(4).clamp(
15639        GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
15640        GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
15641    )
15642}
15643
15644fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
15645    if limit == 0 {
15646        return usize::MAX;
15647    }
15648    limit.saturating_mul(3).max(limit).max(seed_count)
15649}
15650
15651fn graph_db_semantic_seeded_neighborhood(
15652    store: &impl GraphStore,
15653    seed_ids: &[String],
15654    depth: usize,
15655    limit: usize,
15656) -> Result<GraphDbSemanticSeededSubgraph> {
15657    let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
15658    let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
15659    let mut diagnostics = vec![
15660        "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
15661        "seed expansion traverses both outgoing and incident edges so code, markdown, conversation, and memory adapters can link into semantic rows without reversing their edge direction".to_string(),
15662        format!(
15663            "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
15664            if edge_scan_cap == 0 {
15665                "unbounded".to_string()
15666            } else {
15667                edge_scan_cap.to_string()
15668            },
15669            if node_discovery_cap == usize::MAX {
15670                "unbounded".to_string()
15671            } else {
15672                node_discovery_cap.to_string()
15673            }
15674        ),
15675    ];
15676
15677    let options = SemanticSeededNeighborhoodOptions::new(depth, limit)
15678        .with_edge_scan_cap(edge_scan_cap)
15679        .with_node_discovery_cap(node_discovery_cap);
15680    let result = store.semantic_seeded_neighborhood(seed_ids, &options)?;
15681
15682    for seed_id in &result.missing_seed_ids {
15683        diagnostics.push(format!(
15684            "semantic seed {seed_id} was not present in the graph store"
15685        ));
15686    }
15687
15688    if result.skipped_by_edge_cap > 0 {
15689        diagnostics.push(format!(
15690            "semantic-seeded expansion skipped {} lower-scoring incident/outgoing edge(s) after per-node caps",
15691            result.skipped_by_edge_cap
15692        ));
15693    }
15694    if result.skipped_by_node_cap > 0 {
15695        diagnostics.push(format!(
15696            "semantic-seeded expansion skipped {} lower-scoring node discovery edge(s) after the discovery cap",
15697            result.skipped_by_node_cap
15698        ));
15699    }
15700
15701    if result.truncated {
15702        diagnostics.push(format!(
15703            "semantic-seeded neighborhood truncated from {} to {limit} node(s)",
15704            result.total_discovered
15705        ));
15706    }
15707
15708    Ok(GraphDbSemanticSeededSubgraph {
15709        nodes: result.nodes,
15710        edges: result.edges,
15711        truncated: result.truncated,
15712        diagnostics,
15713    })
15714}
15715
15716#[allow(clippy::too_many_arguments)]
15717fn cmd_semantic_related(
15718    query: &str,
15719    path: &Path,
15720    scope: Option<&str>,
15721    limit: usize,
15722    kind: SemanticRelatedKind,
15723    json_output: bool,
15724    compact: bool,
15725    pretty: bool,
15726    terse: bool,
15727    schema: bool,
15728    profile: Option<String>,
15729) -> Result<()> {
15730    let root = lint::resolve_project_root_or_canonical_path(path)?;
15731    write_traversal_graph_store(&root, path, scope)?;
15732    let graph_db = graph_substrate_db_path(&root, scope);
15733    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
15734    let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
15735    if let Some(recovery) = store.read_only_recovery() {
15736        report
15737            .warnings
15738            .push(graph_db_read_recovery_diagnostic(recovery));
15739    }
15740    if let Some(note) =
15741        profile_preference_note(profile.as_deref(), tsift_local_model::ModelRole::Embed)
15742    {
15743        report.warnings.push(note);
15744    }
15745
15746    if json_output {
15747        println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
15748    } else if compact {
15749        for item in &report.items {
15750            println!(
15751                "{:.3}\t{}\t{}\t{}",
15752                item.score, item.kind, item.label, item.handle
15753            );
15754        }
15755        for warning in &report.warnings {
15756            eprintln!("warning: {warning}");
15757        }
15758    } else {
15759        println!(
15760            "Related semantic graph rows for {:?} ({})",
15761            report.query, report.embedding_model
15762        );
15763        for item in &report.items {
15764            println!(
15765                "  {:.3} [{}] {} ({})",
15766                item.score, item.kind, item.label, item.handle
15767            );
15768            if let Some(detail) = &item.detail {
15769                println!("      {}", detail);
15770            }
15771            if let Some(file_path) = &item.file_path {
15772                println!("      file: {}", file_path);
15773            }
15774            println!("      expand: {}", item.expand);
15775        }
15776        for warning in &report.warnings {
15777            eprintln!("warning: {warning}");
15778        }
15779    }
15780
15781    Ok(())
15782}
15783
15784/// Resolve a `--profile` CLI value into a one-line note for the response
15785/// envelope. Records the caller's intent and the resolved profile id, so
15786/// downstream readers see what would have been used even while the actual
15787/// provider seam still falls back to the hash profile (#gctrl2).
15788fn profile_preference_note(
15789    profile: Option<&str>,
15790    role: tsift_local_model::ModelRole,
15791) -> Option<String> {
15792    let preference = tsift_local_model::ProfilePreference::from_cli(profile);
15793    if matches!(preference, tsift_local_model::ProfilePreference::Auto) {
15794        return None;
15795    }
15796    let probe = tsift_local_model::probe_nvidia_smi();
15797    let resolution = tsift_local_model::resolve_profile_preference(&preference, role, &probe);
15798    Some(format!(
15799        "profile preference {} -> {} ({})",
15800        preference.describe(),
15801        resolution.profile.id,
15802        resolution.reason
15803    ))
15804}
15805
15806#[derive(Serialize)]
15807struct SourceLinePreview {
15808    line: usize,
15809    text: String,
15810}
15811
15812#[derive(Serialize)]
15813pub(crate) struct SourceRangePreview {
15814    start: usize,
15815    end: usize,
15816    total_lines: usize,
15817    truncated_before: bool,
15818    truncated_after: bool,
15819}
15820
15821#[derive(Serialize)]
15822struct SourceExpandCommands {
15823    #[serde(skip_serializing_if = "Option::is_none")]
15824    before: Option<String>,
15825    #[serde(skip_serializing_if = "Option::is_none")]
15826    after: Option<String>,
15827    #[serde(skip_serializing_if = "Option::is_none")]
15828    body: Option<String>,
15829    file: String,
15830    #[serde(skip_serializing_if = "Option::is_none")]
15831    markdown_ast: Option<String>,
15832}
15833
15834#[derive(Serialize)]
15835struct SourceSymbolRef {
15836    handle: String,
15837    name: String,
15838    kind: String,
15839    language: String,
15840    file: String,
15841    line: usize,
15842    #[serde(skip_serializing_if = "Option::is_none")]
15843    end_line: Option<usize>,
15844    #[serde(skip_serializing_if = "Option::is_none")]
15845    signature: Option<String>,
15846    #[serde(skip_serializing_if = "Option::is_none")]
15847    span: Option<AstSpanPreview>,
15848    expand: String,
15849}
15850
15851#[derive(Serialize)]
15852struct SourceSummaryRef {
15853    handle: String,
15854    symbol_name: String,
15855    file_path: String,
15856    summary: String,
15857    expand: String,
15858}
15859
15860#[derive(Serialize)]
15861struct SourceReadReport {
15862    handle: String,
15863    root: String,
15864    file: String,
15865    range: SourceRangePreview,
15866    preview: Vec<SourceLinePreview>,
15867    symbols: Vec<SourceSymbolRef>,
15868    summaries: Vec<SourceSummaryRef>,
15869    #[serde(skip_serializing_if = "Option::is_none")]
15870    markdown: Option<SourceReadMarkdownProjection>,
15871    expand: SourceExpandCommands,
15872    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15873    warnings: Vec<String>,
15874}
15875
15876#[derive(Serialize)]
15877struct SourceReadAstExpandCommands {
15878    window: String,
15879    file_window: String,
15880    #[serde(skip_serializing_if = "Option::is_none")]
15881    markdown_ast: Option<String>,
15882}
15883
15884#[derive(Serialize)]
15885struct SourceReadAstReport {
15886    handle: String,
15887    root: String,
15888    file: String,
15889    range: SourceRangePreview,
15890    symbols: Vec<SourceSymbolRef>,
15891    summaries: Vec<SourceSummaryRef>,
15892    #[serde(skip_serializing_if = "Option::is_none")]
15893    markdown: Option<SourceReadMarkdownProjection>,
15894    expand: SourceReadAstExpandCommands,
15895    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15896    warnings: Vec<String>,
15897}
15898
15899#[derive(Serialize)]
15900struct SymbolReadTarget {
15901    handle: String,
15902    name: String,
15903    kind: String,
15904    language: String,
15905    file: String,
15906    line: usize,
15907    #[serde(skip_serializing_if = "Option::is_none")]
15908    end_line: Option<usize>,
15909    #[serde(skip_serializing_if = "Option::is_none")]
15910    signature: Option<String>,
15911    #[serde(skip_serializing_if = "Option::is_none")]
15912    parent_module: Option<String>,
15913    #[serde(skip_serializing_if = "Option::is_none")]
15914    visibility: Option<String>,
15915    #[serde(skip_serializing_if = "Option::is_none")]
15916    span: Option<AstSpanPreview>,
15917}
15918
15919#[derive(Serialize)]
15920struct SymbolReadExpandCommands {
15921    source_window: String,
15922    #[serde(skip_serializing_if = "Option::is_none")]
15923    body: Option<String>,
15924    file: String,
15925    explain: String,
15926    callers: String,
15927    callees: String,
15928    #[serde(skip_serializing_if = "Option::is_none")]
15929    markdown_ast: Option<String>,
15930}
15931
15932#[derive(Serialize)]
15933struct SymbolReadReport {
15934    handle: String,
15935    root: String,
15936    query: String,
15937    symbol: SymbolReadTarget,
15938    range: SourceRangePreview,
15939    body: Vec<SourceLinePreview>,
15940    child_symbols: Vec<SourceSymbolRef>,
15941    summaries: Vec<SourceSummaryRef>,
15942    expand: SymbolReadExpandCommands,
15943    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15944    warnings: Vec<String>,
15945}
15946
15947#[derive(Clone)]
15948pub(crate) struct MarkdownAstRawNode {
15949    handle: String,
15950    span_handle: String,
15951    name: String,
15952    kind: String,
15953    block_kind: String,
15954    node_kind: String,
15955    start_byte: usize,
15956    end_byte: usize,
15957    body_start_byte: Option<usize>,
15958    body_end_byte: Option<usize>,
15959}
15960
15961#[derive(Clone)]
15962pub(crate) struct MarkdownAstProjection {
15963    source_hash: String,
15964    nodes: Vec<MarkdownAstRawNode>,
15965    parse_duration_micros: u128,
15966    cache_hit: bool,
15967}
15968
15969#[derive(Clone)]
15970struct MarkdownAstCacheEntry {
15971    source_hash: String,
15972    nodes: Vec<MarkdownAstRawNode>,
15973    parse_duration_micros: u128,
15974}
15975
15976static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15977    OnceLock::new();
15978
15979#[derive(Serialize, Clone)]
15980struct MarkdownAstNodeMetadata {
15981    #[serde(skip_serializing_if = "Option::is_none")]
15982    heading_level: Option<usize>,
15983    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15984    section_path: Vec<String>,
15985    #[serde(skip_serializing_if = "Option::is_none")]
15986    section_handle: Option<String>,
15987    #[serde(skip_serializing_if = "Option::is_none")]
15988    list_depth: Option<usize>,
15989    #[serde(skip_serializing_if = "Option::is_none")]
15990    list_marker: Option<String>,
15991    #[serde(skip_serializing_if = "Option::is_none")]
15992    list_order: Option<usize>,
15993    #[serde(skip_serializing_if = "Option::is_none")]
15994    fence_language: Option<String>,
15995    #[serde(skip_serializing_if = "Option::is_none")]
15996    fence_marker: Option<String>,
15997    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15998    embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15999}
16000
16001#[derive(Serialize, Clone)]
16002struct MarkdownAstNodeExpand {
16003    source_window: String,
16004    source_body: String,
16005    symbol_read: String,
16006    edit_intents: String,
16007}
16008
16009#[derive(Serialize, Clone)]
16010struct MarkdownAstCacheReport {
16011    source_hash: String,
16012    cache_hit: bool,
16013    parse_duration_micros: u128,
16014    node_count: usize,
16015    section_count: usize,
16016    list_item_count: usize,
16017    code_block_count: usize,
16018}
16019
16020#[derive(Serialize, Clone)]
16021struct MarkdownAstPhaseTiming {
16022    name: String,
16023    duration_micros: u128,
16024    detail: String,
16025}
16026
16027#[derive(Serialize, Clone)]
16028struct MarkdownAstOutlineEntry {
16029    handle: String,
16030    span_handle: String,
16031    name: String,
16032    kind: String,
16033    block_kind: String,
16034    line: usize,
16035    end_line: usize,
16036    #[serde(skip_serializing_if = "Vec::is_empty", default)]
16037    section_path: Vec<String>,
16038    child_count: usize,
16039    expand: String,
16040}
16041
16042#[derive(Serialize, Clone)]
16043struct MarkdownAstProjectionPreview {
16044    mode: String,
16045    total_nodes: usize,
16046    returned_nodes: usize,
16047    omitted_nodes: usize,
16048    selected_node: Option<String>,
16049    cache: MarkdownAstCacheReport,
16050    outline: Vec<MarkdownAstOutlineEntry>,
16051    phase_timings: Vec<MarkdownAstPhaseTiming>,
16052}
16053
16054#[derive(Serialize)]
16055struct SourceReadMarkdownProjection {
16056    handle: String,
16057    mode: String,
16058    total_nodes: usize,
16059    visible_nodes: usize,
16060    outline: Vec<MarkdownAstOutlineEntry>,
16061    expand: String,
16062}
16063
16064#[derive(Serialize, Clone)]
16065struct SourceByteRangePreview {
16066    start: usize,
16067    end: usize,
16068}
16069
16070#[derive(Serialize, Clone)]
16071struct MarkdownAstNode {
16072    handle: String,
16073    span_handle: String,
16074    name: String,
16075    kind: String,
16076    block_kind: String,
16077    node_kind: String,
16078    line: usize,
16079    end_line: usize,
16080    byte_span: SourceByteRangePreview,
16081    #[serde(skip_serializing_if = "Option::is_none")]
16082    body_byte_span: Option<SourceByteRangePreview>,
16083    parent_handle: Option<String>,
16084    #[serde(skip_serializing_if = "Vec::is_empty", default)]
16085    child_handles: Vec<String>,
16086    metadata: MarkdownAstNodeMetadata,
16087    expand: MarkdownAstNodeExpand,
16088}
16089
16090#[derive(Serialize)]
16091struct MarkdownAstExpandCommands {
16092    file: String,
16093    source_read: String,
16094    edit_intents: String,
16095}
16096
16097#[derive(Serialize)]
16098struct MarkdownAstReport {
16099    handle: String,
16100    root: String,
16101    file: String,
16102    range: SourceRangePreview,
16103    projection: MarkdownAstProjectionPreview,
16104    nodes: Vec<MarkdownAstNode>,
16105    expand: MarkdownAstExpandCommands,
16106    #[serde(skip_serializing_if = "Vec::is_empty", default)]
16107    warnings: Vec<String>,
16108}
16109
16110pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
16111    let candidate = if file.is_absolute() {
16112        file.to_path_buf()
16113    } else {
16114        root.join(file)
16115    };
16116    let canonical = candidate
16117        .canonicalize()
16118        .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
16119    if !canonical.is_file() {
16120        bail!("source file is not a regular file: {}", canonical.display());
16121    }
16122    let canonical_root = root
16123        .canonicalize()
16124        .with_context(|| format!("canonicalizing project root {}", root.display()))?;
16125    if !canonical.starts_with(&canonical_root) {
16126        bail!(
16127            "source file {} is outside project root {}",
16128            canonical.display(),
16129            canonical_root.display()
16130        );
16131    }
16132    Ok(canonical)
16133}
16134
16135pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
16136    source_read_window_command(root, file, start, lines)
16137}
16138
16139pub(crate) fn source_read_window_command(
16140    root: &Path,
16141    file: &str,
16142    start: usize,
16143    lines: usize,
16144) -> String {
16145    format!(
16146        "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
16147        shell_quote(file),
16148        shell_quote(&root.to_string_lossy()),
16149        start,
16150        lines
16151    )
16152}
16153
16154pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
16155    format!(
16156        "tsift --envelope source-read {} --path {} --budget normal",
16157        shell_quote(file),
16158        shell_quote(&root.to_string_lossy())
16159    )
16160}
16161
16162pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
16163    format!(
16164        "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
16165        shell_quote(symbol),
16166        shell_quote(&root.to_string_lossy()),
16167        shell_quote(file)
16168    )
16169}
16170
16171fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
16172    format!(
16173        "tsift --envelope explain {} --path {} --budget normal",
16174        shell_quote(symbol),
16175        shell_quote(&root.to_string_lossy())
16176    )
16177}
16178
16179fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
16180    format!(
16181        "tsift graph {} --path {} --{} --json",
16182        shell_quote(symbol),
16183        shell_quote(&root.to_string_lossy()),
16184        relation
16185    )
16186}
16187
16188fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
16189    format!(
16190        "tsift summarize {} --path {} --json",
16191        shell_quote(symbol),
16192        shell_quote(&root.to_string_lossy())
16193    )
16194}
16195
16196pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
16197    let mut command = format!(
16198        "tsift --envelope markdown-ast {} --path {} --budget normal",
16199        shell_quote(file),
16200        shell_quote(&root.to_string_lossy())
16201    );
16202    if let Some(node) = node {
16203        command.push_str(" --node ");
16204        command.push_str(&shell_quote(node));
16205    }
16206    command
16207}
16208
16209fn markdown_edit_intents_command(root: &Path) -> String {
16210    format!(
16211        "tsift --envelope edit-intents --path {} --budget normal",
16212        shell_quote(&root.to_string_lossy())
16213    )
16214}
16215
16216pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
16217    usize::try_from(symbol.line)
16218        .ok()
16219        .and_then(|line| line.checked_add(1))
16220        .unwrap_or(1)
16221}
16222
16223fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
16224    symbol
16225        .end_line
16226        .and_then(|line| usize::try_from(line).ok())
16227        .and_then(|line| line.checked_add(1))
16228}
16229
16230fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
16231    value.and_then(|byte| usize::try_from(byte).ok())
16232}
16233
16234fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
16235    let byte = byte.min(source.len());
16236    source[..byte]
16237        .iter()
16238        .filter(|value| **value == b'\n')
16239        .count()
16240        .saturating_add(1)
16241}
16242
16243fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
16244    source_line_for_byte(source, end_byte.saturating_sub(1))
16245}
16246
16247fn ast_span_handle(
16248    file: &str,
16249    name: &str,
16250    kind: &str,
16251    start_byte: usize,
16252    end_byte: usize,
16253) -> String {
16254    stable_handle(
16255        "span",
16256        &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
16257    )
16258}
16259
16260pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
16261    Some((
16262        symbol_span_byte(symbol.start_byte)?,
16263        symbol_span_byte(symbol.end_byte)?,
16264    ))
16265}
16266
16267pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
16268    Some((
16269        symbol_span_byte(symbol.start_byte)?,
16270        symbol_span_byte(symbol.end_byte)?,
16271    ))
16272}
16273
16274pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
16275    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16276    Some(ast_span_handle(
16277        &symbol.file,
16278        &symbol.name,
16279        &symbol.kind,
16280        start_byte,
16281        end_byte,
16282    ))
16283}
16284
16285fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
16286    left.file == right.file
16287        && left.name == right.name
16288        && left.kind == right.kind
16289        && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
16290}
16291
16292fn stored_symbol_parent_span_handle_in_file(
16293    symbol: &index::StoredSymbol,
16294    symbols: &[&index::StoredSymbol],
16295) -> Option<String> {
16296    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16297    symbols
16298        .iter()
16299        .copied()
16300        .filter(|candidate| {
16301            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16302                return false;
16303            }
16304            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16305            else {
16306                return false;
16307            };
16308            candidate_start <= start_byte && candidate_end >= end_byte
16309        })
16310        .min_by_key(|candidate| {
16311            stored_symbol_span_bounds(candidate)
16312                .map(|(start, end)| end.saturating_sub(start))
16313                .unwrap_or(usize::MAX)
16314        })
16315        .and_then(stored_symbol_span_handle)
16316}
16317
16318fn stored_symbol_child_span_handles_in_file(
16319    symbol: &index::StoredSymbol,
16320    symbols: &[&index::StoredSymbol],
16321    limit: usize,
16322) -> Vec<String> {
16323    let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
16324        return Vec::new();
16325    };
16326    symbols
16327        .iter()
16328        .copied()
16329        .filter(|candidate| {
16330            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16331                return false;
16332            }
16333            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16334            else {
16335                return false;
16336            };
16337            candidate_start >= start_byte && candidate_end <= end_byte
16338        })
16339        .take(limit)
16340        .filter_map(stored_symbol_span_handle)
16341        .collect()
16342}
16343
16344fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
16345    let start = start_byte.min(source.len());
16346    let line_end = source[start..]
16347        .iter()
16348        .position(|value| *value == b'\n')
16349        .map(|pos| start + pos)
16350        .unwrap_or(source.len());
16351    let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
16352    let marker = line.trim_start();
16353    let level = marker.chars().take_while(|ch| *ch == '#').count();
16354    (1..=6).contains(&level).then_some(level)
16355}
16356
16357fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
16358    let start = start_byte.min(source.len());
16359    let line_start = source[..start]
16360        .iter()
16361        .rposition(|value| *value == b'\n')
16362        .map(|pos| pos + 1)
16363        .unwrap_or(0);
16364    source[line_start..start]
16365        .iter()
16366        .map(|byte| match byte {
16367            b'\t' => 4,
16368            b' ' => 1,
16369            _ => 0,
16370        })
16371        .sum::<usize>()
16372        / 2
16373}
16374
16375fn markdown_enclosing_heading_symbols_in_file<'a>(
16376    file: &str,
16377    start_byte: usize,
16378    end_byte: usize,
16379    symbols: &[&'a index::StoredSymbol],
16380) -> Vec<&'a index::StoredSymbol> {
16381    let mut headings = symbols
16382        .iter()
16383        .copied()
16384        .filter(|candidate| candidate.file == file && candidate.kind == "heading")
16385        .filter(|candidate| {
16386            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16387            else {
16388                return false;
16389            };
16390            candidate_start <= start_byte && candidate_end >= end_byte
16391        })
16392        .collect::<Vec<_>>();
16393    headings.sort_by(|left, right| {
16394        stored_symbol_span_bounds(left)
16395            .map(|(start, _)| start)
16396            .unwrap_or(usize::MAX)
16397            .cmp(
16398                &stored_symbol_span_bounds(right)
16399                    .map(|(start, _)| start)
16400                    .unwrap_or(usize::MAX),
16401            )
16402            .then(left.name.cmp(&right.name))
16403    });
16404    headings
16405}
16406
16407fn markdown_stored_symbol_metadata_in_file(
16408    symbol: &index::StoredSymbol,
16409    source: &[u8],
16410    symbols: &[&index::StoredSymbol],
16411) -> Option<MarkdownSpanMetadata> {
16412    if symbol.language != "markdown" {
16413        return None;
16414    }
16415    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16416    let section_symbols =
16417        markdown_enclosing_heading_symbols_in_file(&symbol.file, start_byte, end_byte, symbols);
16418    let section_path = section_symbols
16419        .iter()
16420        .map(|heading| heading.name.clone())
16421        .collect::<Vec<_>>();
16422    let section_handle = section_symbols
16423        .last()
16424        .and_then(|heading| stored_symbol_span_handle(heading));
16425    let heading_level = (symbol.kind == "heading")
16426        .then(|| markdown_heading_level(source, start_byte))
16427        .flatten();
16428    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16429    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16430    let embedded_symbols = if symbol.kind == "code_block" {
16431        markdown_embedded_symbols(
16432            &symbol.file,
16433            source,
16434            symbol_span_byte(symbol.body_start_byte),
16435            symbol_span_byte(symbol.body_end_byte),
16436            fence_language.as_deref(),
16437        )
16438    } else {
16439        Vec::new()
16440    };
16441
16442    (heading_level.is_some()
16443        || !section_path.is_empty()
16444        || section_handle.is_some()
16445        || list_depth.is_some()
16446        || fence_language.is_some()
16447        || !embedded_symbols.is_empty())
16448    .then_some(MarkdownSpanMetadata {
16449        heading_level,
16450        section_path,
16451        section_handle,
16452        list_depth,
16453        fence_language,
16454        embedded_symbols,
16455    })
16456}
16457
16458fn markdown_symbol_hit_metadata(
16459    symbol: &index::SymbolHit,
16460    source: &[u8],
16461    start_byte: usize,
16462) -> Option<MarkdownSpanMetadata> {
16463    if symbol.language != "markdown" {
16464        return None;
16465    }
16466    let heading_level = (symbol.kind == "heading")
16467        .then(|| markdown_heading_level(source, start_byte))
16468        .flatten();
16469    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16470    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16471    let embedded_symbols = if symbol.kind == "code_block" {
16472        markdown_embedded_symbols(
16473            &symbol.file,
16474            source,
16475            symbol_span_byte(symbol.body_start_byte),
16476            symbol_span_byte(symbol.body_end_byte),
16477            fence_language.as_deref(),
16478        )
16479    } else {
16480        Vec::new()
16481    };
16482    (heading_level.is_some()
16483        || list_depth.is_some()
16484        || fence_language.is_some()
16485        || !embedded_symbols.is_empty())
16486    .then_some(MarkdownSpanMetadata {
16487        heading_level,
16488        section_path: Vec::new(),
16489        section_handle: None,
16490        list_depth,
16491        fence_language,
16492        embedded_symbols,
16493    })
16494}
16495
16496fn is_markdown_path(path: &Path) -> bool {
16497    path.extension()
16498        .and_then(|ext| ext.to_str())
16499        .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
16500        .unwrap_or(false)
16501}
16502
16503fn markdown_ast_block_kind(kind: &str) -> String {
16504    match kind {
16505        "heading" => "section",
16506        "code_block" => "fenced_code_block",
16507        "list_item" => "list_item",
16508        other => other,
16509    }
16510    .to_string()
16511}
16512
16513fn markdown_embedded_language_key(language: &str) -> Option<String> {
16514    let key = language
16515        .split_whitespace()
16516        .next()
16517        .unwrap_or("")
16518        .trim()
16519        .trim_start_matches("language-")
16520        .trim_start_matches("lang-")
16521        .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
16522        .to_ascii_lowercase();
16523    (!key.is_empty()).then_some(key)
16524}
16525
16526fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
16527    let key = markdown_embedded_language_key(language)?;
16528    let extension = match key.as_str() {
16529        "rust" => "rs",
16530        "python" => "py",
16531        "typescript" => "ts",
16532        "javascript" => "js",
16533        "kotlin" => "kt",
16534        "shell" | "sh" | "zsh" => "bash",
16535        other => other,
16536    };
16537    let lang = graph::Lang::from_extension(extension)?;
16538    (lang.name() != "markdown").then_some(lang)
16539}
16540
16541fn markdown_embedded_ast_span_handle(
16542    file: &str,
16543    language: &str,
16544    name: &str,
16545    kind: &str,
16546    start_byte: usize,
16547    end_byte: usize,
16548) -> String {
16549    stable_handle(
16550        "span",
16551        &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
16552    )
16553}
16554
16555fn markdown_embedded_symbols(
16556    file: &str,
16557    source: &[u8],
16558    body_start_byte: Option<usize>,
16559    body_end_byte: Option<usize>,
16560    fence_language: Option<&str>,
16561) -> Vec<MarkdownEmbeddedSymbol> {
16562    let Some(fence_language) = fence_language else {
16563        return Vec::new();
16564    };
16565    let Some(lang) = markdown_embedded_lang(fence_language) else {
16566        return Vec::new();
16567    };
16568    let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
16569        return Vec::new();
16570    };
16571    let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
16572    else {
16573        return Vec::new();
16574    };
16575    if body.is_empty() {
16576        return Vec::new();
16577    }
16578
16579    let Ok(symbols) = lang.extract_symbols(body) else {
16580        return Vec::new();
16581    };
16582    let language = lang.name().to_string();
16583    symbols
16584        .into_iter()
16585        .map(|symbol| {
16586            let start_byte = body_start_byte.saturating_add(symbol.start_byte);
16587            let end_byte = body_start_byte.saturating_add(symbol.end_byte);
16588            let body_start = symbol
16589                .body_start_byte
16590                .map(|byte| body_start_byte.saturating_add(byte));
16591            let body_end = symbol
16592                .body_end_byte
16593                .map(|byte| body_start_byte.saturating_add(byte));
16594            let start_line = source_line_for_byte(source, start_byte);
16595            let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
16596            MarkdownEmbeddedSymbol {
16597                handle: markdown_embedded_ast_span_handle(
16598                    file,
16599                    &language,
16600                    &symbol.name,
16601                    &symbol.kind,
16602                    start_byte,
16603                    end_byte,
16604                ),
16605                name: symbol.name,
16606                kind: symbol.kind,
16607                language: language.clone(),
16608                node_kind: symbol.node_kind,
16609                start_byte,
16610                end_byte,
16611                start_line,
16612                end_line,
16613                body_start_byte: body_start,
16614                body_end_byte: body_end,
16615                body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
16616                body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
16617            }
16618        })
16619        .collect()
16620}
16621
16622fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
16623    let start = start_byte.min(source.len());
16624    let line_start = source[..start]
16625        .iter()
16626        .rposition(|value| *value == b'\n')
16627        .map(|pos| pos + 1)
16628        .unwrap_or(0);
16629    let line_end = source[start..]
16630        .iter()
16631        .position(|value| *value == b'\n')
16632        .map(|pos| start + pos)
16633        .unwrap_or(source.len());
16634    std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
16635}
16636
16637fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
16638    let line = markdown_source_line(source, start_byte);
16639    let trimmed = line.trim_start();
16640    for marker in ["-", "*", "+"] {
16641        if trimmed
16642            .strip_prefix(marker)
16643            .and_then(|rest| rest.strip_prefix(' '))
16644            .is_some()
16645        {
16646            return (Some(marker.to_string()), None);
16647        }
16648    }
16649
16650    let digit_end = trimmed
16651        .find(|ch: char| !ch.is_ascii_digit())
16652        .unwrap_or(trimmed.len());
16653    let (digits, rest) = trimmed.split_at(digit_end);
16654    if !digits.is_empty() {
16655        for marker in [".", ")"] {
16656            if rest
16657                .strip_prefix(marker)
16658                .and_then(|value| value.strip_prefix(' '))
16659                .is_some()
16660            {
16661                return (
16662                    Some(format!("{digits}{marker}")),
16663                    digits.parse::<usize>().ok(),
16664                );
16665            }
16666        }
16667    }
16668    (None, None)
16669}
16670
16671fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
16672    let line = markdown_source_line(source, start_byte);
16673    let trimmed = line.trim_start();
16674    ["```", "~~~"]
16675        .into_iter()
16676        .find(|marker| trimmed.starts_with(marker))
16677        .map(str::to_string)
16678}
16679
16680fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16681    let mut nodes = graph::Lang::Markdown
16682        .extract_symbols(source)
16683        .context("extracting Markdown AST nodes")?
16684        .into_iter()
16685        .map(|symbol| {
16686            let body_start_byte = symbol.body_start_byte;
16687            let body_end_byte = symbol.body_end_byte;
16688            let span_handle = ast_span_handle(
16689                file,
16690                &symbol.name,
16691                &symbol.kind,
16692                symbol.start_byte,
16693                symbol.end_byte,
16694            );
16695            MarkdownAstRawNode {
16696                handle: stable_handle(
16697                    "mdast",
16698                    &format!(
16699                        "{}:{}:{}:{}:{}",
16700                        file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16701                    ),
16702                ),
16703                span_handle,
16704                name: symbol.name,
16705                kind: symbol.kind.clone(),
16706                block_kind: markdown_ast_block_kind(&symbol.kind),
16707                node_kind: symbol.node_kind,
16708                start_byte: symbol.start_byte,
16709                end_byte: symbol.end_byte,
16710                body_start_byte,
16711                body_end_byte,
16712            }
16713        })
16714        .collect::<Vec<_>>();
16715    nodes.sort_by(|left, right| {
16716        left.start_byte
16717            .cmp(&right.start_byte)
16718            .then(left.end_byte.cmp(&right.end_byte))
16719            .then(left.kind.cmp(&right.kind))
16720            .then(left.name.cmp(&right.name))
16721    });
16722    Ok(nodes)
16723}
16724
16725pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16726    let source_hash = blake3::hash(source).to_hex().to_string();
16727    let cache_key = format!("{file}:{source_hash}");
16728    let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16729    if let Some(entry) = cache
16730        .lock()
16731        .expect("markdown ast cache poisoned")
16732        .get(&cache_key)
16733    {
16734        return Ok(MarkdownAstProjection {
16735            source_hash: entry.source_hash.clone(),
16736            nodes: entry.nodes.clone(),
16737            parse_duration_micros: entry.parse_duration_micros,
16738            cache_hit: true,
16739        });
16740    }
16741
16742    let started = Instant::now();
16743    let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16744    let parse_duration_micros = started.elapsed().as_micros();
16745    cache.lock().expect("markdown ast cache poisoned").insert(
16746        cache_key,
16747        MarkdownAstCacheEntry {
16748            source_hash: source_hash.clone(),
16749            nodes: nodes.clone(),
16750            parse_duration_micros,
16751        },
16752    );
16753    Ok(MarkdownAstProjection {
16754        source_hash,
16755        nodes,
16756        parse_duration_micros,
16757        cache_hit: false,
16758    })
16759}
16760
16761fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16762    MarkdownAstCacheReport {
16763        source_hash: projection.source_hash.clone(),
16764        cache_hit: projection.cache_hit,
16765        parse_duration_micros: projection.parse_duration_micros,
16766        node_count: projection.nodes.len(),
16767        section_count: projection
16768            .nodes
16769            .iter()
16770            .filter(|node| node.kind == "heading")
16771            .count(),
16772        list_item_count: projection
16773            .nodes
16774            .iter()
16775            .filter(|node| node.kind == "list_item")
16776            .count(),
16777        code_block_count: projection
16778            .nodes
16779            .iter()
16780            .filter(|node| node.kind == "code_block")
16781            .count(),
16782    }
16783}
16784
16785fn markdown_ast_node_direct_child_count(
16786    node: &MarkdownAstRawNode,
16787    nodes: &[MarkdownAstRawNode],
16788) -> usize {
16789    nodes
16790        .iter()
16791        .filter(|candidate| {
16792            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16793        })
16794        .count()
16795}
16796
16797fn markdown_ast_outline_entry(
16798    root: &Path,
16799    file: &str,
16800    source: &[u8],
16801    nodes: &[MarkdownAstRawNode],
16802    node: &MarkdownAstRawNode,
16803    max_bytes: usize,
16804) -> MarkdownAstOutlineEntry {
16805    let line = source_line_for_byte(source, node.start_byte);
16806    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16807    MarkdownAstOutlineEntry {
16808        handle: node.handle.clone(),
16809        span_handle: node.span_handle.clone(),
16810        name: truncate_for_budget(&node.name, max_bytes),
16811        kind: node.kind.clone(),
16812        block_kind: node.block_kind.clone(),
16813        line,
16814        end_line,
16815        section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16816        child_count: markdown_ast_node_direct_child_count(node, nodes),
16817        expand: markdown_ast_command(root, file, Some(&node.handle)),
16818    }
16819}
16820
16821fn markdown_ast_outline_entries(
16822    root: &Path,
16823    file: &str,
16824    source: &[u8],
16825    nodes: &[MarkdownAstRawNode],
16826    limit: usize,
16827    max_bytes: usize,
16828) -> Vec<MarkdownAstOutlineEntry> {
16829    let mut headings = nodes
16830        .iter()
16831        .filter(|node| node.kind == "heading")
16832        .collect::<Vec<_>>();
16833    let mut blocks = nodes
16834        .iter()
16835        .filter(|node| node.kind != "heading")
16836        .collect::<Vec<_>>();
16837    headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16838    blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16839    headings
16840        .into_iter()
16841        .chain(blocks)
16842        .take(limit)
16843        .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16844        .collect()
16845}
16846
16847fn markdown_ast_node_intersects_lines(
16848    source: &[u8],
16849    node: &MarkdownAstRawNode,
16850    start: usize,
16851    end: usize,
16852) -> bool {
16853    let line = source_line_for_byte(source, node.start_byte);
16854    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16855    line <= end && end_line >= start
16856}
16857
16858fn source_read_markdown_projection(
16859    root: &Path,
16860    file: &str,
16861    source: &[u8],
16862    start: usize,
16863    end: usize,
16864    budget: ResponseBudget,
16865) -> Result<SourceReadMarkdownProjection> {
16866    let projection = markdown_ast_projection(file, source)?;
16867    let visible_nodes = projection
16868        .nodes
16869        .iter()
16870        .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16871        .collect::<Vec<_>>();
16872    let mut outline_nodes = visible_nodes.clone();
16873    outline_nodes.sort_by_key(|node| {
16874        (
16875            node.kind != "heading",
16876            node.start_byte,
16877            node.end_byte,
16878            node.name.as_str(),
16879        )
16880    });
16881    let outline = outline_nodes
16882        .into_iter()
16883        .take(budget.preview_items())
16884        .map(|node| {
16885            markdown_ast_outline_entry(
16886                root,
16887                file,
16888                source,
16889                &projection.nodes,
16890                node,
16891                budget.preview_bytes(),
16892            )
16893        })
16894        .collect::<Vec<_>>();
16895    Ok(SourceReadMarkdownProjection {
16896        handle: stable_handle(
16897            "mdproj",
16898            &format!("{file}:{start}:{end}:{}", projection.source_hash),
16899        ),
16900        mode: "window_outline".to_string(),
16901        total_nodes: projection.nodes.len(),
16902        visible_nodes: visible_nodes.len(),
16903        outline,
16904        expand: markdown_ast_command(root, file, None),
16905    })
16906}
16907
16908fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16909    if parent.handle == child.handle {
16910        return false;
16911    }
16912    parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16913}
16914
16915fn markdown_ast_parent_handle(
16916    node: &MarkdownAstRawNode,
16917    nodes: &[MarkdownAstRawNode],
16918) -> Option<String> {
16919    nodes
16920        .iter()
16921        .filter(|candidate| markdown_ast_contains(candidate, node))
16922        .min_by_key(|candidate| {
16923            (
16924                candidate.end_byte.saturating_sub(candidate.start_byte),
16925                candidate.start_byte,
16926            )
16927        })
16928        .map(|candidate| candidate.handle.clone())
16929}
16930
16931fn markdown_ast_child_handles(
16932    node: &MarkdownAstRawNode,
16933    nodes: &[MarkdownAstRawNode],
16934    limit: usize,
16935) -> Vec<String> {
16936    nodes
16937        .iter()
16938        .filter(|candidate| {
16939            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16940        })
16941        .take(limit)
16942        .map(|candidate| candidate.handle.clone())
16943        .collect()
16944}
16945
16946fn markdown_ast_section_nodes<'a>(
16947    node: &MarkdownAstRawNode,
16948    nodes: &'a [MarkdownAstRawNode],
16949) -> Vec<&'a MarkdownAstRawNode> {
16950    let mut headings = nodes
16951        .iter()
16952        .filter(|candidate| candidate.kind == "heading")
16953        .filter(|candidate| {
16954            candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16955        })
16956        .collect::<Vec<_>>();
16957    headings.sort_by(|left, right| {
16958        left.start_byte
16959            .cmp(&right.start_byte)
16960            .then(left.end_byte.cmp(&right.end_byte))
16961            .then(left.name.cmp(&right.name))
16962    });
16963    headings
16964}
16965
16966fn markdown_ast_node_metadata(
16967    file: &str,
16968    node: &MarkdownAstRawNode,
16969    source: &[u8],
16970    nodes: &[MarkdownAstRawNode],
16971) -> MarkdownAstNodeMetadata {
16972    let section_nodes = markdown_ast_section_nodes(node, nodes);
16973    let section_path = section_nodes
16974        .iter()
16975        .map(|heading| heading.name.clone())
16976        .collect::<Vec<_>>();
16977    let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16978    let heading_level = (node.kind == "heading")
16979        .then(|| markdown_heading_level(source, node.start_byte))
16980        .flatten();
16981    let (list_marker, list_order) = if node.kind == "list_item" {
16982        markdown_list_attributes(source, node.start_byte)
16983    } else {
16984        (None, None)
16985    };
16986    let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16987    let embedded_symbols = if node.kind == "code_block" {
16988        markdown_embedded_symbols(
16989            file,
16990            source,
16991            node.body_start_byte,
16992            node.body_end_byte,
16993            fence_language.as_deref(),
16994        )
16995    } else {
16996        Vec::new()
16997    };
16998    MarkdownAstNodeMetadata {
16999        heading_level,
17000        section_path,
17001        section_handle,
17002        list_depth: (node.kind == "list_item")
17003            .then(|| markdown_list_depth(source, node.start_byte)),
17004        list_marker,
17005        list_order,
17006        fence_language,
17007        fence_marker: (node.kind == "code_block")
17008            .then(|| markdown_fence_marker(source, node.start_byte))
17009            .flatten(),
17010        embedded_symbols,
17011    }
17012}
17013
17014fn markdown_ast_node_expand(
17015    root: &Path,
17016    file: &str,
17017    node: &MarkdownAstRawNode,
17018    source: &[u8],
17019) -> MarkdownAstNodeExpand {
17020    let start_line = source_line_for_byte(source, node.start_byte);
17021    let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
17022    let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
17023    let body_start_line = node
17024        .body_start_byte
17025        .map(|byte| source_line_for_byte(source, byte))
17026        .unwrap_or(start_line);
17027    let body_end_line = node
17028        .body_end_byte
17029        .map(|byte| source_line_for_end_byte(source, byte))
17030        .unwrap_or(end_line)
17031        .max(body_start_line);
17032    let body_line_count = body_end_line
17033        .saturating_sub(body_start_line)
17034        .saturating_add(1)
17035        .max(1);
17036    MarkdownAstNodeExpand {
17037        source_window: source_read_command(root, file, start_line, line_count),
17038        source_body: source_read_command(root, file, body_start_line, body_line_count),
17039        symbol_read: source_symbol_read_command(root, &node.name, file),
17040        edit_intents: markdown_edit_intents_command(root),
17041    }
17042}
17043
17044fn markdown_ast_node(
17045    root: &Path,
17046    file: &str,
17047    node: &MarkdownAstRawNode,
17048    source: &[u8],
17049    nodes: &[MarkdownAstRawNode],
17050    child_limit: usize,
17051) -> MarkdownAstNode {
17052    let line = source_line_for_byte(source, node.start_byte);
17053    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
17054    let body_byte_span = node
17055        .body_start_byte
17056        .zip(node.body_end_byte)
17057        .map(|(start, end)| SourceByteRangePreview { start, end });
17058    MarkdownAstNode {
17059        handle: node.handle.clone(),
17060        span_handle: node.span_handle.clone(),
17061        name: node.name.clone(),
17062        kind: node.kind.clone(),
17063        block_kind: node.block_kind.clone(),
17064        node_kind: node.node_kind.clone(),
17065        line,
17066        end_line,
17067        byte_span: SourceByteRangePreview {
17068            start: node.start_byte,
17069            end: node.end_byte,
17070        },
17071        body_byte_span,
17072        parent_handle: markdown_ast_parent_handle(node, nodes),
17073        child_handles: markdown_ast_child_handles(node, nodes, child_limit),
17074        metadata: markdown_ast_node_metadata(file, node, source, nodes),
17075        expand: markdown_ast_node_expand(root, file, node, source),
17076    }
17077}
17078
17079pub(crate) fn stored_symbol_ast_span(
17080    symbol: &index::StoredSymbol,
17081    source: &[u8],
17082    symbols: &[index::StoredSymbol],
17083    child_limit: usize,
17084) -> Option<AstSpanPreview> {
17085    let file_symbols = symbols.iter().collect::<Vec<_>>();
17086    stored_symbol_ast_span_in_file(symbol, source, &file_symbols, child_limit)
17087}
17088
17089fn stored_symbol_ast_span_in_file(
17090    symbol: &index::StoredSymbol,
17091    source: &[u8],
17092    symbols: &[&index::StoredSymbol],
17093    child_limit: usize,
17094) -> Option<AstSpanPreview> {
17095    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
17096    let node_kind = symbol.node_kind.clone()?;
17097    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17098    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17099    Some(AstSpanPreview {
17100        handle: ast_span_handle(
17101            &symbol.file,
17102            &symbol.name,
17103            &symbol.kind,
17104            start_byte,
17105            end_byte,
17106        ),
17107        node_kind,
17108        start_byte,
17109        end_byte,
17110        start_line: source_line_for_byte(source, start_byte),
17111        end_line: source_line_for_end_byte(source, end_byte),
17112        body_start_byte,
17113        body_end_byte,
17114        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17115        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17116        parent_handle: stored_symbol_parent_span_handle_in_file(symbol, symbols),
17117        child_handles: stored_symbol_child_span_handles_in_file(symbol, symbols, child_limit),
17118        markdown: markdown_stored_symbol_metadata_in_file(symbol, source, symbols),
17119    })
17120}
17121
17122pub(crate) fn symbol_hit_ast_span(
17123    symbol: &index::SymbolHit,
17124    source: &[u8],
17125) -> Option<AstSpanPreview> {
17126    let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
17127    let node_kind = symbol.node_kind.clone()?;
17128    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17129    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17130    Some(AstSpanPreview {
17131        handle: ast_span_handle(
17132            &symbol.file,
17133            &symbol.name,
17134            &symbol.kind,
17135            start_byte,
17136            end_byte,
17137        ),
17138        node_kind,
17139        start_byte,
17140        end_byte,
17141        start_line: source_line_for_byte(source, start_byte),
17142        end_line: source_line_for_end_byte(source, end_byte),
17143        body_start_byte,
17144        body_end_byte,
17145        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17146        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17147        parent_handle: None,
17148        child_handles: Vec::new(),
17149        markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
17150    })
17151}
17152
17153pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
17154    usize::try_from(symbol.line)
17155        .ok()
17156        .and_then(|line| line.checked_add(1))
17157        .unwrap_or(1)
17158}
17159
17160pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
17161    symbol
17162        .end_line
17163        .and_then(|line| usize::try_from(line).ok())
17164        .and_then(|line| line.checked_add(1))
17165}
17166
17167fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
17168    if end == 0 {
17169        return false;
17170    }
17171    let symbol_start = source_symbol_line(symbol);
17172    let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
17173    symbol_start <= end && symbol_end >= start
17174}
17175
17176#[allow(clippy::too_many_arguments)]
17177fn load_source_symbols(
17178    root: &Path,
17179    file_abs: &Path,
17180    file_display: &str,
17181    source: &[u8],
17182    scope: Option<&str>,
17183    start: usize,
17184    end: usize,
17185    limit: usize,
17186    max_bytes: usize,
17187    warnings: &mut Vec<String>,
17188) -> Vec<SourceSymbolRef> {
17189    let target = match resolve_query_index_target(root, file_abs, scope) {
17190        Ok(target) => target,
17191        Err(err) => {
17192            warnings.push(format!("index refs unavailable: {err:#}"));
17193            return Vec::new();
17194        }
17195    };
17196    // Build/refresh the per-package cargo index on demand so source-read delivers
17197    // AST symbol refs for any workspace member — not only members a prior
17198    // graph/search/explain query happened to index. `open_index_db` (the
17199    // search/explain/graph path) already ensures the index is current; before
17200    // this, source-read only checked `db_path.exists()`, so a workspace member
17201    // that had never been queried (≈60% of members here) silently degraded to
17202    // window-only output with an "index refs unavailable" warning even though
17203    // `tsift status` reported the index fresh (#cargoidxcov).
17204    if let Err(err) = ensure_query_index_current(root, &target) {
17205        warnings.push(format!("index refs unavailable: {err:#}"));
17206        return Vec::new();
17207    }
17208    let db_path = target.db_path;
17209    if !db_path.exists() {
17210        warnings.push(format!(
17211            "index refs unavailable: no index found at {}",
17212            db_path.display()
17213        ));
17214        return Vec::new();
17215    }
17216
17217    let db = match index::IndexDb::open_read_only_resilient(&db_path) {
17218        Ok(db) => db,
17219        Err(err) => {
17220            warnings.push(format!("index refs unavailable: {err:#}"));
17221            return Vec::new();
17222        }
17223    };
17224
17225    let file_key = file_abs.to_string_lossy().to_string();
17226    let symbols = match db.symbols_for_file(&file_key) {
17227        Ok(symbols) => symbols,
17228        Err(err) => {
17229            warnings.push(format!("symbol refs unavailable: {err:#}"));
17230            return Vec::new();
17231        }
17232    };
17233
17234    symbols
17235        .iter()
17236        .filter(|symbol| source_symbol_intersects(symbol, start, end))
17237        .take(limit)
17238        .map(|symbol| {
17239            let line = source_symbol_line(symbol);
17240            let end_line = source_symbol_end_line(symbol);
17241            let handle = stable_handle(
17242                "ssym",
17243                &format!("{}:{}:{}", file_display, symbol.name, line),
17244            );
17245            SourceSymbolRef {
17246                handle,
17247                name: truncate_for_budget(&symbol.name, max_bytes),
17248                kind: symbol.kind.clone(),
17249                language: symbol.language.clone(),
17250                file: file_display.to_string(),
17251                line,
17252                end_line,
17253                signature: symbol
17254                    .signature
17255                    .clone()
17256                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
17257                span: stored_symbol_ast_span(symbol, source, &symbols, limit),
17258                expand: source_symbol_read_command(root, &symbol.name, file_display),
17259            }
17260        })
17261        .collect()
17262}
17263
17264fn load_source_summaries(
17265    root: &Path,
17266    file_display: &str,
17267    limit: usize,
17268    max_bytes: usize,
17269    warnings: &mut Vec<String>,
17270) -> Vec<SourceSummaryRef> {
17271    let db_path = root.join(".tsift/summaries.db");
17272    if !db_path.exists() {
17273        return Vec::new();
17274    }
17275    let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
17276        Ok(db) => db,
17277        Err(err) => {
17278            warnings.push(format!("summary refs unavailable: {err:#}"));
17279            return Vec::new();
17280        }
17281    };
17282    let summaries = match db.get_by_file(file_display) {
17283        Ok(summaries) => summaries,
17284        Err(err) => {
17285            warnings.push(format!("summary refs unavailable: {err:#}"));
17286            return Vec::new();
17287        }
17288    };
17289
17290    summaries
17291        .into_iter()
17292        .take(limit)
17293        .map(|summary| SourceSummaryRef {
17294            handle: stable_handle(
17295                "sum",
17296                &format!(
17297                    "{}:{}:{}",
17298                    summary.file_path, summary.symbol_name, summary.id
17299                ),
17300            ),
17301            symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
17302            file_path: summary.file_path,
17303            summary: truncate_for_budget(&summary.summary, max_bytes),
17304            expand: source_summary_expand_command(root, &summary.symbol_name),
17305        })
17306        .collect()
17307}
17308
17309fn cmd_markdown_ast(
17310    file: &Path,
17311    path: &Path,
17312    node: Option<&str>,
17313    format: OutputFormat,
17314    absolute: bool,
17315    budget: ResponseBudget,
17316) -> Result<()> {
17317    let root = lint::resolve_project_root_or_canonical_path(path)?;
17318    let file_abs = resolve_source_file(&root, file)?;
17319    if !is_markdown_path(&file_abs) {
17320        bail!(
17321            "markdown-ast only supports Markdown files (.md/.mdx): {}",
17322            file_abs.display()
17323        );
17324    }
17325    let file_display = if absolute {
17326        file_abs.to_string_lossy().to_string()
17327    } else {
17328        relativize_pathbuf(&file_abs, &root)
17329            .to_string_lossy()
17330            .to_string()
17331    };
17332    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17333    let text = String::from_utf8_lossy(&source);
17334    let total_lines = text.lines().count();
17335    let projection = markdown_ast_projection(&file_display, &source)?;
17336    let raw_nodes = &projection.nodes;
17337    let max_items = budget.preview_items();
17338    let max_bytes = budget.preview_bytes();
17339
17340    let selected_nodes = if let Some(handle) = node {
17341        let matches = raw_nodes
17342            .iter()
17343            .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
17344            .collect::<Vec<_>>();
17345        if matches.is_empty() {
17346            bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
17347        }
17348        matches
17349    } else {
17350        raw_nodes.iter().take(max_items).collect::<Vec<_>>()
17351    };
17352    let nodes = selected_nodes
17353        .into_iter()
17354        .map(|raw| {
17355            let mut node =
17356                markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
17357            node.name = truncate_for_budget(&node.name, max_bytes);
17358            node
17359        })
17360        .collect::<Vec<_>>();
17361    let outline_started = Instant::now();
17362    let outline = markdown_ast_outline_entries(
17363        &root,
17364        &file_display,
17365        &source,
17366        raw_nodes,
17367        max_items,
17368        max_bytes,
17369    );
17370    let outline_duration_micros = outline_started.elapsed().as_micros();
17371    let projection_preview = MarkdownAstProjectionPreview {
17372        mode: if node.is_some() {
17373            "selected_node".to_string()
17374        } else {
17375            "outline_first".to_string()
17376        },
17377        total_nodes: raw_nodes.len(),
17378        returned_nodes: nodes.len(),
17379        omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
17380        selected_node: node.map(str::to_string),
17381        cache: markdown_ast_cache_report(&projection),
17382        outline,
17383        phase_timings: vec![
17384            MarkdownAstPhaseTiming {
17385                name: "parse_extract".to_string(),
17386                duration_micros: projection.parse_duration_micros,
17387                detail: if projection.cache_hit {
17388                    "reused cached tree-sitter Markdown symbol extraction".to_string()
17389                } else {
17390                    "tree-sitter Markdown symbol extraction".to_string()
17391                },
17392            },
17393            MarkdownAstPhaseTiming {
17394                name: "outline_projection".to_string(),
17395                duration_micros: outline_duration_micros,
17396                detail: "outline-first section/block preview construction".to_string(),
17397            },
17398        ],
17399    };
17400    let report = MarkdownAstReport {
17401        handle: stable_handle("mdastrep", &file_display),
17402        root: root.to_string_lossy().to_string(),
17403        file: file_display.clone(),
17404        range: SourceRangePreview {
17405            start: 1,
17406            end: total_lines,
17407            total_lines,
17408            truncated_before: false,
17409            truncated_after: false,
17410        },
17411        projection: projection_preview,
17412        nodes,
17413        expand: MarkdownAstExpandCommands {
17414            file: markdown_ast_command(&root, &file_display, None),
17415            source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
17416            edit_intents: markdown_edit_intents_command(&root),
17417        },
17418        warnings: Vec::new(),
17419    };
17420
17421    if format.json_output {
17422        let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
17423        let mut follow_up = vec![
17424            report.expand.file.clone(),
17425            report.expand.source_read.clone(),
17426            report.expand.edit_intents.clone(),
17427        ];
17428        follow_up.extend(
17429            report
17430                .nodes
17431                .iter()
17432                .map(|node| node.expand.source_window.clone()),
17433        );
17434        print_json_or_envelope(
17435            &report,
17436            &format,
17437            "markdown-ast",
17438            "ast",
17439            ToolEnvelopeSummary {
17440                text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
17441                metrics: vec![
17442                    envelope_metric("nodes", report.nodes.len()),
17443                    envelope_metric("total_nodes", report.projection.total_nodes),
17444                    envelope_metric(
17445                        "parse_duration_micros",
17446                        report.projection.cache.parse_duration_micros,
17447                    ),
17448                    envelope_metric("total_lines", report.range.total_lines),
17449                ],
17450            },
17451            truncated,
17452            follow_up,
17453        )?;
17454    } else if format.compact {
17455        println!(
17456            "markdown-ast {} nodes:{} handle:{}",
17457            report.file,
17458            report.nodes.len(),
17459            report.handle
17460        );
17461        for node in &report.nodes {
17462            println!(
17463                "  {} {} {}:{}-{}",
17464                node.handle, node.kind, node.name, node.line, node.end_line
17465            );
17466        }
17467        if node.is_none() && raw_nodes.len() > report.nodes.len() {
17468            println!("expand: {}", report.expand.file);
17469        }
17470    } else {
17471        println!(
17472            "Markdown AST `{}` nodes {} of {} ({})",
17473            report.file,
17474            report.nodes.len(),
17475            raw_nodes.len(),
17476            report.handle
17477        );
17478        for node in &report.nodes {
17479            println!(
17480                "  {} `{}` {}:{}-{} — {}",
17481                node.handle,
17482                node.name,
17483                node.kind,
17484                node.line,
17485                node.end_line,
17486                node.expand.source_window
17487            );
17488        }
17489        if node.is_none() && raw_nodes.len() > report.nodes.len() {
17490            println!();
17491            println!("Expand:");
17492            println!("  file: {}", report.expand.file);
17493        }
17494    }
17495
17496    Ok(())
17497}
17498
17499#[allow(clippy::too_many_arguments)]
17500fn cmd_source_read(
17501    file: &Path,
17502    path: &Path,
17503    style: SourceReadStyle,
17504    start: usize,
17505    lines: usize,
17506    end: Option<usize>,
17507    scope: Option<&str>,
17508    format: OutputFormat,
17509    absolute: bool,
17510    budget: ResponseBudget,
17511) -> Result<()> {
17512    if start == 0 {
17513        bail!("--start is 1-based and must be greater than zero");
17514    }
17515    if lines == 0 {
17516        bail!("--lines must be greater than zero");
17517    }
17518    if let Some(end) = end
17519        && end < start
17520    {
17521        bail!("--end must be greater than or equal to --start");
17522    }
17523
17524    let root = lint::resolve_project_root_or_canonical_path(path)?;
17525    let file_abs = resolve_source_file(&root, file)?;
17526    let file_display = if absolute {
17527        file_abs.to_string_lossy().to_string()
17528    } else {
17529        relativize_pathbuf(&file_abs, &root)
17530            .to_string_lossy()
17531            .to_string()
17532    };
17533
17534    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17535    let text = String::from_utf8_lossy(&source);
17536    let all_lines: Vec<&str> = text.lines().collect();
17537    let total_lines = all_lines.len();
17538    if total_lines > 0 && start > total_lines {
17539        bail!(
17540            "--start {} is beyond end of {} ({} lines)",
17541            start,
17542            file_display,
17543            total_lines
17544        );
17545    }
17546    let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
17547    let end_line = requested_end.min(total_lines);
17548    let mut warnings = Vec::new();
17549    let max_items = budget.preview_items();
17550    let max_bytes = budget.preview_bytes();
17551    if style == SourceReadStyle::Ast {
17552        let symbols = load_source_symbols(
17553            &root,
17554            &file_abs,
17555            &file_display,
17556            &source,
17557            scope,
17558            start,
17559            end_line,
17560            max_items,
17561            max_bytes,
17562            &mut warnings,
17563        );
17564        let summaries =
17565            load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17566        let markdown = if is_markdown_path(&file_abs) {
17567            match source_read_markdown_projection(
17568                &root,
17569                &file_display,
17570                &source,
17571                start,
17572                end_line,
17573                budget,
17574            ) {
17575                Ok(markdown) => Some(markdown),
17576                Err(err) => {
17577                    warnings.push(format!("markdown projection unavailable: {err:#}"));
17578                    None
17579                }
17580            }
17581        } else {
17582            None
17583        };
17584        let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
17585        let report = SourceReadAstReport {
17586            handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
17587            root: root.to_string_lossy().to_string(),
17588            file: file_display.clone(),
17589            range: SourceRangePreview {
17590                start,
17591                end: end_line,
17592                total_lines,
17593                truncated_before: start > 1,
17594                truncated_after: end_line < total_lines,
17595            },
17596            symbols,
17597            summaries,
17598            markdown,
17599            expand: SourceReadAstExpandCommands {
17600                window: source_read_window_command(&root, &file_display, start, window_lines),
17601                file_window: source_read_window_command(
17602                    &root,
17603                    &file_display,
17604                    1,
17605                    total_lines.max(window_lines),
17606                ),
17607                markdown_ast: is_markdown_path(&file_abs)
17608                    .then(|| markdown_ast_command(&root, &file_display, None)),
17609            },
17610            warnings,
17611        };
17612
17613        if format.json_output {
17614            let truncated = report.range.truncated_before
17615                || report.range.truncated_after
17616                || report.symbols.len() >= max_items
17617                || report.summaries.len() >= max_items;
17618            let follow_up = [
17619                Some(report.expand.window.clone()),
17620                Some(report.expand.file_window.clone()),
17621                report.expand.markdown_ast.clone(),
17622            ]
17623            .into_iter()
17624            .flatten()
17625            .collect::<Vec<_>>();
17626            print_json_or_envelope(
17627                &report,
17628                &format,
17629                "source-read",
17630                "ast",
17631                ToolEnvelopeSummary {
17632                    text: format!(
17633                        "source ast {}:{}-{}",
17634                        report.file, report.range.start, report.range.end
17635                    ),
17636                    metrics: vec![
17637                        envelope_metric("symbols", report.symbols.len()),
17638                        envelope_metric("summaries", report.summaries.len()),
17639                        envelope_metric(
17640                            "markdown_nodes",
17641                            report
17642                                .markdown
17643                                .as_ref()
17644                                .map_or(0, |markdown| markdown.visible_nodes),
17645                        ),
17646                    ],
17647                },
17648                truncated,
17649                follow_up,
17650            )?;
17651        } else if format.compact {
17652            println!(
17653                "source-ast {}:{}-{} / {} handle:{}",
17654                report.file,
17655                report.range.start,
17656                report.range.end,
17657                report.range.total_lines,
17658                report.handle
17659            );
17660            for symbol in &report.symbols {
17661                println!(
17662                    "  {} {}:{} {}",
17663                    symbol.name, symbol.file, symbol.line, symbol.expand
17664                );
17665            }
17666            if !report.summaries.is_empty() {
17667                println!("summaries[{}]", report.summaries.len());
17668            }
17669            for warning in &report.warnings {
17670                eprintln!("warning: {warning}");
17671            }
17672        } else {
17673            println!(
17674                "Source AST `{}` lines {}-{} of {} ({})",
17675                report.file,
17676                report.range.start,
17677                report.range.end,
17678                report.range.total_lines,
17679                report.handle
17680            );
17681            if !report.symbols.is_empty() {
17682                println!();
17683                println!("Symbol refs:");
17684                for symbol in &report.symbols {
17685                    println!(
17686                        "  {} `{}` {}:{} — {}",
17687                        symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17688                    );
17689                }
17690            }
17691            if !report.summaries.is_empty() {
17692                println!();
17693                println!("Summary refs:");
17694                for summary in &report.summaries {
17695                    println!(
17696                        "  {} `{}` — {}",
17697                        summary.handle, summary.symbol_name, summary.expand
17698                    );
17699                }
17700            }
17701            println!();
17702            println!("Expand:");
17703            println!("  window:      {}", report.expand.window);
17704            println!("  file window: {}", report.expand.file_window);
17705            if let Some(markdown_ast) = &report.expand.markdown_ast {
17706                println!("  markdown:    {}", markdown_ast);
17707            }
17708            for warning in &report.warnings {
17709                eprintln!("warning: {warning}");
17710            }
17711        }
17712
17713        return Ok(());
17714    }
17715    let max_bytes = budget.preview_bytes();
17716    let token_cap = budget.body_token_cap();
17717    let (preview, preview_end, body_truncated) = if total_lines == 0 {
17718        (Vec::new(), end_line, false)
17719    } else {
17720        let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17721        (capped.preview, capped.capped_end, capped.was_capped)
17722    };
17723    let effective_end = if body_truncated {
17724        preview_end
17725    } else {
17726        end_line
17727    };
17728
17729    if body_truncated {
17730        warnings.push(format!(
17731            "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17732        ));
17733    }
17734    let symbols = load_source_symbols(
17735        &root,
17736        &file_abs,
17737        &file_display,
17738        &source,
17739        scope,
17740        start,
17741        effective_end,
17742        max_items,
17743        max_bytes,
17744        &mut warnings,
17745    );
17746    let summaries =
17747        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17748    let markdown = if is_markdown_path(&file_abs) {
17749        match source_read_markdown_projection(
17750            &root,
17751            &file_display,
17752            &source,
17753            start,
17754            effective_end,
17755            budget,
17756        ) {
17757            Ok(markdown) => Some(markdown),
17758            Err(err) => {
17759                warnings.push(format!("markdown projection unavailable: {err:#}"));
17760                None
17761            }
17762        }
17763    } else {
17764        None
17765    };
17766
17767    let expand = SourceExpandCommands {
17768        before: (start > 1).then(|| {
17769            let before_start = start.saturating_sub(lines).max(1);
17770            source_read_window_command(&root, &file_display, before_start, start - before_start)
17771        }),
17772        after: (effective_end < total_lines)
17773            .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17774        body: body_truncated.then(|| {
17775            let remaining = end_line.saturating_sub(effective_end);
17776            source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17777        }),
17778        file: source_read_ast_command(&root, &file_display),
17779        markdown_ast: is_markdown_path(&file_abs)
17780            .then(|| markdown_ast_command(&root, &file_display, None)),
17781    };
17782
17783    let report = SourceReadReport {
17784        handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17785        root: root.to_string_lossy().to_string(),
17786        file: file_display,
17787        range: SourceRangePreview {
17788            start,
17789            end: effective_end,
17790            total_lines,
17791            truncated_before: start > 1,
17792            truncated_after: effective_end < total_lines,
17793        },
17794        preview,
17795        symbols,
17796        summaries,
17797        markdown,
17798        expand,
17799        warnings,
17800    };
17801
17802    if format.json_output {
17803        let truncated = report.range.truncated_before || report.range.truncated_after;
17804        let follow_up = [
17805            report.expand.before.clone(),
17806            report.expand.after.clone(),
17807            report.expand.body.clone(),
17808            Some(report.expand.file.clone()),
17809            report.expand.markdown_ast.clone(),
17810        ]
17811        .into_iter()
17812        .flatten()
17813        .collect::<Vec<_>>();
17814        print_json_or_envelope(
17815            &report,
17816            &format,
17817            "source-read",
17818            "window",
17819            ToolEnvelopeSummary {
17820                text: format!(
17821                    "source window {}:{}-{}",
17822                    report.file, report.range.start, report.range.end
17823                ),
17824                metrics: vec![
17825                    envelope_metric("lines", report.preview.len()),
17826                    envelope_metric("symbols", report.symbols.len()),
17827                    envelope_metric("summaries", report.summaries.len()),
17828                    envelope_metric(
17829                        "markdown_nodes",
17830                        report
17831                            .markdown
17832                            .as_ref()
17833                            .map_or(0, |markdown| markdown.visible_nodes),
17834                    ),
17835                ],
17836            },
17837            truncated,
17838            follow_up,
17839        )?;
17840    } else if format.compact {
17841        println!(
17842            "source {}:{}-{} / {} handle:{}",
17843            report.file,
17844            report.range.start,
17845            report.range.end,
17846            report.range.total_lines,
17847            report.handle
17848        );
17849        for line in &report.preview {
17850            println!("{:>5} {}", line.line, line.text);
17851        }
17852        if !report.symbols.is_empty() {
17853            println!("syms[{}]:", report.symbols.len());
17854            for symbol in &report.symbols {
17855                println!("  {} {}:{}", symbol.name, symbol.file, symbol.line);
17856            }
17857        }
17858        if report.range.truncated_before || report.range.truncated_after {
17859            println!("expand: {}", report.expand.file);
17860        }
17861    } else {
17862        println!(
17863            "Source window `{}` lines {}-{} of {} ({})",
17864            report.file,
17865            report.range.start,
17866            report.range.end,
17867            report.range.total_lines,
17868            report.handle
17869        );
17870        for line in &report.preview {
17871            println!("{:>5} | {}", line.line, line.text);
17872        }
17873        if !report.symbols.is_empty() {
17874            println!();
17875            println!("Symbol refs:");
17876            for symbol in &report.symbols {
17877                println!(
17878                    "  {} `{}` {}:{} — {}",
17879                    symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17880                );
17881            }
17882        }
17883        if !report.summaries.is_empty() {
17884            println!();
17885            println!("Summary refs:");
17886            for summary in &report.summaries {
17887                println!(
17888                    "  {} `{}` — {}",
17889                    summary.handle, summary.symbol_name, summary.expand
17890                );
17891            }
17892        }
17893        if report.range.truncated_before || report.range.truncated_after {
17894            println!();
17895            println!("Expand:");
17896            if let Some(before) = &report.expand.before {
17897                println!("  before: {}", before);
17898            }
17899            if let Some(after) = &report.expand.after {
17900                println!("  after: {}", after);
17901            }
17902            println!("  file:   {}", report.expand.file);
17903        }
17904        for warning in &report.warnings {
17905            eprintln!("warning: {warning}");
17906        }
17907    }
17908
17909    Ok(())
17910}
17911
17912#[allow(clippy::too_many_arguments)]
17913fn cmd_symbol_read(
17914    symbol: &str,
17915    file_hint: Option<&Path>,
17916    path: &Path,
17917    scope: Option<&str>,
17918    format: OutputFormat,
17919    absolute: bool,
17920    budget: ResponseBudget,
17921) -> Result<()> {
17922    let root = lint::resolve_project_root_or_canonical_path(path)?;
17923    let hinted_file_abs = file_hint
17924        .map(|file| resolve_source_file(&root, file))
17925        .transpose()?;
17926    let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17927    // Build/refresh the per-package cargo index on demand so symbol-read resolves
17928    // symbols in any workspace member, not only ones a prior graph/search query
17929    // indexed. Previously this checked existence only and bailed with "no index
17930    // found" for never-queried members despite a fresh `tsift status`
17931    // (#cargoidxcov).
17932    let target = resolve_query_index_target(&root, path_hint, scope)?;
17933    ensure_query_index_current(&root, &target)?;
17934    let db_path = target.db_path;
17935    if !db_path.exists() {
17936        bail!(
17937            "index refs unavailable: no index found at {}",
17938            db_path.display()
17939        );
17940    }
17941    let db = index::IndexDb::open_read_only_resilient(&db_path)
17942        .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17943    let search_limit = budget.follow_up_items().max(10);
17944    let hits = db
17945        .symbol_search(symbol, search_limit)
17946        .with_context(|| format!("searching symbols for {symbol:?}"))?;
17947    let selected = hits
17948        .into_iter()
17949        .find(|hit| {
17950            let Some(hinted_file_abs) = &hinted_file_abs else {
17951                return true;
17952            };
17953            resolve_source_file(&root, Path::new(&hit.file))
17954                .map(|hit_file| hit_file == *hinted_file_abs)
17955                .unwrap_or(false)
17956        })
17957        .with_context(|| {
17958            let hint = file_hint
17959                .map(|file| format!(" in {}", file.display()))
17960                .unwrap_or_default();
17961            format!("no indexed symbol matched {symbol:?}{hint}")
17962        })?;
17963
17964    let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17965    let file_display = if absolute {
17966        file_abs.to_string_lossy().to_string()
17967    } else {
17968        relativize_pathbuf(&file_abs, &root)
17969            .to_string_lossy()
17970            .to_string()
17971    };
17972    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17973    let content_hash = blake3::hash(&source).to_hex().to_string();
17974    let text = String::from_utf8_lossy(&source);
17975    let all_lines: Vec<&str> = text.lines().collect();
17976    let total_lines = all_lines.len();
17977    let file_symbols = db
17978        .symbols_for_file(&file_abs.to_string_lossy())
17979        .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17980    let max_items = budget.preview_items();
17981    let max_bytes = budget.preview_bytes();
17982    let selected_start = symbol_hit_line(&selected);
17983    let selected_end = symbol_hit_end_line(&selected)
17984        .unwrap_or(selected_start)
17985        .max(selected_start);
17986    let stored_target = file_symbols.iter().find(|candidate| {
17987        candidate.name == selected.name
17988            && candidate.kind == selected.kind
17989            && source_symbol_line(candidate) == selected_start
17990    });
17991    let target_span = stored_target
17992        .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17993        .or_else(|| symbol_hit_ast_span(&selected, &source));
17994    let target_start = target_span
17995        .as_ref()
17996        .map(|span| span.start_line)
17997        .unwrap_or(selected_start);
17998    let target_end = target_span
17999        .as_ref()
18000        .map(|span| span.end_line)
18001        .or_else(|| stored_target.and_then(source_symbol_end_line))
18002        .unwrap_or(selected_end)
18003        .max(target_start);
18004    let target_bounds = stored_target
18005        .and_then(stored_symbol_span_bounds)
18006        .or_else(|| symbol_hit_span_bounds(&selected));
18007    let target_end = stored_target
18008        .and_then(source_symbol_end_line)
18009        .unwrap_or(target_end)
18010        .max(target_start);
18011    let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
18012    let line_capped_end = target_start
18013        .saturating_add(body_line_budget)
18014        .saturating_sub(1)
18015        .min(target_end)
18016        .min(total_lines.max(target_start));
18017    let token_cap = budget.body_token_cap();
18018    let (body, effective_preview_end, body_truncated) =
18019        if total_lines == 0 || target_start > total_lines {
18020            (Vec::new(), line_capped_end, false)
18021        } else {
18022            let capped = build_token_capped_preview(
18023                &all_lines,
18024                target_start,
18025                line_capped_end,
18026                max_bytes,
18027                token_cap,
18028            );
18029            (capped.preview, capped.capped_end, capped.was_capped)
18030        };
18031    let preview_end = if body_truncated {
18032        effective_preview_end
18033    } else {
18034        line_capped_end
18035    };
18036    let child_symbols = file_symbols
18037        .iter()
18038        .filter(|candidate| {
18039            if let Some((target_start_byte, target_end_byte)) = target_bounds {
18040                let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
18041                else {
18042                    return false;
18043                };
18044                return candidate_start >= target_start_byte
18045                    && candidate_end <= target_end_byte
18046                    && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
18047            }
18048            let line = source_symbol_line(candidate);
18049            line > target_start && line <= target_end
18050        })
18051        .take(max_items)
18052        .map(|symbol| {
18053            let line = source_symbol_line(symbol);
18054            let end_line = source_symbol_end_line(symbol);
18055            SourceSymbolRef {
18056                handle: stable_handle(
18057                    "ssym",
18058                    &format!("{}:{}:{}", file_display, symbol.name, line),
18059                ),
18060                name: truncate_for_budget(&symbol.name, max_bytes),
18061                kind: symbol.kind.clone(),
18062                language: symbol.language.clone(),
18063                file: file_display.clone(),
18064                line,
18065                end_line,
18066                signature: symbol
18067                    .signature
18068                    .clone()
18069                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
18070                span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
18071                expand: source_symbol_read_command(&root, &symbol.name, &file_display),
18072            }
18073        })
18074        .collect::<Vec<_>>();
18075    let mut warnings = Vec::new();
18076    if body_truncated {
18077        warnings.push(format!(
18078            "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
18079        ));
18080    }
18081    let summaries =
18082        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
18083    let symbol_handle = stable_handle(
18084        "sread",
18085        &format!("{}:{}:{}", file_display, selected.name, target_start),
18086    );
18087    let source_lines = preview_end
18088        .saturating_sub(target_start)
18089        .saturating_add(1)
18090        .max(1);
18091    let expand = SymbolReadExpandCommands {
18092        source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
18093        body: body_truncated.then(|| {
18094            let remaining = target_end.saturating_sub(preview_end);
18095            source_read_window_command(&root, &file_display, preview_end + 1, remaining)
18096        }),
18097        file: source_read_ast_command(&root, &file_display),
18098        explain: source_symbol_expand_command(&root, &selected.name),
18099        callers: source_symbol_graph_command(&root, &selected.name, "callers"),
18100        callees: source_symbol_graph_command(&root, &selected.name, "callees"),
18101        markdown_ast: (selected.language == "markdown").then(|| {
18102            markdown_ast_command(
18103                &root,
18104                &file_display,
18105                target_span.as_ref().map(|span| span.handle.as_str()),
18106            )
18107        }),
18108    };
18109    let report = SymbolReadReport {
18110        handle: symbol_handle.clone(),
18111        root: root.to_string_lossy().to_string(),
18112        query: symbol.to_string(),
18113        symbol: SymbolReadTarget {
18114            handle: symbol_handle,
18115            name: selected.name.clone(),
18116            kind: selected.kind.clone(),
18117            language: selected.language.clone(),
18118            file: file_display.clone(),
18119            line: target_start,
18120            end_line: Some(target_end),
18121            signature: stored_target
18122                .and_then(|stored| stored.signature.clone())
18123                .map(|signature| truncate_for_budget(&signature, max_bytes)),
18124            parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
18125            visibility: stored_target.and_then(|stored| stored.visibility.clone()),
18126            span: target_span,
18127        },
18128        range: SourceRangePreview {
18129            start: target_start,
18130            end: preview_end,
18131            total_lines,
18132            truncated_before: false,
18133            truncated_after: preview_end < target_end,
18134        },
18135        body,
18136        child_symbols,
18137        summaries,
18138        expand,
18139        warnings,
18140    };
18141
18142    if format.json_output {
18143        let truncated = report.range.truncated_after
18144            || report.body.iter().any(|line| line.text.len() >= max_bytes)
18145            || report.child_symbols.len() >= max_items;
18146        let follow_up = [
18147            Some(report.expand.source_window.clone()),
18148            report.expand.body.clone(),
18149            Some(report.expand.file.clone()),
18150            Some(report.expand.explain.clone()),
18151            Some(report.expand.callers.clone()),
18152            Some(report.expand.callees.clone()),
18153        ]
18154        .into_iter()
18155        .flatten()
18156        .chain(report.expand.markdown_ast.clone())
18157        .collect::<Vec<_>>();
18158        print_json_or_envelope(
18159            &report,
18160            &format,
18161            "symbol-read",
18162            "symbol",
18163            ToolEnvelopeSummary {
18164                text: format!(
18165                    "symbol {} {}:{}-{}",
18166                    report.symbol.name, report.symbol.file, report.range.start, report.range.end
18167                ),
18168                metrics: vec![
18169                    envelope_metric("body_lines", report.body.len()),
18170                    envelope_metric("child_symbols", report.child_symbols.len()),
18171                    envelope_metric("summaries", report.summaries.len()),
18172                ],
18173            },
18174            truncated,
18175            follow_up,
18176        )?;
18177    } else if format.compact {
18178        println!(
18179            "symbol {} {}:{}-{} handle:{} hash:{}",
18180            report.symbol.name,
18181            report.symbol.file,
18182            report.range.start,
18183            report.range.end,
18184            report.handle,
18185            content_hash
18186        );
18187        for line in &report.body {
18188            println!("{:>5} {}", line.line, line.text);
18189        }
18190        if !report.child_symbols.is_empty() {
18191            println!("children[{}]:", report.child_symbols.len());
18192            for child in &report.child_symbols {
18193                println!("  {} {}:{}", child.name, child.file, child.line);
18194            }
18195        }
18196    } else {
18197        println!(
18198            "Symbol `{}` in `{}` lines {}-{} ({})",
18199            report.symbol.name,
18200            report.symbol.file,
18201            report.range.start,
18202            report.range.end,
18203            report.handle
18204        );
18205        for line in &report.body {
18206            println!("{:>5} | {}", line.line, line.text);
18207        }
18208        if !report.child_symbols.is_empty() {
18209            println!();
18210            println!("Child symbols:");
18211            for child in &report.child_symbols {
18212                println!(
18213                    "  {} `{}` {}:{} — {}",
18214                    child.handle, child.name, child.file, child.line, child.expand
18215                );
18216            }
18217        }
18218        println!();
18219        println!("Expand:");
18220        println!("  source:  {}", report.expand.source_window);
18221        println!("  file:    {}", report.expand.file);
18222        println!("  explain: {}", report.expand.explain);
18223        println!("  callers: {}", report.expand.callers);
18224        println!("  callees: {}", report.expand.callees);
18225        for warning in &report.warnings {
18226            eprintln!("warning: {warning}");
18227        }
18228    }
18229
18230    Ok(())
18231}
18232
18233#[allow(clippy::too_many_arguments)]
18234#[derive(Serialize)]
18235struct ExplainBudgetDefinitionPreview {
18236    handle: String,
18237    #[serde(skip_serializing_if = "Option::is_none")]
18238    tag_alias: Option<String>,
18239    kind: String,
18240    name: String,
18241    file: String,
18242    line: i64,
18243    expand: String,
18244}
18245
18246#[derive(Serialize)]
18247struct ExplainBudgetEdgePreview {
18248    handle: String,
18249    #[serde(skip_serializing_if = "Option::is_none")]
18250    tag_alias: Option<String>,
18251    name: String,
18252    file: String,
18253    line: i64,
18254    expand: String,
18255}
18256
18257#[derive(Serialize)]
18258struct ExplainBudgetCommunityPreview {
18259    size: usize,
18260    members: Vec<String>,
18261}
18262
18263#[derive(Serialize)]
18264struct ExplainBudgetReport {
18265    symbol: String,
18266    max_items: usize,
18267    max_bytes: usize,
18268    definition_total: usize,
18269    callers_total: usize,
18270    callers_truncated_by_limit: bool,
18271    callees_total: usize,
18272    callees_truncated_by_limit: bool,
18273    truncated: bool,
18274    definitions: Vec<ExplainBudgetDefinitionPreview>,
18275    callers: Vec<ExplainBudgetEdgePreview>,
18276    callees: Vec<ExplainBudgetEdgePreview>,
18277    #[serde(skip_serializing_if = "Option::is_none")]
18278    community: Option<ExplainBudgetCommunityPreview>,
18279}
18280
18281#[allow(clippy::too_many_arguments)]
18282pub(crate) fn build_explain_budget_report(
18283    symbol: &str,
18284    _root: &Path,
18285    symbols: &[index::StoredSymbol],
18286    callers: &[index::StoredEdge],
18287    callers_total: usize,
18288    callers_truncated_by_limit: bool,
18289    callees: &[index::StoredEdge],
18290    callees_total: usize,
18291    callees_truncated_by_limit: bool,
18292    community: Option<&graph::Community>,
18293    budget: ResponseBudget,
18294) -> ExplainBudgetReport {
18295    let max_items = budget.preview_items();
18296    let max_bytes = budget.preview_bytes();
18297    let definitions = symbols
18298        .iter()
18299        .take(max_items)
18300        .map(|entry| {
18301            let symbol_ref = build_compact_symbol_ref(
18302                "edef",
18303                &format!(
18304                    "{}:{}:{}:{}",
18305                    entry.kind, entry.name, entry.file, entry.line
18306                ),
18307                &entry.name,
18308                entry.tags.as_deref(),
18309                max_bytes,
18310            );
18311            ExplainBudgetDefinitionPreview {
18312                handle: symbol_ref.handle,
18313                tag_alias: symbol_ref.tag_alias,
18314                kind: entry.kind.clone(),
18315                name: symbol_ref.name,
18316                file: truncate_for_budget(&entry.file, max_bytes),
18317                line: entry.line,
18318                expand: format!(
18319                    "tsift search {} --exact --path {} --limit 20",
18320                    shell_quote(&entry.name),
18321                    shell_quote(&entry.file)
18322                ),
18323            }
18324        })
18325        .collect();
18326    let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
18327        .iter()
18328        .take(max_items)
18329        .map(|entry| {
18330            let symbol_ref = build_compact_symbol_ref(
18331                "ecall",
18332                &format!(
18333                    "{}:{}:{}:{}",
18334                    entry.caller_name, entry.caller_file, entry.call_site_line, symbol
18335                ),
18336                &entry.caller_name,
18337                None,
18338                max_bytes,
18339            );
18340            ExplainBudgetEdgePreview {
18341                handle: symbol_ref.handle,
18342                tag_alias: symbol_ref.tag_alias,
18343                name: symbol_ref.name,
18344                file: truncate_for_budget(&entry.caller_file, max_bytes),
18345                line: entry.call_site_line,
18346                expand: format!(
18347                    "tsift explain {} --path {} --limit 0",
18348                    shell_quote(&entry.caller_name),
18349                    shell_quote(&entry.caller_file)
18350                ),
18351            }
18352        })
18353        .collect();
18354    let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
18355        .iter()
18356        .take(max_items)
18357        .map(|entry| {
18358            let symbol_ref = build_compact_symbol_ref(
18359                "eces",
18360                &format!(
18361                    "{}:{}:{}:{}",
18362                    entry.callee_name, entry.caller_file, entry.call_site_line, symbol
18363                ),
18364                &entry.callee_name,
18365                None,
18366                max_bytes,
18367            );
18368            ExplainBudgetEdgePreview {
18369                handle: symbol_ref.handle,
18370                tag_alias: symbol_ref.tag_alias,
18371                name: symbol_ref.name,
18372                file: truncate_for_budget(&entry.caller_file, max_bytes),
18373                line: entry.call_site_line,
18374                expand: format!(
18375                    "tsift explain {} --path {} --limit 0",
18376                    shell_quote(&entry.callee_name),
18377                    shell_quote(&entry.caller_file)
18378                ),
18379            }
18380        })
18381        .collect();
18382    let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
18383        size: entry.members.len(),
18384        members: entry
18385            .members
18386            .iter()
18387            .take(max_items)
18388            .map(|member| truncate_for_budget(&member.name, max_bytes))
18389            .collect(),
18390    });
18391
18392    ExplainBudgetReport {
18393        symbol: symbol.to_string(),
18394        max_items,
18395        max_bytes,
18396        definition_total: symbols.len(),
18397        callers_total,
18398        callers_truncated_by_limit,
18399        callees_total,
18400        callees_truncated_by_limit,
18401        truncated: symbols.len() > max_items
18402            || callers_total > callers_preview.len()
18403            || callees_total > callees_preview.len()
18404            || community
18405                .map(|entry| entry.members.len() > max_items)
18406                .unwrap_or(false),
18407        definitions,
18408        callers: callers_preview,
18409        callees: callees_preview,
18410        community: community_preview,
18411    }
18412}
18413
18414pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
18415    println!(
18416        "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
18417        shell_quote(&report.symbol),
18418        report.definitions.len(),
18419        report.definition_total,
18420        report.callers.len(),
18421        report.callers_total,
18422        report.callees.len(),
18423        report.callees_total
18424    );
18425    for entry in &report.definitions {
18426        println!(
18427            "def {} {} {}:{} expand:{}",
18428            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18429            entry.kind,
18430            entry.file,
18431            entry.line,
18432            entry.expand
18433        );
18434    }
18435    for entry in &report.callers {
18436        println!(
18437            "caller {} {}:{} expand:{}",
18438            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18439            entry.file,
18440            entry.line,
18441            entry.expand
18442        );
18443    }
18444    for entry in &report.callees {
18445        println!(
18446            "callee {} {}:{} expand:{}",
18447            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18448            entry.file,
18449            entry.line,
18450            entry.expand
18451        );
18452    }
18453    if let Some(community) = &report.community {
18454        println!(
18455            "community size:{} members:{}",
18456            community.size,
18457            community.members.join(", ")
18458        );
18459    }
18460    if report.truncated {
18461        println!(
18462            "budget truncated items:{} bytes:{}",
18463            report.max_items, report.max_bytes
18464        );
18465    }
18466}
18467
18468/// Reconcile the tsift symbol index against the tagpath `.naming/index.json`
18469/// source set and report files covered by one but not the other.
18470///
18471/// Today silent recall loss happens when tagpath's `[exclude]` / `extends`
18472/// chain or its hard-coded `SKIP_DIRS` skip files or languages that tsift
18473/// still indexes — the tsift symbols in those files cannot resolve a
18474/// `tagpath_handle` even with a fresh tagpath index. This audit surfaces
18475/// the diff so operators can decide whether to broaden the tagpath walk,
18476/// add an `[exclude]` to tsift, or accept the gap.
18477const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
18478    ".git",
18479    "node_modules",
18480    "target",
18481    "__pycache__",
18482    ".venv",
18483    "vendor",
18484];
18485
18486const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
18487    "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
18488    "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
18489    "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
18490    "sh", "bash", "zsh", "sql", "css", "tsx",
18491];
18492
18493pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
18494    let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
18495        .iter()
18496        .map(|ext| (*ext).to_string())
18497        .collect::<BTreeSet<_>>();
18498
18499    let config_path = root.join(".naming.toml");
18500    if !config_path.exists() {
18501        return extensions;
18502    }
18503
18504    match tagpath::config::resolve(&config_path) {
18505        Ok(config) => {
18506            if let Some(grammars) = config.grammars {
18507                for grammar in grammars.languages.values() {
18508                    for ext in &grammar.extensions {
18509                        if let Some(normalized) = normalize_extension(ext) {
18510                            extensions.insert(normalized);
18511                        }
18512                    }
18513                }
18514            }
18515        }
18516        Err(err) => {
18517            eprintln!("tagpath_policy_hint_config_unreadable: {err}");
18518        }
18519    }
18520    extensions
18521}
18522
18523pub(crate) fn tagpath_audit_policy_hints(
18524    rel_path: &str,
18525    supported_extensions: &BTreeSet<String>,
18526) -> Vec<String> {
18527    let path = Path::new(rel_path);
18528    let mut hints = BTreeSet::new();
18529    if let Some(parent) = path.parent() {
18530        for component in parent.components() {
18531            if let std::path::Component::Normal(name) = component {
18532                let name = name.to_string_lossy();
18533                if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
18534                    hints.insert(format!("skip_dir:{name}"));
18535                }
18536            }
18537        }
18538    }
18539    if path
18540        .extension()
18541        .and_then(|ext| ext.to_str())
18542        .and_then(normalize_extension)
18543        .is_some_and(|ext| !supported_extensions.contains(&ext))
18544    {
18545        hints.insert("extension_unsupported".to_string());
18546    }
18547    hints.into_iter().collect()
18548}
18549
18550fn normalize_extension(ext: &str) -> Option<String> {
18551    let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
18552    if normalized.is_empty() {
18553        None
18554    } else {
18555        Some(normalized)
18556    }
18557}
18558
18559pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
18560    match status {
18561        diff_digest::DiffDigestFileStatus::Added => "added",
18562        diff_digest::DiffDigestFileStatus::Modified => "modified",
18563        diff_digest::DiffDigestFileStatus::Deleted => "deleted",
18564    }
18565}
18566
18567pub(crate) fn diff_digest_summary_label(
18568    state: diff_digest::DiffDigestSummaryState,
18569) -> &'static str {
18570    match state {
18571        diff_digest::DiffDigestSummaryState::Current => "current",
18572        diff_digest::DiffDigestSummaryState::Stale => "stale",
18573        diff_digest::DiffDigestSummaryState::Missing => "missing",
18574        diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
18575    }
18576}
18577
18578fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
18579    match state {
18580        test_digest::TestDigestSummaryState::Current => "current",
18581        test_digest::TestDigestSummaryState::Stale => "stale",
18582        test_digest::TestDigestSummaryState::Missing => "missing",
18583        test_digest::TestDigestSummaryState::Unavailable => "unavailable",
18584    }
18585}
18586
18587fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
18588    match state {
18589        log_digest::LogDigestSummaryState::Current => "current",
18590        log_digest::LogDigestSummaryState::Stale => "stale",
18591        log_digest::LogDigestSummaryState::Missing => "missing",
18592        log_digest::LogDigestSummaryState::Unavailable => "unavailable",
18593    }
18594}
18595
18596pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
18597    match mode {
18598        diff_digest::DiffDigestMode::WorkingTree => "worktree",
18599        diff_digest::DiffDigestMode::Cached => "cached",
18600        diff_digest::DiffDigestMode::Revision => "revision",
18601    }
18602}
18603
18604pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
18605    match (&report.mode, &report.revision) {
18606        (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
18607        (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
18608        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18609            format!("revision {revision}")
18610        }
18611        (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
18612    }
18613}
18614
18615pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
18616    match (&report.mode, &report.revision) {
18617        (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
18618        (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
18619        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18620            format!("No diff found for revision {revision}.")
18621        }
18622        (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
18623    }
18624}
18625
18626fn cmd_impact(
18627    path: &Path,
18628    cached: bool,
18629    revision: Option<&str>,
18630    scope: Option<&str>,
18631    limit: usize,
18632    format: OutputFormat,
18633) -> Result<()> {
18634    let report = impact::compute(
18635        path,
18636        impact::ImpactOptions {
18637            cached,
18638            revision,
18639            scope,
18640            limit,
18641        },
18642    )?;
18643    if format.json_output {
18644        println!(
18645            "{}",
18646            to_json_schema(
18647                &report,
18648                format.pretty,
18649                format.terse,
18650                format.ultra_terse,
18651                format.schema
18652            )?
18653        );
18654        return Ok(());
18655    }
18656
18657    if format.compact {
18658        println!(
18659            "impact mode:{} changed:{} symbols:{} tests:{}/{}",
18660            diff_digest_mode_label(report.mode),
18661            report.changed_files.len(),
18662            report.changed_symbols.len(),
18663            report.affected_tests.len(),
18664            report.affected_tests_total
18665        );
18666        for target in &report.affected_tests {
18667            println!(
18668                "{} reasons:{} command:{}",
18669                target.path,
18670                target.reasons.len(),
18671                target.commands.join(" && ")
18672            );
18673        }
18674        for warning in &report.warnings {
18675            println!("warning {warning}");
18676        }
18677        return Ok(());
18678    }
18679
18680    println!("Impact ({})", diff_digest_mode_label(report.mode));
18681    println!("  changed files:          {}", report.changed_files.len());
18682    println!("  changed symbols:        {}", report.changed_symbols.len());
18683    println!(
18684        "  affected tests:         {}/{}",
18685        report.affected_tests.len(),
18686        report.affected_tests_total
18687    );
18688    for target in &report.affected_tests {
18689        println!();
18690        println!("{}", target.path);
18691        for reason in &target.reasons {
18692            println!("  - {reason}");
18693        }
18694        if !target.symbols.is_empty() {
18695            println!("  symbols: {}", target.symbols.join(", "));
18696        }
18697        for command in &target.commands {
18698            println!("  run: {}", command);
18699        }
18700    }
18701    for warning in &report.warnings {
18702        println!("warning: {warning}");
18703    }
18704    Ok(())
18705}
18706
18707pub(crate) fn render_test_digest_from_input(
18708    path: &Path,
18709    input: &str,
18710    runner: Option<&str>,
18711    format: OutputFormat,
18712) -> Result<()> {
18713    let report = test_digest::compute(path, input, runner)?;
18714    if format.json_output {
18715        println!(
18716            "{}",
18717            to_json_schema(
18718                &report,
18719                format.pretty,
18720                format.terse,
18721                format.ultra_terse,
18722                format.schema
18723            )?
18724        );
18725        return Ok(());
18726    }
18727
18728    if report.failure_groups.is_empty() {
18729        println!("No failures detected (runner: {}).", report.runner);
18730        for warning in &report.warnings {
18731            println!("warning: {warning}");
18732        }
18733        return Ok(());
18734    }
18735
18736    if format.compact {
18737        println!(
18738            "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18739            report.runner,
18740            report.failures,
18741            report.grouped_failures,
18742            report.counts.passed.unwrap_or(0),
18743            report.counts.failed.unwrap_or(report.grouped_failures),
18744            report.counts.skipped.unwrap_or(0),
18745        );
18746        for failure in &report.failure_groups {
18747            let tests = truncate_for_compact(&failure.tests.join(","), 60);
18748            let location = match (&failure.path, failure.line) {
18749                (Some(path), Some(line)) => format!("{path}:{line}"),
18750                (Some(path), None) => path.clone(),
18751                _ => "-".to_string(),
18752            };
18753            println!(
18754                "{} tests:{} count:{} summaries:{} msg:{}",
18755                location,
18756                tests,
18757                failure.occurrences,
18758                test_digest_summary_label(failure.summary_state),
18759                truncate_for_compact(&failure.message, 80)
18760            );
18761        }
18762        for warning in &report.warnings {
18763            println!("warning: {warning}");
18764        }
18765        return Ok(());
18766    }
18767
18768    println!("Test digest ({})", report.runner);
18769    println!("  failures:        {}", report.failures);
18770    println!("  failure groups:  {}", report.grouped_failures);
18771    if let Some(passed) = report.counts.passed {
18772        println!("  passed:          {}", passed);
18773    }
18774    if let Some(failed) = report.counts.failed {
18775        println!("  failed:          {}", failed);
18776    }
18777    if let Some(skipped) = report.counts.skipped {
18778        println!("  skipped:         {}", skipped);
18779    }
18780
18781    for failure in &report.failure_groups {
18782        println!();
18783        match (&failure.path, failure.line, failure.column) {
18784            (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18785            (Some(path), Some(line), None) => println!("{path}:{line}"),
18786            (Some(path), None, _) => println!("{path}"),
18787            (None, _, _) => println!("(no file anchor)"),
18788        }
18789        println!("  tests: {}", failure.tests.join(", "));
18790        println!("  occurrences: {}", failure.occurrences);
18791        println!("  message: {}", failure.message);
18792        println!(
18793            "  cached summaries: {}",
18794            test_digest_summary_label(failure.summary_state)
18795        );
18796        for summary in &failure.current_summaries {
18797            println!(
18798                "    - {}: {}",
18799                summary.symbol,
18800                truncate_for_compact(&summary.summary, 160)
18801            );
18802        }
18803    }
18804    for warning in &report.warnings {
18805        println!("warning: {warning}");
18806    }
18807    Ok(())
18808}
18809
18810#[derive(Clone, Serialize, Deserialize)]
18811struct DispatchTraceSummary {
18812    backlog: usize,
18813    job_packet: usize,
18814    worker_result: usize,
18815    worker_context: usize,
18816    source_handle: usize,
18817    semantic_rows: usize,
18818}
18819
18820#[derive(Clone, Serialize, Deserialize)]
18821struct DispatchTraceReport {
18822    contract_version: String,
18823    root: String,
18824    #[serde(skip_serializing_if = "Option::is_none")]
18825    scope: Option<String>,
18826    targets: Vec<String>,
18827    projection_freshness: GraphDbFreshnessReport,
18828    projection_hashes: Vec<String>,
18829    evidence_packet_ids: Vec<String>,
18830    shared_preparation: ConflictMatrixSharedPreparationSummary,
18831    worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18832    worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18833    summary: DispatchTraceSummary,
18834    nodes: Vec<SubstrateTerseGraphNode>,
18835    edges: Vec<SubstrateTerseGraphEdge>,
18836    conflict_matrix_decisions: Vec<String>,
18837    replay_commands: Vec<String>,
18838    repair_commands: Vec<String>,
18839    truncated: bool,
18840    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18841    warnings: Vec<String>,
18842}
18843
18844fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18845    matches!(
18846        kind,
18847        "session"
18848            | "backlog"
18849            | "job_packet"
18850            | "worker_result"
18851            | "worker_context"
18852            | "source_handle"
18853            | "semantic_concept"
18854            | "semantic_entity"
18855            | "file"
18856            | "symbol"
18857            | "route"
18858    )
18859}
18860
18861fn dispatch_trace_kind_rank(kind: &str) -> usize {
18862    match kind {
18863        "backlog" => 0,
18864        "job_packet" => 1,
18865        "worker_result" => 2,
18866        "worker_context" => 3,
18867        "source_handle" => 4,
18868        "file" => 5,
18869        "symbol" => 6,
18870        "route" => 7,
18871        "semantic_concept" => 8,
18872        "semantic_entity" => 9,
18873        "session" => 10,
18874        _ => 99,
18875    }
18876}
18877
18878fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18879    DispatchTraceSummary {
18880        backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18881        job_packet: nodes
18882            .iter()
18883            .filter(|node| node.kind == "job_packet")
18884            .count(),
18885        worker_result: nodes
18886            .iter()
18887            .filter(|node| node.kind == "worker_result")
18888            .count(),
18889        worker_context: nodes
18890            .iter()
18891            .filter(|node| node.kind == "worker_context")
18892            .count(),
18893        source_handle: nodes
18894            .iter()
18895            .filter(|node| node.kind == "source_handle")
18896            .count(),
18897        semantic_rows: nodes
18898            .iter()
18899            .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18900            .count(),
18901    }
18902}
18903
18904fn dispatch_trace_shared_preparation_summary(
18905    graph_nodes: &[SubstrateGraphNode],
18906    graph_edges: &[SubstrateGraphEdge],
18907    conflict: &ConflictMatrixReport,
18908) -> ConflictMatrixSharedPreparationSummary {
18909    ConflictMatrixSharedPreparationSummary {
18910        evidence_cache_status: conflict
18911            .inputs
18912            .shared_preparation
18913            .evidence_cache_status
18914            .clone(),
18915        graph_nodes: graph_nodes.len(),
18916        graph_edges: graph_edges.len(),
18917        evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18918        source_handles: conflict
18919            .candidates
18920            .iter()
18921            .map(|candidate| candidate.source_handles.len())
18922            .sum(),
18923        worker_context: conflict
18924            .candidates
18925            .iter()
18926            .map(|candidate| candidate.worker_context_handles.len())
18927            .sum(),
18928        worker_results: conflict
18929            .candidates
18930            .iter()
18931            .map(|candidate| candidate.worker_feedback.total)
18932            .sum(),
18933        semantic_rows: conflict
18934            .candidates
18935            .iter()
18936            .map(|candidate| candidate.semantic_related.len())
18937            .sum(),
18938        dispatch_trace_snapshot_nodes: graph_nodes.len(),
18939        dispatch_trace_snapshot_edges: graph_edges.len(),
18940    }
18941}
18942
18943fn dispatch_trace_collect_ids(
18944    targets: &[String],
18945    candidates: &[ConflictMatrixCandidate],
18946    graph_nodes: &[SubstrateGraphNode],
18947    graph_edges: &[SubstrateGraphEdge],
18948    depth: usize,
18949    limit: usize,
18950) -> (BTreeSet<String>, bool) {
18951    let target_refs = targets
18952        .iter()
18953        .map(|target| target.trim_start_matches('#').to_string())
18954        .collect::<BTreeSet<_>>();
18955    let mut ids = BTreeSet::new();
18956    for candidate in candidates {
18957        ids.insert(candidate.target_node_id.clone());
18958        for source in &candidate.source_handles {
18959            ids.insert(source.handle.clone());
18960        }
18961        for handle in &candidate.worker_context_handles {
18962            ids.insert(handle.clone());
18963        }
18964        for semantic in &candidate.semantic_related {
18965            ids.insert(semantic.handle.clone());
18966        }
18967    }
18968    for node in graph_nodes {
18969        if !dispatch_trace_allowed_node_kind(&node.kind) {
18970            continue;
18971        }
18972        if node
18973            .properties
18974            .get("ref_id")
18975            .is_some_and(|ref_id| target_refs.contains(ref_id))
18976        {
18977            ids.insert(node.id.clone());
18978        }
18979    }
18980
18981    let node_by_id = graph_nodes
18982        .iter()
18983        .map(|node| (node.id.as_str(), node))
18984        .collect::<BTreeMap<_, _>>();
18985    let max_nodes = if limit == 0 {
18986        usize::MAX
18987    } else {
18988        limit
18989            .saturating_mul(targets.len().max(1))
18990            .saturating_mul(12)
18991            .max(64)
18992    };
18993    let mut truncated = false;
18994    for _ in 0..depth.max(1) {
18995        let before = ids.len();
18996        let current_ids = ids.clone();
18997        for edge in graph_edges {
18998            if ids.len() >= max_nodes {
18999                truncated = true;
19000                break;
19001            }
19002            let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
19003            if !touches {
19004                continue;
19005            }
19006            for endpoint in [&edge.from_id, &edge.to_id] {
19007                let Some(node) = node_by_id.get(endpoint.as_str()) else {
19008                    continue;
19009                };
19010                if dispatch_trace_allowed_node_kind(&node.kind) {
19011                    ids.insert(endpoint.clone());
19012                }
19013            }
19014        }
19015        if ids.len() == before || truncated {
19016            break;
19017        }
19018    }
19019    (ids, truncated)
19020}
19021
19022#[allow(clippy::too_many_arguments)]
19023fn build_dispatch_trace_report_from_conflict_snapshot(
19024    root: &Path,
19025    scope: Option<&str>,
19026    conflict: ConflictMatrixReport,
19027    graph_nodes: Vec<SubstrateGraphNode>,
19028    graph_edges: Vec<SubstrateGraphEdge>,
19029    depth: usize,
19030    limit: usize,
19031    extra_warnings: Vec<String>,
19032) -> Result<DispatchTraceReport> {
19033    let shared_preparation =
19034        dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
19035    let (ids, truncated) = dispatch_trace_collect_ids(
19036        &conflict.targets,
19037        &conflict.candidates,
19038        &graph_nodes,
19039        &graph_edges,
19040        depth,
19041        limit,
19042    );
19043    let mut nodes = graph_nodes
19044        .into_iter()
19045        .filter(|node| ids.contains(&node.id))
19046        .collect::<Vec<_>>();
19047    nodes.sort_by(|left, right| {
19048        dispatch_trace_kind_rank(&left.kind)
19049            .cmp(&dispatch_trace_kind_rank(&right.kind))
19050            .then(left.id.cmp(&right.id))
19051    });
19052    let node_ids = nodes
19053        .iter()
19054        .map(|node| node.id.as_str())
19055        .collect::<BTreeSet<_>>();
19056    let mut edges = graph_edges
19057        .into_iter()
19058        .filter(|edge| {
19059            node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
19060        })
19061        .collect::<Vec<_>>();
19062    edges.sort_by(|left, right| {
19063        left.from_id
19064            .cmp(&right.from_id)
19065            .then(left.kind.cmp(&right.kind))
19066            .then(left.to_id.cmp(&right.to_id))
19067    });
19068    let mut warnings = conflict.warnings;
19069    warnings.extend(extra_warnings);
19070
19071    Ok(DispatchTraceReport {
19072        contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
19073        root: conflict.root,
19074        scope: conflict.scope,
19075        targets: conflict.targets,
19076        projection_freshness: conflict.orchestration.projection_freshness,
19077        projection_hashes: conflict.orchestration.projection_hashes,
19078        evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
19079        shared_preparation,
19080        worker_prompt_packets: conflict.worker_prompt_packets,
19081        worker_feedback: conflict
19082            .candidates
19083            .iter()
19084            .map(|candidate| candidate.worker_feedback.clone())
19085            .collect(),
19086        summary: dispatch_trace_summary(&nodes),
19087        nodes: nodes.into_iter().map(Into::into).collect(),
19088        edges: edges.into_iter().map(Into::into).collect(),
19089        conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
19090        replay_commands: conflict.next_commands,
19091        repair_commands: graph_db_repair_commands(root, scope),
19092        truncated,
19093        warnings,
19094    })
19095}
19096
19097fn build_dispatch_trace_report(
19098    path: &Path,
19099    scope: Option<&str>,
19100    raw_targets: &[String],
19101    depth: usize,
19102    limit: usize,
19103    impact_limit: usize,
19104) -> Result<DispatchTraceReport> {
19105    let root = lint::resolve_project_root_or_canonical_path(path)?;
19106    let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
19107    if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
19108        write_traversal_graph_store(&root, path, scope)
19109            .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19110    }
19111    let graph_db = graph_substrate_db_path(&root, scope);
19112    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19113        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19114    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19115    let extra_warnings = store
19116        .read_only_recovery()
19117        .map(graph_db_read_recovery_diagnostic)
19118        .into_iter()
19119        .collect::<Vec<_>>();
19120    let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
19121    let graph_prepared = prepare_conflict_matrix_graph_orchestration(
19122        &root,
19123        scope,
19124        "sqlite",
19125        raw_targets,
19126        &prepared,
19127        depth,
19128        limit,
19129        &store,
19130        freshness.clone(),
19131    )?;
19132    let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
19133        &prepared.preparation_cache.source_watermark,
19134        &prepared.preparation_cache.document_watermark,
19135        &prepared.preparation_cache.staged_diff_watermark,
19136        &[
19137            &format!("targets:{}", raw_targets.join(",")),
19138            &format!("depth:{depth}"),
19139            &format!("limit:{limit}"),
19140        ],
19141    );
19142    if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
19143        &root,
19144        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19145        &dt_cache_key,
19146    ) {
19147        return Ok(cached_report);
19148    }
19149    let conflict = build_conflict_matrix_report_from_prepared_graph(
19150        &root,
19151        path,
19152        scope,
19153        depth,
19154        limit,
19155        impact_limit,
19156        freshness,
19157        extra_warnings.clone(),
19158        &prepared,
19159        &graph_prepared,
19160    )?;
19161    let report = build_dispatch_trace_report_from_conflict_snapshot(
19162        &root,
19163        scope,
19164        conflict,
19165        graph_prepared.graph.nodes,
19166        graph_prepared.graph.edges,
19167        depth,
19168        limit,
19169        extra_warnings,
19170    )?;
19171    cycle_packet_cache::cycle_packet_write_cache(
19172        &root,
19173        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19174        &dt_cache_key,
19175        &report,
19176    );
19177    Ok(report)
19178}
19179
19180fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
19181    let json = serde_json::to_string(report)?.replace("</", "<\\/");
19182    let mut html = String::new();
19183    html.push_str(
19184        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
19185    );
19186    html.push_str(
19187        r#"<style>
19188:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
19189@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
19190*{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}}
19191</style>"#,
19192    );
19193    html.push_str("</head><body><div class=\"page\">");
19194    html.push_str(&format!(
19195        "<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>",
19196        html_escape(&report.targets.join(", ")),
19197        report.evidence_packet_ids.len(),
19198        report.nodes.len(),
19199        report.worker_prompt_packets.len(),
19200        html_escape(&report.contract_version)
19201    ));
19202    html.push_str(
19203        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>"#,
19204    );
19205    html.push_str("<script id=\"trace-data\" type=\"application/json\">");
19206    html.push_str(&json);
19207    html.push_str(
19208        r##"</script><script>
19209const report = JSON.parse(document.getElementById("trace-data").textContent);
19210const svg = document.getElementById("graph-canvas");
19211const nodeList = document.getElementById("nodes");
19212const packets = document.getElementById("packets");
19213const feedback = document.getElementById("feedback");
19214const nodes = report.nodes.map((node, index) => ({...node, index}));
19215const nodeById = new Map(nodes.map(node => [node.id, node]));
19216const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
19217const 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"]]);
19218function color(kind){return colorByKind.get(kind)||"#6b7280";}
19219function text(value){return value == null ? "" : String(value);}
19220function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));}
19221function layout(){
19222  const rect = svg.getBoundingClientRect();
19223  const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
19224  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
19225  const counts = new Map();
19226  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
19227  const offsets = new Map();
19228  for (const node of nodes) {
19229    const group = kinds.indexOf(node.kind);
19230    const index = offsets.get(node.kind) || 0;
19231    offsets.set(node.kind, index + 1);
19232    const total = counts.get(node.kind) || 1;
19233    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
19234    const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
19235    node.x = cx + Math.cos(angle) * ring;
19236    node.y = cy + Math.sin(angle) * ring;
19237  }
19238}
19239function draw(){
19240  svg.innerHTML = "";
19241  for (const edge of edges) {
19242    const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
19243    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
19244    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
19245    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
19246    line.setAttribute("class", "edge");
19247    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
19248    svg.appendChild(line);
19249  }
19250  for (const node of nodes) {
19251    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
19252    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
19253    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
19254    circle.setAttribute("fill", color(node.kind));
19255    circle.setAttribute("class", "node");
19256    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
19257    svg.appendChild(circle);
19258    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
19259    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
19260    label.setAttribute("class", "node-label");
19261    label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
19262    svg.appendChild(label);
19263  }
19264}
19265packets.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>";
19266feedback.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>";
19267nodeList.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("");
19268window.addEventListener("resize", () => { layout(); draw(); });
19269layout(); draw();
19270</script></div></body></html>"##,
19271    );
19272    Ok(html)
19273}
19274
19275struct DispatchTraceOptions<'a> {
19276    path: &'a Path,
19277    scope: Option<&'a str>,
19278    raw_targets: &'a [String],
19279    depth: usize,
19280    limit: usize,
19281    impact_limit: usize,
19282    trace_format: DispatchTraceFormat,
19283}
19284
19285fn cmd_dispatch_trace(
19286    options: DispatchTraceOptions<'_>,
19287    output_format: OutputFormat,
19288) -> Result<()> {
19289    let report = build_dispatch_trace_report(
19290        options.path,
19291        options.scope,
19292        options.raw_targets,
19293        options.depth,
19294        options.limit,
19295        options.impact_limit,
19296    )?;
19297    match options.trace_format {
19298        DispatchTraceFormat::Json => {
19299            if output_format.envelope {
19300                print_json_or_envelope(
19301                    &report,
19302                    &output_format,
19303                    "dispatch-trace",
19304                    "operator-review",
19305                    ToolEnvelopeSummary {
19306                        text: format!(
19307                            "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
19308                            report.targets.len(),
19309                            report.nodes.len(),
19310                            report.worker_prompt_packets.len()
19311                        ),
19312                        metrics: vec![
19313                            envelope_metric("targets", report.targets.len()),
19314                            envelope_metric("nodes", report.nodes.len()),
19315                            envelope_metric("edges", report.edges.len()),
19316                            envelope_metric(
19317                                "worker_prompt_packets",
19318                                report.worker_prompt_packets.len(),
19319                            ),
19320                        ],
19321                    },
19322                    report.truncated,
19323                    report.replay_commands.clone(),
19324                )
19325            } else {
19326                println!(
19327                    "{}",
19328                    to_json_schema(
19329                        &report,
19330                        output_format.pretty,
19331                        output_format.terse,
19332                        output_format.ultra_terse,
19333                        output_format.schema
19334                    )?
19335                );
19336                Ok(())
19337            }
19338        }
19339        DispatchTraceFormat::Html => {
19340            println!("{}", dispatch_trace_html(&report)?);
19341            Ok(())
19342        }
19343    }
19344}
19345
19346#[derive(Clone, Debug)]
19347struct DependencyDagProfile {
19348    id: String,
19349    graph_node_id: String,
19350    label: String,
19351    path: Option<String>,
19352    line: Option<i64>,
19353    detail: Option<String>,
19354    source_files: BTreeSet<String>,
19355    source_symbols: BTreeSet<String>,
19356    config_files: BTreeSet<String>,
19357    expected_tests: BTreeSet<String>,
19358    semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
19359    worker_feedback: ConflictMatrixWorkerFeedback,
19360}
19361
19362#[derive(Clone, Debug, Serialize)]
19363struct DependencyDagNode {
19364    id: String,
19365    graph_node_id: String,
19366    label: String,
19367    #[serde(skip_serializing_if = "Option::is_none")]
19368    path: Option<String>,
19369    #[serde(skip_serializing_if = "Option::is_none")]
19370    line: Option<i64>,
19371    #[serde(skip_serializing_if = "Option::is_none")]
19372    detail: Option<String>,
19373    source_files: Vec<String>,
19374    source_symbols: Vec<String>,
19375    config_files: Vec<String>,
19376    expected_tests: Vec<String>,
19377    semantic_refs: Vec<ConflictMatrixSemanticRef>,
19378    worker_feedback: ConflictMatrixWorkerFeedback,
19379}
19380
19381#[derive(Clone, Debug, Serialize)]
19382struct DependencyDagEdge {
19383    from: String,
19384    to: String,
19385    kind: String,
19386    weight: usize,
19387    reasons: Vec<String>,
19388    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19389    shared_files: Vec<String>,
19390    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19391    shared_symbols: Vec<String>,
19392    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19393    shared_tests: Vec<String>,
19394    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19395    shared_config_files: Vec<String>,
19396    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19397    shared_semantic_refs: Vec<String>,
19398}
19399
19400#[derive(Clone, Debug, Serialize)]
19401struct DependencyDagTopoBatch {
19402    batch: usize,
19403    targets: Vec<String>,
19404}
19405
19406#[derive(Clone, Debug, Serialize)]
19407struct DependencyDagCycleDiagnostics {
19408    has_cycles: bool,
19409    blocked_nodes: Vec<String>,
19410    cycle_edges: Vec<DependencyDagEdge>,
19411}
19412
19413#[derive(Serialize)]
19414struct DependencyDagSummary {
19415    nodes: usize,
19416    edges: usize,
19417    topo_batches: usize,
19418    has_cycles: bool,
19419}
19420
19421#[derive(Serialize)]
19422struct DependencyDagReport {
19423    contract_version: &'static str,
19424    root: String,
19425    #[serde(skip_serializing_if = "Option::is_none")]
19426    scope: Option<String>,
19427    path: String,
19428    targets: Vec<String>,
19429    projection_freshness: GraphDbFreshnessReport,
19430    projection_hashes: Vec<String>,
19431    nodes: Vec<DependencyDagNode>,
19432    edges: Vec<DependencyDagEdge>,
19433    topo_batches: Vec<DependencyDagTopoBatch>,
19434    cycle_diagnostics: DependencyDagCycleDiagnostics,
19435    summary: DependencyDagSummary,
19436    replay_commands: Vec<String>,
19437    repair_commands: Vec<String>,
19438    #[serde(skip_serializing_if = "Vec::is_empty", default)]
19439    warnings: Vec<String>,
19440}
19441
19442fn dependency_dag_backlog_node_for_target(
19443    store: &impl GraphStore,
19444    target: &str,
19445) -> Result<SubstrateGraphNode> {
19446    let resolved = graph_db_resolve_evidence_target(store, target)?
19447        .with_context(|| format!("dependency-dag target not found: {target}"))?;
19448    if resolved.kind == "backlog" {
19449        return Ok(resolved);
19450    }
19451    let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
19452        bail!(
19453            "dependency-dag target {} resolved to {} without a backlog ref_id",
19454            target,
19455            resolved.kind
19456        );
19457    };
19458    store
19459        .nodes_by_kind("backlog")?
19460        .into_iter()
19461        .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
19462        .min_by(|left, right| {
19463            left.properties
19464                .get("line")
19465                .and_then(|value| value.parse::<i64>().ok())
19466                .cmp(
19467                    &right
19468                        .properties
19469                        .get("line")
19470                        .and_then(|value| value.parse::<i64>().ok()),
19471                )
19472                .then(left.id.cmp(&right.id))
19473        })
19474        .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
19475}
19476
19477fn dependency_dag_resolve_backlog_nodes(
19478    root: &Path,
19479    path: &Path,
19480    store: &impl GraphStore,
19481    raw_targets: &[String],
19482) -> Result<Vec<SubstrateGraphNode>> {
19483    let mut nodes = Vec::new();
19484    let mut seen = BTreeSet::new();
19485    if raw_targets.is_empty() {
19486        let hinted_path = if path.is_absolute() {
19487            path.to_path_buf()
19488        } else {
19489            root.join(path)
19490        };
19491        let hinted_markdown = hinted_path
19492            .extension()
19493            .and_then(|ext| ext.to_str())
19494            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
19495        let hinted_rel = hinted_markdown.then(|| {
19496            relativize_pathbuf(&hinted_path, root)
19497                .to_string_lossy()
19498                .replace('\\', "/")
19499        });
19500        for node in store.nodes_by_kind("backlog")? {
19501            if let Some(expected_path) = &hinted_rel
19502                && node.properties.get("path") != Some(expected_path)
19503            {
19504                continue;
19505            }
19506            if seen.insert(node.id.clone()) {
19507                nodes.push(node);
19508            }
19509        }
19510        if nodes.is_empty() && hinted_rel.is_some() {
19511            for node in store.nodes_by_kind("backlog")? {
19512                if seen.insert(node.id.clone()) {
19513                    nodes.push(node);
19514                }
19515            }
19516        }
19517    } else {
19518        for target in raw_targets {
19519            let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
19520            let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
19521            if seen.insert(node.id.clone()) {
19522                nodes.push(node);
19523            }
19524        }
19525    }
19526    if nodes.is_empty() {
19527        bail!("dependency-dag needs at least one resolvable backlog id");
19528    }
19529    nodes.sort_by(|left, right| {
19530        left.properties
19531            .get("line")
19532            .and_then(|value| value.parse::<i64>().ok())
19533            .cmp(
19534                &right
19535                    .properties
19536                    .get("line")
19537                    .and_then(|value| value.parse::<i64>().ok()),
19538            )
19539            .then(left.id.cmp(&right.id))
19540    });
19541    Ok(nodes)
19542}
19543
19544fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
19545    node.properties
19546        .get("ref_id")
19547        .cloned()
19548        .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
19549}
19550
19551fn dependency_dag_node_profile(
19552    root: &Path,
19553    store: &impl GraphStore,
19554    node: &SubstrateGraphNode,
19555    graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
19556    graph_edges: &[SubstrateGraphEdge],
19557    depth: usize,
19558    limit: usize,
19559) -> Result<DependencyDagProfile> {
19560    let id = dependency_dag_node_id(node);
19561    let mut source_files = BTreeSet::new();
19562    let mut source_symbols = BTreeSet::new();
19563    for edge in graph_edges
19564        .iter()
19565        .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
19566    {
19567        let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
19568            continue;
19569        };
19570        match target.kind.as_str() {
19571            "file" | "route" => {
19572                if let Some(path) = target.properties.get("path") {
19573                    source_files.insert(path.clone());
19574                }
19575            }
19576            "symbol" => {
19577                source_symbols.insert(target.label.clone());
19578                if let Some(path) = target.properties.get("path") {
19579                    source_files.insert(path.clone());
19580                }
19581            }
19582            _ => {}
19583        }
19584    }
19585
19586    let max_rows = if limit == 0 { usize::MAX } else { limit };
19587    for (source, _) in
19588        graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
19589    {
19590        let terse: SubstrateTerseGraphNode = (&source).into();
19591        if let Some(handle) = conflict_matrix_source_handle(&terse) {
19592            source_files.insert(handle.file);
19593        }
19594    }
19595
19596    let worker_results = graph_nodes_by_id
19597        .values()
19598        .filter(|candidate| {
19599            candidate.kind == "worker_result"
19600                && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
19601        })
19602        .map(SubstrateTerseGraphNode::from)
19603        .collect::<Vec<_>>();
19604    let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
19605    let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
19606    let config_files = source_files
19607        .iter()
19608        .filter(|file| is_planner_config_path(file))
19609        .cloned()
19610        .collect();
19611
19612    let mut semantic_refs = BTreeMap::new();
19613    for kind in ["semantic_concept", "semantic_entity"] {
19614        for (semantic, _) in
19615            graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
19616        {
19617            let terse: SubstrateTerseGraphNode = (&semantic).into();
19618            let item = conflict_matrix_semantic_ref(root, &terse);
19619            semantic_refs
19620                .entry(format!("{}:{}", item.kind, item.label))
19621                .or_insert(item);
19622        }
19623    }
19624
19625    Ok(DependencyDagProfile {
19626        id,
19627        graph_node_id: node.id.clone(),
19628        label: node.label.clone(),
19629        path: node.properties.get("path").cloned(),
19630        line: node
19631            .properties
19632            .get("line")
19633            .and_then(|value| value.parse::<i64>().ok()),
19634        detail: node.properties.get("detail").cloned(),
19635        source_files,
19636        source_symbols,
19637        config_files,
19638        expected_tests,
19639        semantic_refs,
19640        worker_feedback,
19641    })
19642}
19643
19644fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
19645    let lower = text.to_ascii_lowercase();
19646    let mut refs = Vec::new();
19647    for marker in markers {
19648        let mut offset = 0usize;
19649        while let Some(pos) = lower[offset..].find(marker) {
19650            let start = offset + pos + marker.len();
19651            let segment = text[start..]
19652                .split(['\n', '.'])
19653                .next()
19654                .unwrap_or(&text[start..]);
19655            refs.extend(extract_conflict_target_refs(segment));
19656            offset = start;
19657        }
19658    }
19659    dedupe_preserve_order(refs)
19660}
19661
19662fn dependency_dag_push_edge(
19663    edges: &mut Vec<DependencyDagEdge>,
19664    seen: &mut BTreeSet<(String, String, String)>,
19665    edge: DependencyDagEdge,
19666) {
19667    if edge.from == edge.to {
19668        return;
19669    }
19670    if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
19671        edges.push(edge);
19672    }
19673}
19674
19675fn dependency_dag_explicit_edges(
19676    profiles: &[DependencyDagProfile],
19677    target_ids: &BTreeSet<String>,
19678    edges: &mut Vec<DependencyDagEdge>,
19679    seen: &mut BTreeSet<(String, String, String)>,
19680) {
19681    for profile in profiles {
19682        let detail = profile.detail.as_deref().unwrap_or_default();
19683        for dep in dependency_dag_marker_refs(
19684            detail,
19685            &[
19686                "depends on",
19687                "depends-on",
19688                "deps:",
19689                "after",
19690                "blocked by",
19691                "requires",
19692            ],
19693        ) {
19694            if target_ids.contains(&dep) {
19695                dependency_dag_push_edge(
19696                    edges,
19697                    seen,
19698                    DependencyDagEdge {
19699                        from: dep.clone(),
19700                        to: profile.id.clone(),
19701                        kind: "explicit_depends_on".to_string(),
19702                        weight: 1000,
19703                        reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
19704                        shared_files: Vec::new(),
19705                        shared_symbols: Vec::new(),
19706                        shared_tests: Vec::new(),
19707                        shared_config_files: Vec::new(),
19708                        shared_semantic_refs: Vec::new(),
19709                    },
19710                );
19711            }
19712        }
19713        for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
19714            if target_ids.contains(&downstream) {
19715                dependency_dag_push_edge(
19716                    edges,
19717                    seen,
19718                    DependencyDagEdge {
19719                        from: profile.id.clone(),
19720                        to: downstream.clone(),
19721                        kind: "explicit_before".to_string(),
19722                        weight: 900,
19723                        reasons: vec![format!(
19724                            "{} declares it should run before #{downstream}",
19725                            profile.id
19726                        )],
19727                        shared_files: Vec::new(),
19728                        shared_symbols: Vec::new(),
19729                        shared_tests: Vec::new(),
19730                        shared_config_files: Vec::new(),
19731                        shared_semantic_refs: Vec::new(),
19732                    },
19733                );
19734            }
19735        }
19736    }
19737}
19738
19739fn dependency_dag_worker_follow_up_edges(
19740    profiles: &[DependencyDagProfile],
19741    target_ids: &BTreeSet<String>,
19742    edges: &mut Vec<DependencyDagEdge>,
19743    seen: &mut BTreeSet<(String, String, String)>,
19744) {
19745    for profile in profiles {
19746        for follow_up in &profile.worker_feedback.follow_up_ids {
19747            if target_ids.contains(follow_up) {
19748                dependency_dag_push_edge(
19749                    edges,
19750                    seen,
19751                    DependencyDagEdge {
19752                        from: profile.id.clone(),
19753                        to: follow_up.clone(),
19754                        kind: "worker_result_follow_up".to_string(),
19755                        weight: 700,
19756                        reasons: vec![format!(
19757                            "worker_result for #{} references follow-up #{}",
19758                            profile.id, follow_up
19759                        )],
19760                        shared_files: Vec::new(),
19761                        shared_symbols: Vec::new(),
19762                        shared_tests: Vec::new(),
19763                        shared_config_files: Vec::new(),
19764                        shared_semantic_refs: Vec::new(),
19765                    },
19766                );
19767            }
19768        }
19769    }
19770}
19771
19772fn dependency_dag_overlap_edges(
19773    profiles: &[DependencyDagProfile],
19774    edges: &mut Vec<DependencyDagEdge>,
19775    seen: &mut BTreeSet<(String, String, String)>,
19776) {
19777    for left_idx in 0..profiles.len() {
19778        for right_idx in (left_idx + 1)..profiles.len() {
19779            let left = &profiles[left_idx];
19780            let right = &profiles[right_idx];
19781            let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19782            let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19783            let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19784            let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19785            let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19786            let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19787            let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19788            if shared_files.is_empty()
19789                && shared_symbols.is_empty()
19790                && shared_tests.is_empty()
19791                && shared_config_files.is_empty()
19792                && shared_semantic_refs.is_empty()
19793            {
19794                continue;
19795            }
19796            let kind = if shared_files.is_empty()
19797                && shared_symbols.is_empty()
19798                && shared_tests.is_empty()
19799                && shared_config_files.is_empty()
19800            {
19801                "semantic_relation"
19802            } else {
19803                "shared_resource"
19804            };
19805            let mut reasons = Vec::new();
19806            if !shared_files.is_empty() {
19807                reasons.push(format!("shared files: {}", shared_files.join(", ")));
19808            }
19809            if !shared_symbols.is_empty() {
19810                reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19811            }
19812            if !shared_tests.is_empty() {
19813                reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19814            }
19815            if !shared_config_files.is_empty() {
19816                reasons.push(format!(
19817                    "shared config files: {}",
19818                    shared_config_files.join(", ")
19819                ));
19820            }
19821            if !shared_semantic_refs.is_empty() {
19822                reasons.push(format!(
19823                    "shared semantic refs: {}",
19824                    shared_semantic_refs.join(", ")
19825                ));
19826            }
19827            let weight = shared_files.len() * 100
19828                + shared_config_files.len() * 100
19829                + shared_symbols.len() * 40
19830                + shared_tests.len() * 10
19831                + shared_semantic_refs.len() * 5;
19832            dependency_dag_push_edge(
19833                edges,
19834                seen,
19835                DependencyDagEdge {
19836                    from: left.id.clone(),
19837                    to: right.id.clone(),
19838                    kind: kind.to_string(),
19839                    weight,
19840                    reasons,
19841                    shared_files,
19842                    shared_symbols,
19843                    shared_tests,
19844                    shared_config_files,
19845                    shared_semantic_refs,
19846                },
19847            );
19848        }
19849    }
19850}
19851
19852fn dependency_dag_topo_batches(
19853    targets: &[String],
19854    edges: &[DependencyDagEdge],
19855) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19856    let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19857    let order = targets
19858        .iter()
19859        .enumerate()
19860        .map(|(idx, id)| (id.clone(), idx))
19861        .collect::<BTreeMap<_, _>>();
19862    let mut indegree = targets
19863        .iter()
19864        .map(|id| (id.clone(), 0usize))
19865        .collect::<BTreeMap<_, _>>();
19866    let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19867    let mut seen_pairs = BTreeSet::<(String, String)>::new();
19868    for edge in edges {
19869        if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19870            continue;
19871        }
19872        if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19873            continue;
19874        }
19875        *indegree.entry(edge.to.clone()).or_default() += 1;
19876        outgoing
19877            .entry(edge.from.clone())
19878            .or_default()
19879            .push(edge.to.clone());
19880    }
19881    for values in outgoing.values_mut() {
19882        values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19883        values.dedup();
19884    }
19885
19886    let mut processed = BTreeSet::new();
19887    let mut batches = Vec::new();
19888    loop {
19889        let mut ready = targets
19890            .iter()
19891            .filter(|id| !processed.contains(*id))
19892            .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19893            .cloned()
19894            .collect::<Vec<_>>();
19895        ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19896        if ready.is_empty() {
19897            break;
19898        }
19899        for id in &ready {
19900            processed.insert(id.clone());
19901            for next in outgoing.get(id).into_iter().flatten() {
19902                if let Some(value) = indegree.get_mut(next) {
19903                    *value = value.saturating_sub(1);
19904                }
19905            }
19906        }
19907        batches.push(DependencyDagTopoBatch {
19908            batch: batches.len() + 1,
19909            targets: ready,
19910        });
19911    }
19912
19913    let blocked_nodes = targets
19914        .iter()
19915        .filter(|id| !processed.contains(*id))
19916        .cloned()
19917        .collect::<Vec<_>>();
19918    let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19919    let cycle_edges = edges
19920        .iter()
19921        .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19922        .cloned()
19923        .collect::<Vec<_>>();
19924    (
19925        batches,
19926        DependencyDagCycleDiagnostics {
19927            has_cycles: !blocked_nodes.is_empty(),
19928            blocked_nodes,
19929            cycle_edges,
19930        },
19931    )
19932}
19933
19934fn dependency_dag_replay_commands(
19935    path: &Path,
19936    scope: Option<&str>,
19937    targets: &[String],
19938    depth: usize,
19939    limit: usize,
19940) -> Vec<String> {
19941    let target_args = targets
19942        .iter()
19943        .map(|target| shell_quote(target))
19944        .collect::<Vec<_>>()
19945        .join(" ");
19946    let mut command = format!(
19947        "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19948        shell_quote(path.to_string_lossy().as_ref()),
19949        scope
19950            .map(|scope| format!(" --scope {}", shell_quote(scope)))
19951            .unwrap_or_default(),
19952        depth,
19953        limit
19954    );
19955    if !target_args.is_empty() {
19956        command.push(' ');
19957        command.push_str(&target_args);
19958    }
19959    vec![command]
19960}
19961
19962fn build_dependency_dag_report(
19963    path: &Path,
19964    scope: Option<&str>,
19965    raw_targets: &[String],
19966    depth: usize,
19967    limit: usize,
19968) -> Result<DependencyDagReport> {
19969    let root = lint::resolve_project_root_or_canonical_path(path)?;
19970    write_traversal_graph_store(&root, path, scope)
19971        .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19972    let graph_db = graph_substrate_db_path(&root, scope);
19973    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19974        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19975    let mut warnings = Vec::new();
19976    if let Some(recovery) = store.read_only_recovery() {
19977        warnings.push(graph_db_read_recovery_diagnostic(recovery));
19978    }
19979    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19980    if freshness.fail_closed {
19981        bail!(
19982            "dependency-dag graph projection failed closed: {}; repair: {}",
19983            freshness.diagnostics.join("; "),
19984            graph_db_repair_commands(&root, scope).join("; ")
19985        );
19986    }
19987
19988    let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19989    let graph_nodes = store.all_nodes()?;
19990    let graph_edges = store.all_edges()?;
19991    let graph_nodes_by_id = graph_nodes
19992        .into_iter()
19993        .map(|node| (node.id.clone(), node))
19994        .collect::<BTreeMap<_, _>>();
19995    let profiles = target_nodes
19996        .iter()
19997        .map(|node| {
19998            dependency_dag_node_profile(
19999                &root,
20000                &store,
20001                node,
20002                &graph_nodes_by_id,
20003                &graph_edges,
20004                depth,
20005                limit,
20006            )
20007        })
20008        .collect::<Result<Vec<_>>>()?;
20009    let targets = profiles
20010        .iter()
20011        .map(|profile| profile.id.clone())
20012        .collect::<Vec<_>>();
20013    let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
20014
20015    let mut edges = Vec::new();
20016    let mut seen_edges = BTreeSet::new();
20017    dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
20018    dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
20019    dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
20020    edges.sort_by(|left, right| {
20021        left.from
20022            .cmp(&right.from)
20023            .then(left.to.cmp(&right.to))
20024            .then(left.kind.cmp(&right.kind))
20025    });
20026    let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
20027
20028    let nodes = profiles
20029        .into_iter()
20030        .map(|profile| DependencyDagNode {
20031            id: profile.id,
20032            graph_node_id: profile.graph_node_id,
20033            label: profile.label,
20034            path: profile.path,
20035            line: profile.line,
20036            detail: profile.detail,
20037            source_files: sorted_set(&profile.source_files),
20038            source_symbols: sorted_set(&profile.source_symbols),
20039            config_files: sorted_set(&profile.config_files),
20040            expected_tests: sorted_set(&profile.expected_tests),
20041            semantic_refs: profile.semantic_refs.into_values().collect(),
20042            worker_feedback: profile.worker_feedback,
20043        })
20044        .collect::<Vec<_>>();
20045    let projection_hashes = freshness
20046        .content_hash
20047        .clone()
20048        .into_iter()
20049        .collect::<Vec<_>>();
20050    let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
20051    let repair_commands = graph_db_repair_commands(&root, scope);
20052    let summary = DependencyDagSummary {
20053        nodes: nodes.len(),
20054        edges: edges.len(),
20055        topo_batches: topo_batches.len(),
20056        has_cycles: cycle_diagnostics.has_cycles,
20057    };
20058
20059    Ok(DependencyDagReport {
20060        contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
20061        root: root.to_string_lossy().to_string(),
20062        scope: scope.map(str::to_string),
20063        path: path.to_string_lossy().to_string(),
20064        targets,
20065        projection_freshness: freshness,
20066        projection_hashes,
20067        nodes,
20068        edges,
20069        topo_batches,
20070        cycle_diagnostics,
20071        summary,
20072        replay_commands,
20073        repair_commands,
20074        warnings,
20075    })
20076}
20077
20078fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
20079    if compact {
20080        println!(
20081            "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
20082            report.targets.len(),
20083            report.edges.len(),
20084            report.topo_batches.len(),
20085            report.cycle_diagnostics.has_cycles
20086        );
20087    } else {
20088        println!("Dependency DAG");
20089        println!("  targets: {}", report.targets.join(", "));
20090        println!("  edges:   {}", report.edges.len());
20091        println!("  cycles:  {}", report.cycle_diagnostics.has_cycles);
20092    }
20093    for batch in &report.topo_batches {
20094        println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
20095    }
20096    for edge in &report.edges {
20097        println!(
20098            "edge {} -> {} kind:{} weight:{}",
20099            edge.from, edge.to, edge.kind, edge.weight
20100        );
20101        for reason in &edge.reasons {
20102            println!("  reason: {reason}");
20103        }
20104    }
20105    if report.cycle_diagnostics.has_cycles {
20106        println!(
20107            "cycle blocked nodes: {}",
20108            report.cycle_diagnostics.blocked_nodes.join(", ")
20109        );
20110    }
20111    for command in &report.replay_commands {
20112        println!("replay: {command}");
20113    }
20114    for command in &report.repair_commands {
20115        println!("repair: {command}");
20116    }
20117    for warning in &report.warnings {
20118        println!("warning: {warning}");
20119    }
20120}
20121
20122fn cmd_dependency_dag(
20123    path: &Path,
20124    scope: Option<&str>,
20125    raw_targets: &[String],
20126    depth: usize,
20127    limit: usize,
20128    format: OutputFormat,
20129) -> Result<()> {
20130    let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
20131    if format.json_output {
20132        print_json_or_envelope(
20133            &report,
20134            &format,
20135            "dependency-dag",
20136            "topological-planning",
20137            ToolEnvelopeSummary {
20138                text: format!(
20139                    "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
20140                    report.targets.len(),
20141                    report.edges.len(),
20142                    report.topo_batches.len(),
20143                    report.cycle_diagnostics.has_cycles
20144                ),
20145                metrics: vec![
20146                    envelope_metric("targets", report.targets.len()),
20147                    envelope_metric("edges", report.edges.len()),
20148                    envelope_metric("topo_batches", report.topo_batches.len()),
20149                    envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
20150                ],
20151            },
20152            report.cycle_diagnostics.has_cycles,
20153            report.replay_commands.clone(),
20154        )
20155    } else {
20156        print_dependency_dag_human(&report, format.compact);
20157        Ok(())
20158    }
20159}
20160
20161/// Persist a bulky raw log behind an artifact handle and attach it to the
20162/// report, so the bounded digest references the full transcript via a stable
20163/// handle + expansion command instead of losing it (stdin) or relying on
20164/// inlined groups. No-op for small logs or when the artifacts dir is unwritable.
20165fn maybe_attach_log_digest_raw_artifact(
20166    root: &Path,
20167    report: &mut log_digest::LogDigestReport,
20168    input: &str,
20169) -> Result<()> {
20170    if input.trim().is_empty() || !log_digest::raw_log_artifact_recommended(report, input.len()) {
20171        return Ok(());
20172    }
20173    let key = format!("logdigest:{}:{}", report.total_lines, input.len());
20174    let artifact_path = root
20175        .join(".tsift/artifacts")
20176        .join(format!("{}.log", stable_handle("logdg", &key)));
20177    let expand = format!(
20178        "tsift log-digest --path {} --input {} --json",
20179        shell_quote(root.to_string_lossy().as_ref()),
20180        shell_quote(artifact_path.to_string_lossy().as_ref())
20181    );
20182    let artifact = persist_transcript_artifact(root, "logdg", "log", &key, input, expand)?;
20183    report.raw_log_artifact = Some(log_digest::LogDigestArtifactRef {
20184        handle: artifact.handle,
20185        path: artifact.path,
20186        bytes: artifact.bytes,
20187        lines: artifact.lines,
20188        expand: artifact.expand,
20189    });
20190    Ok(())
20191}
20192
20193/// Run the log-digest token-savings + false-negative fixture gate: prove the
20194/// digest both compresses raw cargo/pytest/npm/pnpm/agent-doc logs and preserves
20195/// their real signals. With `fail_under`, exits non-zero on any case miss.
20196pub(crate) fn render_log_digest_fixture(
20197    path: &Path,
20198    fixture_path: &Path,
20199    fail_under: bool,
20200    format: OutputFormat,
20201) -> Result<()> {
20202    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20203    let fixture_body = fs::read_to_string(fixture_path)
20204        .with_context(|| format!("reading log-digest fixture: {}", fixture_path.display()))?;
20205    let fixture: log_digest::LogDigestFixture = serde_json::from_str(&fixture_body)
20206        .with_context(|| format!("parsing log-digest fixture: {}", fixture_path.display()))?;
20207    let report = log_digest::evaluate_fixture(&root, &fixture)?;
20208
20209    if format.json_output {
20210        print_json_or_envelope(
20211            &report,
20212            &format,
20213            "log-digest-fixture",
20214            "report",
20215            ToolEnvelopeSummary {
20216                text: if report.passed {
20217                    format!("log-digest gate passed for {} case(s)", report.total_cases)
20218                } else {
20219                    format!("log-digest gate failed {} case(s)", report.failed_cases)
20220                },
20221                metrics: vec![
20222                    envelope_metric("cases", report.total_cases),
20223                    envelope_metric("failed", report.failed_cases),
20224                    envelope_metric("passed", report.passed),
20225                ],
20226            },
20227            false,
20228            vec![],
20229        )?;
20230    } else {
20231        println!("Log digest fixture gate");
20232        println!("  cases:  {}", report.total_cases);
20233        println!("  failed: {}", report.failed_cases);
20234        println!("  status: {}", if report.passed { "pass" } else { "fail" });
20235        for case in &report.cases {
20236            println!(
20237                "  [{}] {} ({}): savings {:.1}% (min {:.1}%) raw_tok {} digest_tok {}",
20238                if case.passed { "pass" } else { "FAIL" },
20239                case.name,
20240                case.ecosystem,
20241                case.savings_percent,
20242                case.minimum_savings_percent,
20243                case.raw_tokens,
20244                case.digest_tokens
20245            );
20246            if !case.missing_required_signals.is_empty() {
20247                println!(
20248                    "    missing required signals: {}",
20249                    case.missing_required_signals.join(", ")
20250                );
20251            }
20252            if !case.present_forbidden_signals.is_empty() {
20253                println!(
20254                    "    present forbidden signals: {}",
20255                    case.present_forbidden_signals.join(", ")
20256                );
20257            }
20258        }
20259    }
20260
20261    if fail_under && !report.passed {
20262        bail!("log-digest fixture gate failed");
20263    }
20264    Ok(())
20265}
20266
20267pub(crate) fn render_log_digest_from_input(
20268    path: &Path,
20269    input: &str,
20270    format: OutputFormat,
20271) -> Result<()> {
20272    let mut report = log_digest::compute(path, input)?;
20273    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20274    maybe_attach_log_digest_raw_artifact(&root, &mut report, input)?;
20275    if format.json_output {
20276        println!(
20277            "{}",
20278            to_json_schema(
20279                &report,
20280                format.pretty,
20281                format.terse,
20282                format.ultra_terse,
20283                format.schema
20284            )?
20285        );
20286        return Ok(());
20287    }
20288
20289    if format.compact {
20290        println!(
20291            "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
20292            report.non_empty_lines,
20293            report.signal_groups,
20294            report.repeated_line_groups,
20295            report.file_ref_groups,
20296            report.symbol_ref_groups,
20297            report.stack_groups
20298        );
20299        for signal in &report.signals {
20300            let location = match (&signal.path, signal.line) {
20301                (Some(path), Some(line)) => format!("{path}:{line}"),
20302                (Some(path), None) => path.clone(),
20303                _ => "-".to_string(),
20304            };
20305            println!(
20306                "{} sev:{} count:{} sums:{} msg:{}",
20307                location,
20308                signal.severity,
20309                signal.occurrences,
20310                log_digest_summary_label(signal.summary_state),
20311                truncate_for_compact(&signal.message, 80)
20312            );
20313        }
20314        for repeated in &report.repeated_lines {
20315            println!(
20316                "repeat count:{} line:{}",
20317                repeated.occurrences,
20318                truncate_for_compact(&repeated.line, 80)
20319            );
20320        }
20321        for family in &report.line_families {
20322            println!(
20323                "family count:{} variants:{} template:{}",
20324                family.occurrences,
20325                family.variants,
20326                truncate_for_compact(&family.template, 80)
20327            );
20328        }
20329        for symbol in &report.symbol_refs {
20330            println!(
20331                "sym:{} count:{} sums:{}",
20332                symbol.symbol,
20333                symbol.occurrences,
20334                log_digest_summary_label(symbol.summary_state)
20335            );
20336        }
20337        if let Some(artifact) = &report.raw_log_artifact {
20338            println!(
20339                "raw-artifact handle:{} lines:{} bytes:{} expand:{}",
20340                artifact.handle, artifact.lines, artifact.bytes, artifact.expand
20341            );
20342        }
20343        for warning in &report.warnings {
20344            println!("warning: {warning}");
20345        }
20346        return Ok(());
20347    }
20348
20349    println!("Log digest");
20350    println!("  lines:                    {}", report.total_lines);
20351    println!("  non-empty lines:          {}", report.non_empty_lines);
20352    println!("  signal groups:            {}", report.signal_groups);
20353    println!(
20354        "  repeated lines:           {}",
20355        report.repeated_line_groups
20356    );
20357    println!(
20358        "  repeated line instances:  {}",
20359        report.repeated_line_occurrences
20360    );
20361    println!("  line families:            {}", report.line_family_groups);
20362    println!("  file refs:                {}", report.file_ref_groups);
20363    println!("  symbol refs:              {}", report.symbol_ref_groups);
20364    println!("  stack groups:             {}", report.stack_groups);
20365
20366    if !report.signals.is_empty() {
20367        println!();
20368        println!("Signals:");
20369        for signal in &report.signals {
20370            match (&signal.path, signal.line, signal.column) {
20371                (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
20372                (Some(path), Some(line), None) => println!("{path}:{line}"),
20373                (Some(path), None, _) => println!("{path}"),
20374                (None, _, _) => println!("(no file anchor)"),
20375            }
20376            println!("  severity: {}", signal.severity);
20377            println!("  occurrences: {}", signal.occurrences);
20378            println!("  message: {}", signal.message);
20379            println!(
20380                "  cached summaries: {}",
20381                log_digest_summary_label(signal.summary_state)
20382            );
20383            for summary in &signal.current_summaries {
20384                println!(
20385                    "    - {}: {}",
20386                    summary.symbol,
20387                    truncate_for_compact(&summary.summary, 160)
20388                );
20389            }
20390        }
20391    }
20392
20393    if !report.repeated_lines.is_empty() {
20394        println!();
20395        println!("Repeated lines:");
20396        for repeated in &report.repeated_lines {
20397            println!(
20398                "  {}x {}",
20399                repeated.occurrences,
20400                truncate_for_compact(&repeated.line, 180)
20401            );
20402        }
20403    }
20404
20405    if !report.line_families.is_empty() {
20406        println!();
20407        println!("Line families (near-duplicate folds):");
20408        for family in &report.line_families {
20409            println!(
20410                "  {}x ({} variants) {}",
20411                family.occurrences,
20412                family.variants,
20413                truncate_for_compact(&family.template, 180)
20414            );
20415            println!(
20416                "    first: {}",
20417                truncate_for_compact(&family.first_sample, 180)
20418            );
20419            println!(
20420                "    last:  {}",
20421                truncate_for_compact(&family.last_sample, 180)
20422            );
20423        }
20424    }
20425
20426    if !report.file_refs.is_empty() {
20427        println!();
20428        println!("Anchored files:");
20429        for file_ref in &report.file_refs {
20430            match (file_ref.line, file_ref.column) {
20431                (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
20432                (Some(line), None) => println!("{}:{}", file_ref.path, line),
20433                (None, _) => println!("{}", file_ref.path),
20434            }
20435            println!("  occurrences: {}", file_ref.occurrences);
20436            println!(
20437                "  cached summaries: {}",
20438                log_digest_summary_label(file_ref.summary_state)
20439            );
20440            for summary in &file_ref.current_summaries {
20441                println!(
20442                    "    - {}: {}",
20443                    summary.symbol,
20444                    truncate_for_compact(&summary.summary, 160)
20445                );
20446            }
20447        }
20448    }
20449
20450    if !report.symbol_refs.is_empty() {
20451        println!();
20452        println!("Symbol candidates:");
20453        for symbol in &report.symbol_refs {
20454            println!("{}", symbol.symbol);
20455            println!("  occurrences: {}", symbol.occurrences);
20456            println!(
20457                "  cached summaries: {}",
20458                log_digest_summary_label(symbol.summary_state)
20459            );
20460            for summary in &symbol.current_summaries {
20461                println!(
20462                    "    - {}: {}",
20463                    summary.symbol,
20464                    truncate_for_compact(&summary.summary, 160)
20465                );
20466            }
20467        }
20468    }
20469
20470    if !report.stack_traces.is_empty() {
20471        println!();
20472        println!("Stack groups:");
20473        for stack in &report.stack_traces {
20474            println!("  occurrences: {}", stack.occurrences);
20475            for frame in &stack.frames {
20476                println!("    - {}", frame);
20477            }
20478        }
20479    }
20480
20481    if let Some(artifact) = &report.raw_log_artifact {
20482        println!();
20483        println!("Raw log artifact:");
20484        println!("  handle: {}", artifact.handle);
20485        println!("  path:   {}", artifact.path);
20486        println!("  lines:  {}", artifact.lines);
20487        println!("  bytes:  {}", artifact.bytes);
20488        println!("  expand: {}", artifact.expand);
20489    }
20490
20491    for warning in &report.warnings {
20492        println!("warning: {warning}");
20493    }
20494    Ok(())
20495}
20496
20497pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
20498    match trend {
20499        metric_digest::MetricDigestTrend::Improved => "improved",
20500        metric_digest::MetricDigestTrend::Regressed => "regressed",
20501        metric_digest::MetricDigestTrend::Flat => "flat",
20502        metric_digest::MetricDigestTrend::Unknown => "changed",
20503    }
20504}
20505
20506pub(crate) fn metric_digest_gate_label(
20507    decision: metric_digest::CommunitySearchGateDecision,
20508) -> &'static str {
20509    match decision {
20510        metric_digest::CommunitySearchGateDecision::Pass => "pass",
20511        metric_digest::CommunitySearchGateDecision::Block => "block",
20512    }
20513}
20514
20515pub(crate) fn memgraphrag_metric_digest_gate_label(
20516    decision: metric_digest::MemGraphRagPerformanceGateDecision,
20517) -> &'static str {
20518    match decision {
20519        metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
20520        metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
20521    }
20522}
20523
20524fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
20525    let input = fs::read_to_string(fixture_path)
20526        .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
20527    let report = dci_benchmark::compute(&input)?;
20528
20529    if format.json_output {
20530        println!(
20531            "{}",
20532            to_json_schema(
20533                &report,
20534                format.pretty,
20535                format.terse,
20536                format.ultra_terse,
20537                format.schema
20538            )?
20539        );
20540        return Ok(());
20541    }
20542
20543    if format.compact {
20544        println!(
20545            "dci tasks:{} strategies:{} warnings:{}",
20546            report.tasks_loaded,
20547            report.strategies_compared,
20548            report.warnings.len()
20549        );
20550        for summary in &report.strategy_summaries {
20551            println!(
20552                "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
20553                summary.strategy,
20554                summary.rank,
20555                summary.localized,
20556                summary.task_runs,
20557                dci_benchmark::format_number(summary.localization_rate * 100.0),
20558                dci_benchmark::format_number(summary.avg_useful_hits),
20559                dci_benchmark::format_number(summary.zero_output_rate * 100.0),
20560                dci_benchmark::format_number(summary.avg_tool_calls),
20561                dci_benchmark::format_number(summary.avg_latency_ms),
20562                dci_benchmark::format_number(summary.avg_estimated_tokens),
20563                dci_benchmark::format_number(summary.avg_output_tokens)
20564            );
20565        }
20566        if let Some(gate) = &report.memory_retrieval_gate {
20567            println!(
20568                "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
20569                gate.decision,
20570                gate.baseline_strategy,
20571                dci_benchmark::format_number(gate.min_avg_useful_hits),
20572                gate.max_zero_output_failures,
20573                gate.diagnostics.len()
20574            );
20575        }
20576        for warning in &report.warnings {
20577            println!("warning: {warning}");
20578        }
20579        return Ok(());
20580    }
20581
20582    println!("DCI benchmark");
20583    if let Some(description) = &report.description {
20584        println!("  description: {}", description);
20585    }
20586    println!("  tasks loaded:        {}", report.tasks_loaded);
20587    println!("  strategies compared: {}", report.strategies_compared);
20588
20589    println!();
20590    println!("Strategy summary:");
20591    for summary in &report.strategy_summaries {
20592        println!(
20593            "  #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
20594            summary.rank,
20595            summary.strategy,
20596            summary.localized,
20597            summary.task_runs,
20598            summary.localization_rate * 100.0,
20599            dci_benchmark::format_number(summary.avg_useful_hits),
20600            summary.zero_output_rate * 100.0,
20601            dci_benchmark::format_number(summary.avg_tool_calls),
20602            dci_benchmark::format_number(summary.avg_latency_ms),
20603            dci_benchmark::format_number(summary.avg_estimated_tokens),
20604            dci_benchmark::format_number(summary.avg_output_tokens)
20605        );
20606    }
20607
20608    if let Some(gate) = &report.memory_retrieval_gate {
20609        println!();
20610        println!("Memory retrieval gate:");
20611        println!("  decision: {}", gate.decision);
20612        println!(
20613            "  baseline: {}, min avg useful hits {}, max zero-output failures {}",
20614            gate.baseline_strategy,
20615            dci_benchmark::format_number(gate.min_avg_useful_hits),
20616            gate.max_zero_output_failures
20617        );
20618        for row in &gate.rows {
20619            println!(
20620                "  {}: status {}, avg useful hits {}, zero-output failures {}",
20621                row.strategy,
20622                row.status,
20623                dci_benchmark::format_number(row.avg_useful_hits),
20624                row.zero_output_failures
20625            );
20626        }
20627        for diagnostic in &gate.diagnostics {
20628            println!("  diagnostic: {diagnostic}");
20629        }
20630    }
20631
20632    println!();
20633    println!("Task winners:");
20634    for row in &report.task_rows {
20635        let label = row
20636            .label
20637            .as_ref()
20638            .map(|value| format!(" ({value})"))
20639            .unwrap_or_default();
20640        println!("  {}{}", row.task_id, label);
20641        println!("    localized: {}", row.best_localization.join(", "));
20642        println!("    most useful hits: {}", row.most_useful_hits.join(", "));
20643        println!(
20644            "    lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
20645            row.lowest_tool_calls.as_deref().unwrap_or("-"),
20646            row.lowest_latency.as_deref().unwrap_or("-"),
20647            row.lowest_token_budget.as_deref().unwrap_or("-"),
20648            row.lowest_output_tokens.as_deref().unwrap_or("-")
20649        );
20650        if !row.zero_output_failures.is_empty() {
20651            println!("    zero output: {}", row.zero_output_failures.join(", "));
20652        }
20653    }
20654
20655    for warning in &report.warnings {
20656        println!("warning: {warning}");
20657    }
20658    Ok(())
20659}
20660
20661pub(crate) fn format_compact_count(value: u64) -> String {
20662    if value >= 1_000_000 {
20663        format!("{:.1}M", value as f64 / 1_000_000.0)
20664    } else if value >= 1_000 {
20665        format!("{:.1}K", value as f64 / 1_000.0)
20666    } else {
20667        value.to_string()
20668    }
20669}
20670
20671fn cmd_digest_runner(
20672    kind: &str,
20673    path: &Path,
20674    runner: Option<&str>,
20675    shell_command: &str,
20676    format: OutputFormat,
20677) -> Result<()> {
20678    let digest_kind = DigestRunnerKind::parse(kind)?;
20679    let root = transcript_artifact_root(path)?;
20680    let execution = run_digest_runner_command(shell_command)?;
20681    let output = &execution.output;
20682    let captured = String::from_utf8_lossy(&output.stdout).into_owned();
20683    let exit_code = output.status.code().unwrap_or(-1);
20684    if format.json_output && format.envelope {
20685        let artifact_key = format!(
20686            "{}:{}:{}:{}",
20687            digest_kind.as_str(),
20688            shell_command,
20689            execution.executed_command,
20690            captured
20691        );
20692        let artifact = if captured.trim().is_empty() {
20693            None
20694        } else {
20695            let (suffix, expand) = match digest_kind {
20696                DigestRunnerKind::Test => (
20697                    "test.log",
20698                    format!(
20699                        "tsift test-digest --path {} --input {}{} --json",
20700                        shell_quote(root.to_string_lossy().as_ref()),
20701                        shell_quote(
20702                            root.join(".tsift/artifacts")
20703                                .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
20704                                .to_string_lossy()
20705                                .as_ref()
20706                        ),
20707                        runner
20708                            .map(|value| format!(" --runner {}", shell_quote(value)))
20709                            .unwrap_or_default()
20710                    ),
20711                ),
20712                DigestRunnerKind::Log => (
20713                    "log",
20714                    format!(
20715                        "tsift log-digest --path {} --input {} --json",
20716                        shell_quote(root.to_string_lossy().as_ref()),
20717                        shell_quote(
20718                            root.join(".tsift/artifacts")
20719                                .join(format!("{}.log", stable_handle("tart", &artifact_key)))
20720                                .to_string_lossy()
20721                                .as_ref()
20722                        )
20723                    ),
20724                ),
20725            };
20726            Some(persist_transcript_artifact(
20727                &root,
20728                "tart",
20729                suffix,
20730                &artifact_key,
20731                &captured,
20732                expand,
20733            )?)
20734        };
20735        let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
20736
20737        match digest_kind {
20738            DigestRunnerKind::Test => {
20739                let digest_report = test_digest::compute(path, &captured, runner)?;
20740                let report = serde_json::json!({
20741                    "kind": digest_kind.as_str(),
20742                    "command": shell_command,
20743                    "executed_command": execution.executed_command,
20744                    "exit_code": exit_code,
20745                    "success": output.status.success(),
20746                    "filter": filter_report,
20747                    "artifact": artifact,
20748                    "digest": digest_report,
20749                });
20750                let mut follow_up = artifact
20751                    .as_ref()
20752                    .map(|entry| vec![entry.expand.clone()])
20753                    .unwrap_or_default();
20754                follow_up.push(format!(
20755                    "tsift rewrite --run {}",
20756                    shell_quote(shell_command)
20757                ));
20758                let summary_text = if output.status.success() && digest_report.failures == 0 {
20759                    format!("test run passed for {}", runner.unwrap_or("auto"))
20760                } else {
20761                    format!("test run captured {} failure(s)", digest_report.failures)
20762                };
20763                print_json_or_envelope(
20764                    &report,
20765                    &format,
20766                    "digest-runner",
20767                    "test-run",
20768                    ToolEnvelopeSummary {
20769                        text: summary_text,
20770                        metrics: vec![
20771                            envelope_metric("runner", &digest_report.runner),
20772                            envelope_metric("exit_code", exit_code),
20773                            envelope_metric("filter", execution.filter_label()),
20774                            envelope_metric("failures", digest_report.failures),
20775                            envelope_metric("groups", digest_report.grouped_failures),
20776                            envelope_metric(
20777                                "artifact",
20778                                artifact
20779                                    .as_ref()
20780                                    .map(|entry| entry.handle.as_str())
20781                                    .unwrap_or("-"),
20782                            ),
20783                        ],
20784                    },
20785                    false,
20786                    follow_up,
20787                )?;
20788            }
20789            DigestRunnerKind::Log => {
20790                let digest_report = log_digest::compute(path, &captured)?;
20791                let report = serde_json::json!({
20792                    "kind": digest_kind.as_str(),
20793                    "command": shell_command,
20794                    "executed_command": execution.executed_command,
20795                    "exit_code": exit_code,
20796                    "success": output.status.success(),
20797                    "filter": filter_report,
20798                    "artifact": artifact,
20799                    "digest": digest_report,
20800                });
20801                let mut follow_up = artifact
20802                    .as_ref()
20803                    .map(|entry| vec![entry.expand.clone()])
20804                    .unwrap_or_default();
20805                follow_up.push(format!(
20806                    "tsift rewrite --run {}",
20807                    shell_quote(shell_command)
20808                ));
20809                let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
20810                    "command finished without log signals".to_string()
20811                } else {
20812                    format!(
20813                        "command emitted {} log signal group(s)",
20814                        digest_report.signal_groups
20815                    )
20816                };
20817                print_json_or_envelope(
20818                    &report,
20819                    &format,
20820                    "digest-runner",
20821                    "command-run",
20822                    ToolEnvelopeSummary {
20823                        text: summary_text,
20824                        metrics: vec![
20825                            envelope_metric("exit_code", exit_code),
20826                            envelope_metric("filter", execution.filter_label()),
20827                            envelope_metric("signals", digest_report.signal_groups),
20828                            envelope_metric("file_refs", digest_report.file_ref_groups),
20829                            envelope_metric(
20830                                "artifact",
20831                                artifact
20832                                    .as_ref()
20833                                    .map(|entry| entry.handle.as_str())
20834                                    .unwrap_or("-"),
20835                            ),
20836                        ],
20837                    },
20838                    false,
20839                    follow_up,
20840                )?;
20841            }
20842        }
20843
20844        if output.status.success() {
20845            return Ok(());
20846        }
20847        if let Some(code) = output.status.code() {
20848            std::process::exit(code);
20849        }
20850        bail!("digest-wrapped command terminated by signal: {shell_command}");
20851    }
20852
20853    if captured.trim().is_empty() {
20854        let label = match digest_kind {
20855            DigestRunnerKind::Test => "test",
20856            DigestRunnerKind::Log => "log",
20857        };
20858        println!("No {label} output captured.");
20859    } else {
20860        match digest_kind {
20861            DigestRunnerKind::Test => {
20862                render_test_digest_from_input(path, &captured, runner, format)?
20863            }
20864            DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20865        }
20866    }
20867
20868    if output.status.success() {
20869        return Ok(());
20870    }
20871    if let Some(code) = output.status.code() {
20872        std::process::exit(code);
20873    }
20874    bail!("digest-wrapped command terminated by signal: {shell_command}");
20875}
20876
20877struct DigestRunnerExecution {
20878    output: std::process::Output,
20879    executed_command: String,
20880    filter: Option<DigestRunnerFilter>,
20881}
20882
20883impl DigestRunnerExecution {
20884    fn filter_label(&self) -> &'static str {
20885        self.filter
20886            .as_ref()
20887            .map(|filter| filter.tool)
20888            .unwrap_or("none")
20889    }
20890}
20891
20892struct DigestRunnerFilter {
20893    tool: &'static str,
20894    command: String,
20895}
20896
20897impl DigestRunnerFilter {
20898    fn to_json(&self) -> serde_json::Value {
20899        serde_json::json!({
20900            "tool": self.tool,
20901            "command": self.command,
20902        })
20903    }
20904}
20905
20906fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20907    let filter = rtk_rewrite_for_digest_runner(shell_command);
20908    let executed_command = filter
20909        .as_ref()
20910        .map(|filter| filter.command.as_str())
20911        .unwrap_or(shell_command);
20912    let output = Command::new("sh")
20913        .arg("-lc")
20914        .arg(format!("({executed_command}) 2>&1"))
20915        .stdout(Stdio::piped())
20916        .output()
20917        .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20918
20919    Ok(DigestRunnerExecution {
20920        output,
20921        executed_command: executed_command.to_string(),
20922        filter,
20923    })
20924}
20925
20926fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20927    if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20928        return None;
20929    }
20930    let output = Command::new("rtk")
20931        .arg("rewrite")
20932        .arg(shell_command)
20933        .output()
20934        .ok()?;
20935    if !output.status.success() {
20936        return None;
20937    }
20938    let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20939    if rewritten.is_empty() || rewritten == shell_command {
20940        return None;
20941    }
20942    Some(DigestRunnerFilter {
20943        tool: "rtk",
20944        command: rewritten,
20945    })
20946}
20947
20948fn find_command_on_path(command: &str) -> Option<PathBuf> {
20949    let path_var = std::env::var_os("PATH")?;
20950    std::env::split_paths(&path_var)
20951        .map(|dir| dir.join(command))
20952        .find(|candidate| candidate.is_file())
20953}
20954
20955pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20956    if !db_path.exists() {
20957        bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20958    }
20959    summarize::SummaryDb::open_read_only_resilient(db_path)
20960}
20961
20962fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20963    !matches!(report.index, status::IndexStatus::Fresh { .. })
20964}
20965
20966fn status_workspace_scope_ids_needing_fix(
20967    report: &status::StatusReport,
20968) -> std::collections::HashSet<&str> {
20969    let (workspace_scopes, missing_scopes) = match &report.index {
20970        status::IndexStatus::Fresh {
20971            workspace_scopes,
20972            missing_scopes,
20973            ..
20974        }
20975        | status::IndexStatus::Stale {
20976            workspace_scopes,
20977            missing_scopes,
20978            ..
20979        } => (workspace_scopes.as_slice(), missing_scopes.as_slice()),
20980        status::IndexStatus::Missing { missing_scopes } => (&[][..], missing_scopes.as_slice()),
20981    };
20982
20983    workspace_scopes
20984        .iter()
20985        .filter(|scope| scope.stale_files > 0)
20986        .map(|scope| scope.scope.as_str())
20987        .chain(missing_scopes.iter().map(|scope| scope.scope.as_str()))
20988        .collect()
20989}
20990
20991fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20992    !matches!(report.instructions, init::InstructionStatus::Current { .. })
20993}
20994
20995pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20996    if status_instructions_need_fix(report) {
20997        eprintln!("status fix: refreshing tsift instructions");
20998        init::init(root, false, false)?;
20999    }
21000
21001    let eviction = cycle_packet_cache::cycle_packet_cache_evict(
21002        root,
21003        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
21004        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
21005    );
21006    if eviction.evicted_entries > 0 {
21007        eprintln!(
21008            "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
21009            eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
21010        );
21011    }
21012
21013    if !status_index_needs_fix(report) {
21014        return Ok(());
21015    }
21016
21017    let scopes = config::Config::submodule_dirs(root)?;
21018    if scopes.is_empty() {
21019        eprintln!("status fix: refreshing index");
21020        run_index_update(
21021            &root.join(".tsift/index.db"),
21022            root,
21023            "status --fix refreshing index".to_string(),
21024            root,
21025            None,
21026            false,
21027            false,
21028        )?;
21029        return Ok(());
21030    }
21031
21032    let cfg = config::Config::load(root)?;
21033    let scope_ids_needing_fix = status_workspace_scope_ids_needing_fix(report);
21034    for scope in scopes {
21035        if !scope_ids_needing_fix.contains(scope.id.as_str()) {
21036            continue;
21037        }
21038        if !scope.source_root.exists() {
21039            eprintln!(
21040                "status fix: skipping missing submodule `{}` ({})",
21041                scope.id,
21042                scope.source_root.display()
21043            );
21044            continue;
21045        }
21046        eprintln!("status fix: refreshing submodule `{}` index", scope.id);
21047        run_index_update(
21048            &cfg.db_path_for(root, &scope.id),
21049            &scope.source_root,
21050            format!("status --fix refreshing submodule `{}` index", scope.id),
21051            root,
21052            Some(scope.id.as_str()),
21053            false,
21054            false,
21055        )?;
21056    }
21057
21058    Ok(())
21059}
21060
21061pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
21062    match &report.index {
21063        status::IndexStatus::Fresh { missing_scopes, .. }
21064        | status::IndexStatus::Stale { missing_scopes, .. }
21065        | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
21066    }
21067}
21068
21069pub(crate) fn autoindex_missing_workspace_scopes(
21070    root: &Path,
21071    report: &status::StatusReport,
21072) -> Result<()> {
21073    let missing_scopes = match &report.index {
21074        status::IndexStatus::Fresh { missing_scopes, .. }
21075        | status::IndexStatus::Stale { missing_scopes, .. }
21076        | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
21077    };
21078    if missing_scopes.is_empty() {
21079        return Ok(());
21080    }
21081
21082    let missing_scope_ids = missing_scopes
21083        .iter()
21084        .map(|scope| scope.scope.as_str())
21085        .collect::<std::collections::HashSet<_>>();
21086    let cfg = config::Config::load(root)?;
21087    for scope in config::Config::submodule_dirs(root)? {
21088        if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
21089            continue;
21090        }
21091        let db_path = cfg.db_path_for(root, &scope.id);
21092        run_index_update(
21093            &db_path,
21094            &scope.source_root,
21095            format!(
21096                "autoindexing missing submodule `{}` during status",
21097                scope.id
21098            ),
21099            root,
21100            Some(scope.id.as_str()),
21101            false,
21102            false,
21103        )?;
21104    }
21105    Ok(())
21106}
21107
21108pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
21109    for warning in &stats.warnings {
21110        let rel_path = relativize_pathbuf(&warning.path, root);
21111        eprintln!(
21112            "warning: summarize stats {}: {}",
21113            rel_path.display(),
21114            warning.message
21115        );
21116    }
21117}
21118
21119fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
21120    Result::<(), anyhow::Error>::Err(err)
21121        .context(context)
21122        .unwrap_err()
21123}
21124
21125fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
21126    let message = err.to_string();
21127    message.contains("another tsift index writer is already active")
21128        || substrate::error_mentions_locked_db(err)
21129}
21130
21131fn add_write_lock_context(
21132    err: anyhow::Error,
21133    action: String,
21134    root: &std::path::Path,
21135    scope: Option<&str>,
21136) -> anyhow::Error {
21137    if !should_attach_lock_diagnostics(&err) {
21138        return contextualize_error(err, action);
21139    }
21140
21141    let Ok(report) = status::check_locks(root, None, scope) else {
21142        return contextualize_error(err, action);
21143    };
21144
21145    contextualize_error(
21146        err,
21147        format!(
21148            "{}\n\nlock diagnostics:\n{}",
21149            action,
21150            status::format_locks_human(&report, false).trim_end()
21151        ),
21152    )
21153}
21154
21155pub(crate) fn run_index_update(
21156    db_path: &std::path::Path,
21157    source_root: &std::path::Path,
21158    action: String,
21159    root: &std::path::Path,
21160    scope: Option<&str>,
21161    rebuild: bool,
21162    prune: bool,
21163) -> Result<index::IndexSummary> {
21164    let result = (|| {
21165        let db = index::IndexDb::open(db_path)?;
21166        if rebuild {
21167            db.rebuild(source_root)
21168        } else if prune {
21169            db.apply_changes_pruned(source_root)
21170        } else {
21171            db.apply_changes(source_root)
21172        }
21173    })();
21174
21175    let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
21176    emit_index_warnings(&summary, source_root, scope);
21177    Ok(summary)
21178}
21179
21180pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
21181    for change in &mut summary.changes {
21182        change.path = relativize_pathbuf(&change.path, root);
21183    }
21184    for warning in &mut summary.warnings {
21185        warning.path = relativize_pathbuf(&warning.path, root);
21186    }
21187}
21188
21189fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
21190    for warning in &summary.warnings {
21191        let rel_path = relativize_pathbuf(&warning.path, root);
21192        let stage = match warning.stage {
21193            index::IndexWarningStage::ReadSource => "read failed",
21194            index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
21195            index::IndexWarningStage::ExtractCallSites => "call extraction failed",
21196            index::IndexWarningStage::ExtractRoutes => "route extraction failed",
21197        };
21198        let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
21199        let lang_suffix = warning
21200            .language
21201            .as_deref()
21202            .map(|lang| format!(" [{}]", lang))
21203            .unwrap_or_default();
21204        eprintln!(
21205            "warning: {}{}{}: {}: {}",
21206            scope_prefix,
21207            rel_path.display(),
21208            lang_suffix,
21209            stage,
21210            warning.message
21211        );
21212    }
21213}
21214
21215pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
21216    let config_path = root.join(".tsift/config.toml");
21217    if !config_path.exists() {
21218        return summarize::SummarizeConfig::default();
21219    }
21220    #[derive(serde::Deserialize, Default)]
21221    struct RawConfig {
21222        #[serde(default)]
21223        summarize: Option<RawSummarize>,
21224    }
21225    #[derive(serde::Deserialize)]
21226    struct RawSummarize {
21227        model: Option<String>,
21228        max_file_tokens: Option<usize>,
21229        api_key_env: Option<String>,
21230    }
21231    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
21232    let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
21233    let defaults = summarize::SummarizeConfig::default();
21234    match raw.summarize {
21235        Some(s) => summarize::SummarizeConfig {
21236            model: s.model.unwrap_or(defaults.model),
21237            max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
21238            api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
21239        },
21240        None => defaults,
21241    }
21242}
21243
21244#[derive(Debug, Clone, PartialEq, Eq)]
21245struct ExtractSymbolContext {
21246    db_path: PathBuf,
21247    source_root: PathBuf,
21248}
21249
21250pub(crate) fn find_symbols_db_for_file(
21251    root: &Path,
21252    file_path: &Path,
21253) -> Result<Option<ExtractSymbolContext>> {
21254    let cfg = config::Config::load(root)?;
21255    let mut submodules = config::Config::submodule_dirs(root)?;
21256    submodules.sort_by(|left, right| {
21257        right
21258            .source_root
21259            .components()
21260            .count()
21261            .cmp(&left.source_root.components().count())
21262    });
21263
21264    for scope in submodules {
21265        if !file_path.starts_with(&scope.source_root) {
21266            continue;
21267        }
21268        let db_path = cfg.db_path_for(root, &scope.id);
21269        if db_path.exists() {
21270            return Ok(Some(ExtractSymbolContext {
21271                db_path,
21272                source_root: scope.source_root,
21273            }));
21274        }
21275    }
21276
21277    let single = root.join(".tsift/index.db");
21278    if single.exists() && file_path.starts_with(root) {
21279        return Ok(Some(ExtractSymbolContext {
21280            db_path: single,
21281            source_root: root.to_path_buf(),
21282        }));
21283    }
21284
21285    Ok(None)
21286}
21287
21288pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
21289    let canonical = path
21290        .canonicalize()
21291        .with_context(|| format!("canonicalizing {}", path.display()))?;
21292
21293    Ok(if canonical.is_dir() {
21294        canonical
21295    } else {
21296        canonical
21297            .parent()
21298            .map(Path::to_path_buf)
21299            .unwrap_or(canonical)
21300    })
21301}
21302
21303fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
21304    if path.exists() {
21305        return path
21306            .canonicalize()
21307            .with_context(|| format!("canonicalizing extract scope {}", path.display()));
21308    }
21309
21310    Ok(summarize::normalize_lexical_path(path))
21311}
21312
21313pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
21314    let scope = if extract_path.is_absolute() {
21315        extract_path.to_path_buf()
21316    } else {
21317        root.join(extract_path)
21318    };
21319    normalize_extract_scope_path(&scope)
21320}
21321
21322pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
21323    normalize_extract_scope_path(changed_path)
21324        .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
21325        .starts_with(extract_scope)
21326}
21327
21328pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
21329    summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
21330}
21331
21332pub(crate) fn summarize_full_extract_deleted_summary_paths(
21333    summary_db: &summarize::SummaryDb,
21334    root: &Path,
21335    extract_scope: &Path,
21336    files_to_extract: &[PathBuf],
21337) -> Result<BTreeSet<String>> {
21338    let live_paths = files_to_extract
21339        .iter()
21340        .map(|file_path| summarize_relative_file_path(root, file_path))
21341        .collect::<BTreeSet<_>>();
21342    let mut deleted = BTreeSet::new();
21343
21344    for cached_path in summary_db.cached_file_paths()? {
21345        if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
21346            continue;
21347        }
21348        if !live_paths.contains(&cached_path) {
21349            deleted.insert(cached_path);
21350        }
21351    }
21352
21353    Ok(deleted)
21354}
21355
21356#[derive(Debug, Clone)]
21357struct SearchIndexTarget {
21358    label: String,
21359    db_path: PathBuf,
21360    source_root: PathBuf,
21361    scope_name: Option<String>,
21362    reindex_cmd: String,
21363}
21364
21365fn cargo_package_index_target(
21366    root: &Path,
21367    package: multiplicity::CargoPackageInfo,
21368) -> SearchIndexTarget {
21369    SearchIndexTarget {
21370        label: format!("cargo package `{}` index", package.scope_id),
21371        db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
21372        source_root: package.package_root.clone(),
21373        scope_name: Some(package.scope_id.clone()),
21374        reindex_cmd: format!(
21375            "tsift index --submodule {} {}",
21376            package.scope_id,
21377            root.display()
21378        ),
21379    }
21380}
21381
21382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21383enum SearchIndexState {
21384    Missing,
21385    Fresh,
21386    Stale { stale_files: usize },
21387}
21388
21389fn resolve_search_index_targets(
21390    root: &Path,
21391    path_hint: &Path,
21392    scope: Option<&str>,
21393    federated: bool,
21394) -> Result<Vec<SearchIndexTarget>> {
21395    if let Some(scope_name) = scope {
21396        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
21397            let cfg = config::Config::load(root)?;
21398            return Ok(vec![SearchIndexTarget {
21399                label: format!("submodule `{}` index", scope.id),
21400                db_path: cfg.db_path_for(root, &scope.id),
21401                source_root: scope.source_root.clone(),
21402                scope_name: Some(scope.id.clone()),
21403                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21404            }]);
21405        }
21406        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
21407            return Ok(vec![cargo_package_index_target(root, package)]);
21408        }
21409        config::Config::resolve_submodule(root, scope_name)?;
21410    }
21411
21412    if federated {
21413        let cfg = config::Config::load(root)?;
21414        let mut targets = Vec::new();
21415        for scope in config::Config::submodule_dirs(root)? {
21416            if !cfg.federation_for_scope(&scope) {
21417                continue;
21418            }
21419            targets.push(SearchIndexTarget {
21420                label: format!("submodule `{}` index", scope.id),
21421                db_path: cfg.db_path_for(root, &scope.id),
21422                source_root: scope.source_root.clone(),
21423                scope_name: Some(scope.id.clone()),
21424                reindex_cmd: format!("tsift index --workspace {}", root.display()),
21425            });
21426        }
21427        return Ok(targets);
21428    }
21429
21430    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
21431        let cfg = config::Config::load(root)?;
21432        return Ok(vec![SearchIndexTarget {
21433            label: format!("submodule `{}` index", scope.id),
21434            db_path: cfg.db_path_for(root, &scope.id),
21435            source_root: scope.source_root.clone(),
21436            scope_name: Some(scope.id.clone()),
21437            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21438        }]);
21439    }
21440
21441    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
21442        return Ok(vec![cargo_package_index_target(root, package)]);
21443    }
21444
21445    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
21446        let cfg = config::Config::load(root)?;
21447        return Ok(vec![SearchIndexTarget {
21448            label: format!("submodule `{}` index", scope.id),
21449            db_path: cfg.db_path_for(root, &scope.id),
21450            source_root: scope.source_root.clone(),
21451            scope_name: Some(scope.id.clone()),
21452            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21453        }]);
21454    }
21455
21456    let scopes = config::Config::submodule_dirs(root)?;
21457    if !scopes.is_empty() {
21458        let root_db = root.join(".tsift/index.db");
21459        if !root_db.exists() {
21460            let available_scopes = scopes
21461                .iter()
21462                .map(|scope| scope.id.as_str())
21463                .collect::<Vec<_>>()
21464                .join(", ");
21465            let cfg = config::Config::load(root)?;
21466            let indexed_scopes = scopes
21467                .iter()
21468                .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
21469                .map(|scope| scope.id.as_str())
21470                .collect::<Vec<_>>();
21471            let indexed_label = if indexed_scopes.is_empty() {
21472                "none".to_string()
21473            } else {
21474                indexed_scopes.join(", ")
21475            };
21476            bail!(
21477                "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: {}.",
21478                root.display(),
21479                root_db.display(),
21480                available_scopes,
21481                indexed_label,
21482            );
21483        }
21484    }
21485
21486    Ok(vec![SearchIndexTarget {
21487        label: "index".to_string(),
21488        db_path: root.join(".tsift/index.db"),
21489        source_root: root.to_path_buf(),
21490        scope_name: None,
21491        reindex_cmd: format!("tsift index {}", root.display()),
21492    }])
21493}
21494
21495fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
21496    if !target.source_root.exists() || !target.db_path.exists() {
21497        return Ok(SearchIndexState::Missing);
21498    }
21499
21500    let inspection =
21501        index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
21502    let stale_files =
21503        inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
21504    if stale_files == 0 {
21505        Ok(SearchIndexState::Fresh)
21506    } else {
21507        Ok(SearchIndexState::Stale { stale_files })
21508    }
21509}
21510
21511#[derive(Debug, Clone, PartialEq, Eq)]
21512struct RebuildSearchTarget {
21513    label: String,
21514    reason: RebuildSearchReason,
21515    reindex_cmd: String,
21516}
21517
21518#[derive(Debug, Clone, PartialEq, Eq)]
21519enum RebuildSearchReason {
21520    Missing,
21521    Stale { stale_files: usize },
21522}
21523
21524#[derive(Debug, Clone, PartialEq, Eq)]
21525struct DegradedSearchTarget {
21526    label: String,
21527    reason: RebuildSearchReason,
21528    reindex_cmd: String,
21529}
21530
21531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21532pub(crate) enum DegradedSearchMode {
21533    ReadOnly,
21534    Exact,
21535}
21536
21537#[derive(Debug)]
21538struct SearchPrecheck {
21539    targets: Vec<SearchIndexTarget>,
21540    degraded_targets: Vec<DegradedSearchTarget>,
21541}
21542
21543fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
21544    err.chain().any(|cause| {
21545        cause
21546            .to_string()
21547            .contains("another tsift index writer is already active")
21548    })
21549}
21550
21551fn infer_agent_doc_task_submodule(
21552    root: &Path,
21553    path_hint: &Path,
21554) -> Result<Option<config::WorkspaceScope>> {
21555    let hinted_path = if path_hint.is_absolute() {
21556        path_hint.to_path_buf()
21557    } else {
21558        root.join(path_hint)
21559    };
21560    let Ok(relative) = hinted_path.strip_prefix(root) else {
21561        return Ok(None);
21562    };
21563    let mut components = relative.components();
21564    let Some(std::path::Component::Normal(first)) = components.next() else {
21565        return Ok(None);
21566    };
21567    if first != "tasks" {
21568        return Ok(None);
21569    }
21570    let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
21571        return Ok(None);
21572    };
21573    config::Config::find_submodule(root, file_stem)
21574}
21575
21576fn degraded_search_target(
21577    target: &SearchIndexTarget,
21578    reason: RebuildSearchReason,
21579) -> DegradedSearchTarget {
21580    DegradedSearchTarget {
21581        label: target.label.clone(),
21582        reason,
21583        reindex_cmd: target.reindex_cmd.clone(),
21584    }
21585}
21586
21587fn apply_search_index_update(
21588    root: &Path,
21589    target: &SearchIndexTarget,
21590) -> Result<index::IndexSummary> {
21591    run_index_update(
21592        &target.db_path,
21593        &target.source_root,
21594        format!("autoindexing {}", target.label),
21595        root,
21596        target.scope_name.as_deref(),
21597        false,
21598        false,
21599    )
21600}
21601
21602fn collect_rebuild_search_targets(
21603    targets: &[SearchIndexTarget],
21604) -> Result<Vec<RebuildSearchTarget>> {
21605    let mut rebuild_targets = Vec::new();
21606    for target in targets {
21607        let reason = match inspect_search_index(target)? {
21608            SearchIndexState::Missing => RebuildSearchReason::Missing,
21609            SearchIndexState::Fresh => continue,
21610            SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
21611        };
21612        rebuild_targets.push(RebuildSearchTarget {
21613            label: target.label.clone(),
21614            reason,
21615            reindex_cmd: target.reindex_cmd.clone(),
21616        });
21617    }
21618    Ok(rebuild_targets)
21619}
21620
21621fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
21622    match target.reason {
21623        RebuildSearchReason::Missing => format!("{} is missing", target.label),
21624        RebuildSearchReason::Stale { stale_files } => {
21625            let file_suffix = if stale_files == 1 { "" } else { "s" };
21626            format!(
21627                "{} is stale ({} file{})",
21628                target.label, stale_files, file_suffix
21629            )
21630        }
21631    }
21632}
21633
21634fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
21635    if rebuild_targets.len() == 1 {
21636        let target = &rebuild_targets[0];
21637        return format!(
21638            "{}. Run `{}` to rebuild before retrying.",
21639            rebuild_search_target_detail(target),
21640            target.reindex_cmd
21641        );
21642    }
21643
21644    let summary: Vec<String> = rebuild_targets
21645        .iter()
21646        .take(3)
21647        .map(rebuild_search_target_detail)
21648        .collect();
21649    let overflow = rebuild_targets.len().saturating_sub(summary.len());
21650    let mut details = summary.join(", ");
21651    if overflow > 0 {
21652        details.push_str(&format!(", +{} more", overflow));
21653    }
21654    let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
21655    format!(
21656        "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
21657        rebuild_targets.len(),
21658        details,
21659        reindex_cmd
21660    )
21661}
21662
21663pub(crate) fn precheck_search_indexes(
21664    root: &Path,
21665    path_hint: &Path,
21666    scope: Option<&str>,
21667    federated: bool,
21668    autoindex: bool,
21669) -> Result<SearchPrecheck> {
21670    let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
21671    let mut stale_targets = Vec::new();
21672    let mut degraded_targets = Vec::new();
21673
21674    for target in &targets {
21675        match inspect_search_index(target)? {
21676            SearchIndexState::Missing => {
21677                if autoindex && let Err(err) = apply_search_index_update(root, target) {
21678                    if is_active_writer_lock_error(&err) {
21679                        degraded_targets
21680                            .push(degraded_search_target(target, RebuildSearchReason::Missing));
21681                    } else {
21682                        return Err(err);
21683                    }
21684                }
21685            }
21686            SearchIndexState::Fresh => {}
21687            SearchIndexState::Stale { stale_files } => {
21688                if autoindex {
21689                    if let Err(err) = apply_search_index_update(root, target) {
21690                        if is_active_writer_lock_error(&err) {
21691                            degraded_targets.push(degraded_search_target(
21692                                target,
21693                                RebuildSearchReason::Stale { stale_files },
21694                            ));
21695                        } else {
21696                            return Err(err);
21697                        }
21698                    }
21699                } else {
21700                    stale_targets.push(RebuildSearchTarget {
21701                        label: target.label.clone(),
21702                        reason: RebuildSearchReason::Stale { stale_files },
21703                        reindex_cmd: target.reindex_cmd.clone(),
21704                    });
21705                }
21706            }
21707        }
21708    }
21709
21710    if stale_targets.is_empty() {
21711        return Ok(SearchPrecheck {
21712            targets,
21713            degraded_targets,
21714        });
21715    }
21716
21717    bail!(
21718        "tsift search aborted: {} \
21719         or re-run without `--no-autoindex`.",
21720        rebuild_search_targets_message(&stale_targets),
21721    );
21722}
21723
21724pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
21725    if targets.is_empty() {
21726        return None;
21727    }
21728
21729    if targets
21730        .iter()
21731        .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
21732    {
21733        Some(DegradedSearchMode::Exact)
21734    } else {
21735        Some(DegradedSearchMode::ReadOnly)
21736    }
21737}
21738
21739fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
21740    if targets.len() == 1 {
21741        let target = &targets[0];
21742        return match target.reason {
21743            RebuildSearchReason::Missing => format!("{} is missing", target.label),
21744            RebuildSearchReason::Stale { stale_files } => {
21745                let file_suffix = if stale_files == 1 { "" } else { "s" };
21746                format!(
21747                    "{} is stale ({} file{})",
21748                    target.label, stale_files, file_suffix
21749                )
21750            }
21751        };
21752    }
21753
21754    let missing = targets
21755        .iter()
21756        .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
21757        .count();
21758    let stale = targets.len().saturating_sub(missing);
21759    let mut parts = Vec::new();
21760    if stale > 0 {
21761        let suffix = if stale == 1 { "" } else { "es" };
21762        parts.push(format!("{stale} stale index{suffix}"));
21763    }
21764    if missing > 0 {
21765        let suffix = if missing == 1 { "" } else { "es" };
21766        parts.push(format!("{missing} missing index{suffix}"));
21767    }
21768    parts.join(", ")
21769}
21770
21771pub(crate) fn emit_degraded_search_note(
21772    targets: &[DegradedSearchTarget],
21773    mode: DegradedSearchMode,
21774) {
21775    let summary = degraded_search_targets_summary(targets);
21776    let reindex_cmd = &targets[0].reindex_cmd;
21777    match mode {
21778        DegradedSearchMode::ReadOnly => eprintln!(
21779            "note: active tsift writer detected; skipping autoindex because {}. \
21780             Continuing with read-only search and the current index snapshot; symbol hits may lag. \
21781             Retry `{}` after the active writer finishes for fresh index results.",
21782            summary, reindex_cmd
21783        ),
21784        DegradedSearchMode::Exact => eprintln!(
21785            "note: active tsift writer detected; skipping autoindex because {}. \
21786             Continuing with exact live-file search. Retry `{}` after the active writer finishes \
21787             for indexed symbol hits.",
21788            summary, reindex_cmd
21789        ),
21790    }
21791}
21792
21793fn search_timeout_message(
21794    timeout_secs: u64,
21795    strategy: &str,
21796    targets: &[SearchIndexTarget],
21797) -> Result<String> {
21798    let rebuild_targets = collect_rebuild_search_targets(targets)?;
21799    if rebuild_targets.is_empty() {
21800        return Ok(format!(
21801            "tsift search timed out after {}s (strategy: {}). \
21802             The search root looks fresh, so reindexing is unlikely to help. \
21803             Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
21804             or try a different strategy.",
21805            timeout_secs, strategy,
21806        ));
21807    }
21808
21809    Ok(format!(
21810        "tsift search timed out after {}s (strategy: {}). {}",
21811        timeout_secs,
21812        strategy,
21813        rebuild_search_targets_message(&rebuild_targets),
21814    ))
21815}
21816
21817fn is_exact_preferring_query_char(ch: char) -> bool {
21818    matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
21819}
21820
21821fn query_prefers_exact_search(query: &str) -> bool {
21822    let trimmed = query.trim();
21823    !trimmed.is_empty()
21824        && !trimmed.chars().any(char::is_whitespace)
21825        && trimmed.chars().any(|ch| ch.is_alphanumeric())
21826        && trimmed.chars().any(is_exact_preferring_query_char)
21827        && trimmed
21828            .chars()
21829            .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
21830}
21831
21832pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
21833    strategy.unwrap_or_else(|| {
21834        if query_prefers_exact_search(query) {
21835            "exact".to_string()
21836        } else {
21837            "lexical".to_string()
21838        }
21839    })
21840}
21841
21842pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21843    let mut files = Vec::new();
21844    if path.is_file() {
21845        files.push(path.to_path_buf());
21846        return Ok(files);
21847    }
21848    let walker = ignore::WalkBuilder::new(path)
21849        .hidden(true)
21850        .git_ignore(true)
21851        .build();
21852    for entry in walker {
21853        let entry = entry?;
21854        if entry.file_type().is_some_and(|ft| ft.is_file()) {
21855            let p = entry.path();
21856            if let Some(ext) = p.extension() {
21857                let ext = ext.to_string_lossy();
21858                if matches!(
21859                    ext.as_ref(),
21860                    "rs" | "py"
21861                        | "ts"
21862                        | "tsx"
21863                        | "js"
21864                        | "jsx"
21865                        | "kt"
21866                        | "kts"
21867                        | "zig"
21868                        | "sh"
21869                        | "bash"
21870                        | "zsh"
21871                ) {
21872                    files.push(p.to_path_buf());
21873                }
21874            }
21875        }
21876    }
21877    Ok(files)
21878}
21879
21880#[cfg(test)]
21881mod tests {
21882    use super::semantic_edit::{
21883        EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21884        markdown_section_spans,
21885    };
21886    use super::*;
21887    use tsift_memory::{MemoryEventKind, MemoryStore};
21888
21889    use std::cell::RefCell;
21890    use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21891
21892    #[test]
21893    fn graph_db_write_lock_serializes_concurrent_writers() {
21894        let dir = tempfile::tempdir().unwrap();
21895        let graph_db = dir.path().join(".tsift/graph.db");
21896        let short = Duration::from_millis(150);
21897
21898        let first = acquire_graph_db_write_lock_with_timeout(&graph_db, short)
21899            .expect("first writer acquires the lock");
21900        // A second acquire fails (bounded) while the first guard is held — this is
21901        // the cross-process mutual exclusion that protects refresh/snapshot-import.
21902        let second = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21903        assert!(
21904            second.is_err(),
21905            "a second writer must not acquire the graph-db write lock while it is held"
21906        );
21907        drop(first);
21908        // After release the lock is re-acquirable.
21909        let third = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21910        assert!(
21911            third.is_ok(),
21912            "graph-db write lock must be re-acquirable after release"
21913        );
21914    }
21915
21916    // #gdblockcover: `graph-db compact --apply` (DELETE + wal_checkpoint(TRUNCATE)
21917    // + VACUUM) must take the same advisory write lock as refresh/snapshot-import,
21918    // or its VACUUM races a concurrent refresh's WAL transaction. Hold the lock
21919    // externally and prove the compact blocks on it (rather than running unguarded
21920    // as it did before the fix), then completes once the lock is released.
21921    #[test]
21922    fn graph_db_compact_apply_blocks_on_held_write_lock() {
21923        let dir = setup_traversal_project();
21924        let session = dir.path().join("tasks/software/tsift.md");
21925        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
21926        let graph_db = graph_substrate_db_path(dir.path(), None);
21927
21928        let held = acquire_graph_db_write_lock(&graph_db).expect("hold writer lock");
21929
21930        let root = dir.path().to_path_buf();
21931        let handle = std::thread::Builder::new()
21932            .name("compact-apply".to_string())
21933            .stack_size(16 * 1024 * 1024)
21934            .spawn(move || {
21935                crate::commands::infra::cmd_graph_db_compact(
21936                    &root,
21937                    None,
21938                    true,
21939                    false,
21940                    false,
21941                    OutputFormat {
21942                        json_output: true,
21943                        compact: true,
21944                        pretty: false,
21945                        terse: false,
21946                        ultra_terse: false,
21947                        schema: false,
21948                        envelope: false,
21949                    },
21950                )
21951            })
21952            .unwrap();
21953
21954        // While the lock is held the compact cannot have finished — it must be
21955        // parked in the acquire loop. Before the fix it ran VACUUM unguarded and
21956        // would already be done here.
21957        std::thread::sleep(Duration::from_millis(300));
21958        assert!(
21959            !handle.is_finished(),
21960            "compact --apply must block on the held graph-db write lock, not run unguarded"
21961        );
21962
21963        drop(held);
21964        let result = handle.join().expect("compact thread joins");
21965        assert!(
21966            result.is_ok(),
21967            "compact --apply must succeed after the lock is released: {result:?}"
21968        );
21969    }
21970
21971    fn parse_cli<I, T>(itr: I) -> Cli
21972    where
21973        I: IntoIterator<Item = T> + Send + 'static,
21974        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21975    {
21976        std::thread::Builder::new()
21977            .name("cli-parse".to_string())
21978            .stack_size(16 * 1024 * 1024)
21979            .spawn(move || Cli::parse_from(itr))
21980            .unwrap()
21981            .join()
21982            .unwrap()
21983    }
21984
21985    fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21986    where
21987        I: IntoIterator<Item = T> + Send + 'static,
21988        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21989    {
21990        std::thread::Builder::new()
21991            .name("cli-try-parse".to_string())
21992            .stack_size(16 * 1024 * 1024)
21993            .spawn(move || Cli::try_parse_from(itr))
21994            .unwrap()
21995            .join()
21996            .unwrap()
21997    }
21998
21999    fn build_relative_search_budget_report(
22000        query: &str,
22001        strategy: &str,
22002        root: &Path,
22003        response: &sift::SearchResponse,
22004        symbol_hits: &[index::SymbolHit],
22005        budget: ResponseBudget,
22006        filters: &SearchFacetFilters,
22007    ) -> SearchBudgetReport {
22008        build_search_budget_report(SearchBudgetReportInput {
22009            query,
22010            strategy,
22011            root,
22012            response,
22013            symbol_hits,
22014            absolute: false,
22015            budget,
22016            filters,
22017        })
22018    }
22019
22020    #[derive(Default)]
22021    struct MemoryConvexGraphClient {
22022        nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
22023        edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
22024    }
22025
22026    impl ConvexGraphClient for MemoryConvexGraphClient {
22027        fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
22028            self.nodes
22029                .borrow_mut()
22030                .insert(row.external_id.clone(), row.clone());
22031            Ok(())
22032        }
22033
22034        fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
22035            self.edges
22036                .borrow_mut()
22037                .insert(row.edge_key.clone(), row.clone());
22038            Ok(())
22039        }
22040
22041        fn delete_node_row(&self, external_id: &str) -> Result<usize> {
22042            Ok(usize::from(
22043                self.nodes.borrow_mut().remove(external_id).is_some(),
22044            ))
22045        }
22046
22047        fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
22048            Ok(usize::from(
22049                self.edges.borrow_mut().remove(edge_key).is_some(),
22050            ))
22051        }
22052
22053        fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
22054            Ok(self.nodes.borrow().get(external_id).cloned())
22055        }
22056
22057        fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
22058            Ok(self.nodes.borrow().values().cloned().collect())
22059        }
22060
22061        fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
22062            Ok(self.edges.borrow().values().cloned().collect())
22063        }
22064
22065        fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
22066            Ok(self
22067                .nodes
22068                .borrow()
22069                .values()
22070                .filter(|row| row.kind == kind)
22071                .cloned()
22072                .collect())
22073        }
22074
22075        fn outgoing_edge_rows(
22076            &self,
22077            from_external_id: &str,
22078            kind: Option<&str>,
22079        ) -> Result<Vec<ConvexEdgeRow>> {
22080            Ok(self
22081                .edges
22082                .borrow()
22083                .values()
22084                .filter(|row| row.from_external_id == from_external_id)
22085                .filter(|row| kind.is_none_or(|kind| row.kind == kind))
22086                .cloned()
22087                .collect())
22088        }
22089    }
22090
22091    fn init_git_repo(path: &Path) {
22092        let status = std::process::Command::new("git")
22093            .args(["init"])
22094            .current_dir(path)
22095            .status()
22096            .unwrap();
22097        assert!(status.success(), "git init failed");
22098
22099        let status = std::process::Command::new("git")
22100            .args(["add", "."])
22101            .current_dir(path)
22102            .status()
22103            .unwrap();
22104        assert!(status.success(), "git add failed");
22105
22106        let status = std::process::Command::new("git")
22107            .args([
22108                "-c",
22109                "user.name=tsift-tests",
22110                "-c",
22111                "user.email=tsift-tests@example.com",
22112                "commit",
22113                "--quiet",
22114                "-m",
22115                "init",
22116            ])
22117            .current_dir(path)
22118            .status()
22119            .unwrap();
22120        assert!(status.success(), "git commit failed");
22121    }
22122
22123    fn write_empty_root_index(root: &Path) {
22124        let index_dir = root.join(".tsift");
22125        fs::create_dir_all(&index_dir).unwrap();
22126        fs::write(index_dir.join("index.db"), "").unwrap();
22127    }
22128
22129    fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
22130        if let Some(parent) = path.parent() {
22131            fs::create_dir_all(parent).unwrap();
22132        }
22133        let body = std::iter::repeat_n(line, lines)
22134            .collect::<Vec<_>>()
22135            .join("\n");
22136        fs::write(path, format!("{body}\n")).unwrap();
22137        path.to_path_buf()
22138    }
22139
22140    // --- build_token_capped_preview ---
22141
22142    #[test]
22143    fn token_capped_preview_returns_all_lines_when_under_cap() {
22144        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 1", "}"];
22145        let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
22146        assert!(!result.was_capped);
22147        assert_eq!(result.preview.len(), 3);
22148        assert_eq!(result.capped_end, 3);
22149    }
22150
22151    #[test]
22152    fn token_capped_preview_truncates_when_over_cap() {
22153        let lines: Vec<&str> = (0..200)
22154            .map(|_| "    let x = some_very_long_expression_here();")
22155            .collect();
22156        let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
22157        assert!(result.was_capped);
22158        assert!(result.preview.len() < 200);
22159        assert!(result.capped_end < 200);
22160    }
22161
22162    #[test]
22163    fn token_capped_preview_keeps_at_least_one_line() {
22164        let long_line: String = "x".repeat(8000);
22165        let lines: Vec<&str> = vec![&long_line];
22166        let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
22167        assert!(!result.was_capped);
22168        assert_eq!(result.preview.len(), 1);
22169    }
22170
22171    #[test]
22172    fn token_capped_preview_cap_at_boundary() {
22173        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22174        let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
22175        assert!(!result.was_capped);
22176        assert_eq!(result.preview.len(), 4);
22177    }
22178
22179    #[test]
22180    fn token_capped_preview_cap_just_over_boundary() {
22181        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22182        let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
22183        assert!(result.was_capped);
22184        assert_eq!(result.preview.len(), 3);
22185        assert_eq!(result.capped_end, 3);
22186    }
22187
22188    #[test]
22189    fn token_capped_preview_empty_lines() {
22190        let lines: Vec<&str> = vec![];
22191        let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
22192        assert!(!result.was_capped);
22193        assert!(result.preview.is_empty());
22194    }
22195
22196    #[test]
22197    fn token_capped_preview_per_line_truncation_applied() {
22198        let long_line = "x".repeat(500);
22199        let lines: Vec<&str> = vec![&long_line, "short"];
22200        let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
22201        assert!(!result.was_capped);
22202        assert_eq!(result.preview.len(), 2);
22203        assert!(result.preview[0].text.len() <= 23);
22204        assert!(result.preview[0].text.ends_with("..."));
22205    }
22206
22207    // --- classify_task ---
22208
22209    #[test]
22210    fn route_search_defaults_to_haiku() {
22211        let (tier, model) = classify_task("find all uses of authenticate");
22212        assert_eq!(tier, "haiku");
22213        assert!(
22214            model.contains("haiku"),
22215            "expected haiku model, got {}",
22216            model
22217        );
22218    }
22219
22220    #[test]
22221    fn route_edit_keywords_to_sonnet() {
22222        for kw in &[
22223            "edit the file",
22224            "fix the bug",
22225            "update the config",
22226            "remove dead code",
22227            "create a new module",
22228        ] {
22229            let (tier, _) = classify_task(kw);
22230            assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
22231        }
22232    }
22233
22234    #[test]
22235    fn route_architecture_keywords_to_opus() {
22236        for kw in &[
22237            "design the API",
22238            "architecture review",
22239            "plan the migration",
22240            "analyze the system",
22241            "evaluate trade-offs",
22242        ] {
22243            let (tier, _) = classify_task(kw);
22244            assert_eq!(tier, "opus", "expected opus for {:?}", kw);
22245        }
22246    }
22247
22248    #[test]
22249    fn route_architecture_beats_edit() {
22250        // "design and implement" — architecture signal wins (checked first)
22251        let (tier, _) = classify_task("design and implement the new auth service");
22252        assert_eq!(tier, "opus");
22253    }
22254
22255    #[test]
22256    fn cli_accepts_global_compact_flag() {
22257        let cli = parse_cli(["tsift", "--compact", "status"]);
22258        assert!(cli.compact);
22259        assert!(matches!(cli.command, Some(Commands::Status { .. })));
22260    }
22261
22262    #[test]
22263    fn summarize_diff_scope_matches_relative_directory() {
22264        let root = Path::new("/repo");
22265        let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
22266
22267        assert!(summarize_diff_matches_scope(
22268            Path::new("/repo/src/feature/main.rs"),
22269            &extract_scope
22270        ));
22271        assert!(!summarize_diff_matches_scope(
22272            Path::new("/repo/src/other/main.rs"),
22273            &extract_scope
22274        ));
22275    }
22276
22277    #[test]
22278    fn summarize_diff_scope_matches_relative_file() {
22279        let root = Path::new("/repo");
22280        let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
22281
22282        assert!(summarize_diff_matches_scope(
22283            Path::new("/repo/src/feature/main.rs"),
22284            &extract_scope
22285        ));
22286        assert!(!summarize_diff_matches_scope(
22287            Path::new("/repo/src/feature/lib.rs"),
22288            &extract_scope
22289        ));
22290    }
22291
22292    #[test]
22293    fn summarize_extract_scope_walks_relative_paths_from_root() {
22294        let dir = tempfile::tempdir().unwrap();
22295        let source_dir = dir.path().join("src");
22296        std::fs::create_dir_all(&source_dir).unwrap();
22297        let main_rs = source_dir.join("main.rs");
22298        std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
22299
22300        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
22301        let files = collect_source_files(&extract_scope).unwrap();
22302
22303        assert_eq!(files, vec![main_rs]);
22304    }
22305
22306    #[test]
22307    fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
22308        let dir = tempfile::tempdir().unwrap();
22309        let nested = dir.path().join("src/nested");
22310        std::fs::create_dir_all(&nested).unwrap();
22311        std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
22312        let nested_file = nested.join("main.rs");
22313        std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
22314
22315        let extract_base = resolve_extract_base(&nested).unwrap();
22316        let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
22317        let files = collect_source_files(&extract_scope).unwrap();
22318
22319        assert_eq!(extract_scope, nested);
22320        assert_eq!(files, vec![nested_file]);
22321    }
22322
22323    #[test]
22324    fn summarize_extract_base_uses_parent_of_file_path() {
22325        let dir = tempfile::tempdir().unwrap();
22326        let nested = dir.path().join("src/nested");
22327        std::fs::create_dir_all(&nested).unwrap();
22328        let file_path = nested.join("main.rs");
22329        std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
22330
22331        let extract_base = resolve_extract_base(&file_path).unwrap();
22332
22333        assert_eq!(extract_base, nested);
22334    }
22335
22336    #[test]
22337    fn summarize_extract_scope_normalizes_dotdot_segments() {
22338        let dir = tempfile::tempdir().unwrap();
22339        let source_dir = dir.path().join("src");
22340        std::fs::create_dir_all(&source_dir).unwrap();
22341
22342        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
22343
22344        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22345        assert!(summarize_diff_matches_scope(
22346            &source_dir.join("main.rs"),
22347            &extract_scope
22348        ));
22349    }
22350
22351    #[cfg(unix)]
22352    #[test]
22353    fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
22354        use std::os::unix::fs::symlink;
22355
22356        let dir = tempfile::tempdir().unwrap();
22357        let real_root = dir.path().join("real");
22358        let source_dir = real_root.join("src");
22359        std::fs::create_dir_all(&source_dir).unwrap();
22360        let symlink_scope = dir.path().join("scope-link");
22361        symlink(&source_dir, &symlink_scope).unwrap();
22362
22363        let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
22364
22365        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22366        assert!(summarize_diff_matches_scope(
22367            &source_dir.join("lib.rs"),
22368            &extract_scope
22369        ));
22370    }
22371
22372    #[test]
22373    fn summarize_diff_extract_includes_untracked_files() {
22374        let dir = tempfile::tempdir().unwrap();
22375        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22376        init_git_repo(dir.path());
22377
22378        let source_dir = dir.path().join("src");
22379        std::fs::create_dir_all(&source_dir).unwrap();
22380        let new_file = source_dir.join("new.rs");
22381        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22382
22383        let files = summarize::git_changed_files(dir.path()).unwrap();
22384
22385        assert_eq!(files.existing, vec![new_file]);
22386        assert!(files.deleted.is_empty());
22387    }
22388
22389    #[test]
22390    fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
22391        let dir = tempfile::tempdir().unwrap();
22392        let status = std::process::Command::new("git")
22393            .args(["init"])
22394            .current_dir(dir.path())
22395            .status()
22396            .unwrap();
22397        assert!(status.success(), "git init failed");
22398
22399        let source_dir = dir.path().join("src");
22400        std::fs::create_dir_all(&source_dir).unwrap();
22401        let new_file = source_dir.join("new.rs");
22402        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22403
22404        let files = summarize::git_changed_files(dir.path()).unwrap();
22405
22406        assert_eq!(files.existing, vec![new_file]);
22407        assert!(files.deleted.is_empty());
22408    }
22409
22410    #[test]
22411    fn summarize_diff_extract_tracks_deleted_files() {
22412        let dir = tempfile::tempdir().unwrap();
22413        let source_dir = dir.path().join("src");
22414        std::fs::create_dir_all(&source_dir).unwrap();
22415        let deleted_file = source_dir.join("gone.rs");
22416        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22417        init_git_repo(dir.path());
22418
22419        std::fs::remove_file(&deleted_file).unwrap();
22420
22421        let files = summarize::git_changed_files(dir.path()).unwrap();
22422
22423        assert!(files.existing.is_empty());
22424        assert_eq!(files.deleted, vec![deleted_file]);
22425    }
22426
22427    #[test]
22428    fn summarize_diff_extract_tracks_git_renames() {
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 old_file = source_dir.join("old.rs");
22433        let new_file = source_dir.join("new.rs");
22434        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22435        init_git_repo(dir.path());
22436
22437        let status = std::process::Command::new("git")
22438            .args(["mv", "src/old.rs", "src/new.rs"])
22439            .current_dir(dir.path())
22440            .status()
22441            .unwrap();
22442        assert!(status.success(), "git mv failed");
22443
22444        let files = summarize::git_changed_files(dir.path()).unwrap();
22445
22446        assert_eq!(files.existing, vec![new_file]);
22447        assert_eq!(files.deleted, vec![old_file]);
22448    }
22449
22450    #[test]
22451    fn summarize_diff_extract_deletes_removed_summary_rows() {
22452        let dir = tempfile::tempdir().unwrap();
22453        let source_dir = dir.path().join("src");
22454        std::fs::create_dir_all(&source_dir).unwrap();
22455        let deleted_file = source_dir.join("gone.rs");
22456        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22457        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22458        init_git_repo(dir.path());
22459
22460        let summary_db =
22461            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22462        summary_db
22463            .insert(&summarize::Summary {
22464                id: 0,
22465                symbol_name: "stale".to_string(),
22466                file_path: "src/gone.rs".to_string(),
22467                content_hash: "hash1".to_string(),
22468                summary: "stale summary".to_string(),
22469                entities: None,
22470                relationships: None,
22471                concept_labels: None,
22472                extracted_at: "1700000000".to_string(),
22473                model: "test".to_string(),
22474                tokens_input: Some(100),
22475                tokens_output: Some(50),
22476            })
22477            .unwrap();
22478
22479        std::fs::remove_file(&deleted_file).unwrap();
22480
22481        cmd_summarize(
22482            None,
22483            None,
22484            Some(PathBuf::from("src")),
22485            true,
22486            false,
22487            dir.path(),
22488            false,
22489            true,
22490            false,
22491            false,
22492            false,
22493            None,
22494        )
22495        .unwrap();
22496
22497        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22498    }
22499
22500    #[test]
22501    fn summarize_diff_extract_deletes_renamed_summary_rows() {
22502        let dir = tempfile::tempdir().unwrap();
22503        let source_dir = dir.path().join("src");
22504        std::fs::create_dir_all(&source_dir).unwrap();
22505        let old_file = source_dir.join("old.rs");
22506        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22507        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22508        init_git_repo(dir.path());
22509
22510        let summary_db =
22511            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22512        summary_db
22513            .insert(&summarize::Summary {
22514                id: 0,
22515                symbol_name: "stale".to_string(),
22516                file_path: "src/old.rs".to_string(),
22517                content_hash: "hash1".to_string(),
22518                summary: "stale summary".to_string(),
22519                entities: None,
22520                relationships: None,
22521                concept_labels: None,
22522                extracted_at: "1700000000".to_string(),
22523                model: "test".to_string(),
22524                tokens_input: Some(100),
22525                tokens_output: Some(50),
22526            })
22527            .unwrap();
22528
22529        let status = std::process::Command::new("git")
22530            .args(["mv", "src/old.rs", "src/new.rs"])
22531            .current_dir(dir.path())
22532            .status()
22533            .unwrap();
22534        assert!(status.success(), "git mv failed");
22535
22536        cmd_summarize(
22537            None,
22538            None,
22539            Some(PathBuf::from("src")),
22540            true,
22541            false,
22542            dir.path(),
22543            false,
22544            true,
22545            false,
22546            false,
22547            false,
22548            None,
22549        )
22550        .unwrap();
22551
22552        assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
22553    }
22554
22555    #[test]
22556    fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
22557        let dir = tempfile::tempdir().unwrap();
22558        let source_dir = dir.path().join("src");
22559        std::fs::create_dir_all(&source_dir).unwrap();
22560        let deleted_file = source_dir.join("gone.rs");
22561        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22562
22563        let summary_db =
22564            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22565        summary_db
22566            .insert(&summarize::Summary {
22567                id: 0,
22568                symbol_name: "stale".to_string(),
22569                file_path: "src/gone.rs".to_string(),
22570                content_hash: "hash1".to_string(),
22571                summary: "stale summary".to_string(),
22572                entities: None,
22573                relationships: None,
22574                concept_labels: None,
22575                extracted_at: "1700000000".to_string(),
22576                model: "test".to_string(),
22577                tokens_input: Some(100),
22578                tokens_output: Some(50),
22579            })
22580            .unwrap();
22581
22582        std::fs::remove_file(&deleted_file).unwrap();
22583
22584        cmd_summarize(
22585            None,
22586            None,
22587            Some(PathBuf::from("src")),
22588            false,
22589            false,
22590            dir.path(),
22591            false,
22592            true,
22593            false,
22594            false,
22595            false,
22596            None,
22597        )
22598        .unwrap();
22599
22600        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22601    }
22602
22603    #[test]
22604    fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
22605        let dir = tempfile::tempdir().unwrap();
22606        let source_dir = dir.path().join("src");
22607        std::fs::create_dir_all(&source_dir).unwrap();
22608        let file = source_dir.join("lib.rs");
22609        std::fs::write(&file, "fn helper() {}\n").unwrap();
22610
22611        let content = std::fs::read(&file).unwrap();
22612        let summary_db =
22613            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22614        summary_db
22615            .insert(&summarize::Summary {
22616                id: 0,
22617                symbol_name: "lib.rs".to_string(),
22618                file_path: "src/lib.rs".to_string(),
22619                content_hash: summarize::content_hash(&content),
22620                summary: "cached summary".to_string(),
22621                entities: None,
22622                relationships: None,
22623                concept_labels: None,
22624                extracted_at: "1700000000".to_string(),
22625                model: "test".to_string(),
22626                tokens_input: Some(100),
22627                tokens_output: Some(50),
22628            })
22629            .unwrap();
22630        drop(summary_db);
22631
22632        let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
22633        let _lock = hold_writer_lock(&lock_path);
22634
22635        let err = cmd_summarize(
22636            None,
22637            None,
22638            Some(PathBuf::from("src")),
22639            false,
22640            false,
22641            dir.path(),
22642            false,
22643            true,
22644            false,
22645            false,
22646            false,
22647            None,
22648        )
22649        .unwrap_err();
22650        let message = err.to_string();
22651
22652        assert!(message.contains("another tsift summarize extractor is already active"));
22653        assert!(message.contains("tsift summarize --extract"));
22654    }
22655
22656    #[test]
22657    fn summarize_stats_fails_closed_when_cache_missing() {
22658        let dir = tempfile::tempdir().unwrap();
22659        let err = cmd_summarize(
22660            None,
22661            None,
22662            None,
22663            false,
22664            true,
22665            dir.path(),
22666            false,
22667            false,
22668            false,
22669            false,
22670            false,
22671            None,
22672        )
22673        .unwrap_err();
22674
22675        assert!(
22676            err.to_string().contains("no summaries.db found"),
22677            "got: {err}"
22678        );
22679        assert!(!dir.path().join(".tsift/summaries.db").exists());
22680    }
22681
22682    #[test]
22683    fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22684        let dir = tempfile::tempdir().unwrap();
22685        let summary_db =
22686            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22687        summary_db
22688            .insert(&summarize::Summary {
22689                id: 0,
22690                symbol_name: "alpha_helper".to_string(),
22691                file_path: "src/lib.rs".to_string(),
22692                content_hash: "hash1".to_string(),
22693                summary: "cached summary".to_string(),
22694                entities: None,
22695                relationships: None,
22696                concept_labels: None,
22697                extracted_at: "1700000000".to_string(),
22698                model: "claude-haiku-4-5-20251001".to_string(),
22699                tokens_input: Some(100),
22700                tokens_output: Some(40),
22701            })
22702            .unwrap();
22703        drop(summary_db);
22704        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22705
22706        let result = cmd_summarize(
22707            None,
22708            None,
22709            None,
22710            false,
22711            true,
22712            dir.path(),
22713            false,
22714            false,
22715            false,
22716            false,
22717            false,
22718            None,
22719        );
22720
22721        assert!(result.is_ok());
22722    }
22723
22724    #[test]
22725    fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22726        let dir = tempfile::tempdir().unwrap();
22727        let summary_db =
22728            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22729        summary_db
22730            .insert(&summarize::Summary {
22731                id: 0,
22732                symbol_name: "alpha_helper".to_string(),
22733                file_path: "src/lib.rs".to_string(),
22734                content_hash: "hash1".to_string(),
22735                summary: "cached summary".to_string(),
22736                entities: None,
22737                relationships: None,
22738                concept_labels: None,
22739                extracted_at: "1700000000".to_string(),
22740                model: "claude-haiku-4-5-20251001".to_string(),
22741                tokens_input: Some(100),
22742                tokens_output: Some(40),
22743            })
22744            .unwrap();
22745        drop(summary_db);
22746        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22747
22748        let result = cmd_summarize(
22749            Some("alpha_helper".to_string()),
22750            None,
22751            None,
22752            false,
22753            false,
22754            dir.path(),
22755            false,
22756            true,
22757            false,
22758            false,
22759            false,
22760            None,
22761        );
22762
22763        assert!(result.is_ok());
22764    }
22765
22766    #[test]
22767    fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
22768        let dir = tempfile::tempdir().unwrap();
22769        let nested = dir.path().join("src/nested");
22770        std::fs::create_dir_all(&nested).unwrap();
22771
22772        let summary_db =
22773            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22774        summary_db
22775            .insert(&summarize::Summary {
22776                id: 0,
22777                symbol_name: "alpha_helper".to_string(),
22778                file_path: "src/lib.rs".to_string(),
22779                content_hash: "hash1".to_string(),
22780                summary: "cached summary".to_string(),
22781                entities: None,
22782                relationships: None,
22783                concept_labels: None,
22784                extracted_at: "1700000000".to_string(),
22785                model: "claude-haiku-4-5-20251001".to_string(),
22786                tokens_input: Some(100),
22787                tokens_output: Some(40),
22788            })
22789            .unwrap();
22790
22791        let result = cmd_summarize(
22792            Some("alpha_helper".to_string()),
22793            None,
22794            None,
22795            false,
22796            false,
22797            &nested,
22798            false,
22799            true,
22800            false,
22801            false,
22802            false,
22803            None,
22804        );
22805
22806        assert!(result.is_ok());
22807        assert!(!nested.join(".tsift/summaries.db").exists());
22808    }
22809
22810    #[test]
22811    fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
22812        let dir = tempfile::tempdir().unwrap();
22813        std::fs::write(
22814            dir.path().join(".gitmodules"),
22815            r#"[submodule "src/alpha"]
22816	path = src/alpha
22817	url = https://example.com/alpha
22818[submodule "src/beta"]
22819	path = src/beta
22820	url = https://example.com/beta
22821"#,
22822        )
22823        .unwrap();
22824
22825        let alpha_root = dir.path().join("src/alpha");
22826        let beta_root = dir.path().join("src/beta");
22827        std::fs::create_dir_all(alpha_root.join("src")).unwrap();
22828        std::fs::create_dir_all(beta_root.join("src")).unwrap();
22829        std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
22830        std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
22831        std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
22832        let beta_file = beta_root.join("src/lib.rs");
22833        std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
22834        std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
22835        std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
22836
22837        let context = find_symbols_db_for_file(dir.path(), &beta_file)
22838            .unwrap()
22839            .expect("expected matching scoped index");
22840
22841        assert_eq!(
22842            context.db_path,
22843            dir.path().join(".tsift/indexes/beta/index.db")
22844        );
22845        assert_eq!(context.source_root, beta_root);
22846    }
22847
22848    // --- apply_edit_op ---
22849
22850    fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
22851        EditOp {
22852            file: PathBuf::from("dummy.txt"),
22853            old: old.to_string(),
22854            new: new.to_string(),
22855            replace_all,
22856        }
22857    }
22858
22859    #[test]
22860    fn edit_replaces_single_occurrence() {
22861        let content = "hello world";
22862        let op = make_op("world", "rust", false);
22863        let (result, count) = apply_edit_op(content, &op).unwrap();
22864        assert_eq!(result, "hello rust");
22865        assert_eq!(count, 1);
22866    }
22867
22868    #[test]
22869    fn edit_replace_all_replaces_every_occurrence() {
22870        let content = "foo foo foo";
22871        let op = make_op("foo", "bar", true);
22872        let (result, count) = apply_edit_op(content, &op).unwrap();
22873        assert_eq!(result, "bar bar bar");
22874        assert_eq!(count, 3);
22875    }
22876
22877    #[test]
22878    fn edit_fails_when_old_not_found() {
22879        let content = "hello world";
22880        let op = make_op("missing", "x", false);
22881        assert!(apply_edit_op(content, &op).is_err());
22882    }
22883
22884    #[test]
22885    fn edit_fails_when_ambiguous_without_replace_all() {
22886        let content = "foo foo";
22887        let op = make_op("foo", "bar", false);
22888        let err = apply_edit_op(content, &op).unwrap_err();
22889        assert!(err.to_string().contains("2 times"), "got: {}", err);
22890    }
22891
22892    #[test]
22893    fn edit_fails_when_old_equals_new() {
22894        let content = "hello";
22895        let op = make_op("hello", "hello", false);
22896        assert!(apply_edit_op(content, &op).is_err());
22897    }
22898
22899    #[test]
22900    fn edit_batch_rolls_back_when_later_swap_fails() {
22901        let dir = tempfile::tempdir().unwrap();
22902        let alpha = dir.path().join("alpha.txt");
22903        let beta = dir.path().join("beta.txt");
22904        fs::write(&alpha, "alpha old\n").unwrap();
22905        fs::write(&beta, "beta old\n").unwrap();
22906
22907        let batch = EditBatch {
22908            edits: vec![
22909                EditOp {
22910                    file: alpha.clone(),
22911                    old: "old".to_string(),
22912                    new: "new".to_string(),
22913                    replace_all: false,
22914                },
22915                EditOp {
22916                    file: beta.clone(),
22917                    old: "old".to_string(),
22918                    new: "new".to_string(),
22919                    replace_all: false,
22920                },
22921            ],
22922        };
22923
22924        let plan = build_edit_plan(&batch).unwrap();
22925        let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
22926            if commit_index == 1 {
22927                bail!("simulated swap failure");
22928            }
22929            Ok(())
22930        }) {
22931            Ok(_) => panic!("expected simulated swap failure"),
22932            Err(err) => err,
22933        };
22934
22935        assert!(err.to_string().contains("simulated swap failure"));
22936        assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22937        assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22938    }
22939
22940    // --- SQL introspection ---
22941
22942    fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22943        let tmp = tempfile::NamedTempFile::new().unwrap();
22944        let conn = Connection::open(tmp.path()).unwrap();
22945        conn.execute_batch(
22946            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22947             INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22948             INSERT INTO users VALUES (2, 'Bob', NULL);
22949             CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22950                 FOREIGN KEY(user_id) REFERENCES users(id));
22951             INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22952             INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22953             INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22954        ).unwrap();
22955        (tmp, conn)
22956    }
22957
22958    // --- rewrite_command ---
22959
22960    #[test]
22961    fn rewrite_rg_simple_pattern() {
22962        let result = rewrite_command("rg authenticate");
22963        assert_eq!(
22964            result,
22965            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22966        );
22967    }
22968
22969    #[test]
22970    fn rewrite_rg_with_path() {
22971        let result = rewrite_command("rg authenticate src/");
22972        assert_eq!(
22973            result,
22974            Some(
22975                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22976                    .to_string()
22977            )
22978        );
22979    }
22980
22981    #[test]
22982    fn rewrite_rg_with_flags_ignored() {
22983        let result = rewrite_command("rg -i authenticate src/");
22984        assert_eq!(
22985            result,
22986            Some(
22987                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22988                    .to_string()
22989            )
22990        );
22991    }
22992
22993    #[test]
22994    fn rewrite_rg_with_type_flag() {
22995        // -t rs takes a value, should be skipped; pattern is next positional
22996        let result = rewrite_command("rg -t rs authenticate");
22997        assert_eq!(
22998            result,
22999            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
23000        );
23001    }
23002
23003    #[test]
23004    fn rewrite_rg_pipe_passthrough() {
23005        // Pipe chains can't be translated — pass through
23006        let result = rewrite_command("rg authenticate | head -5");
23007        assert_eq!(result, None);
23008    }
23009
23010    #[test]
23011    fn rewrite_rg_files_passthrough() {
23012        let result = rewrite_command("rg --files src/tsift .agent-doc logs");
23013        assert_eq!(result, None);
23014    }
23015
23016    #[test]
23017    fn rewrite_find_passthrough() {
23018        let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
23019        assert_eq!(result, None);
23020    }
23021
23022    #[test]
23023    fn rewrite_grep_recursive() {
23024        let result = rewrite_command("grep -r authenticate src/");
23025        assert_eq!(
23026            result,
23027            Some(
23028                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
23029                    .to_string()
23030            )
23031        );
23032    }
23033
23034    #[test]
23035    fn rewrite_grep_non_recursive_passthrough() {
23036        let result = rewrite_command("grep authenticate file.txt");
23037        assert_eq!(result, None);
23038    }
23039
23040    #[test]
23041    fn rewrite_tsift_passthrough() {
23042        let result = rewrite_command("tsift search \"foo\"");
23043        assert_eq!(result, Some("tsift search \"foo\"".to_string()));
23044    }
23045
23046    #[test]
23047    fn rewrite_run_tsift_search_disables_timeout_by_default() {
23048        let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
23049        assert_eq!(
23050            result,
23051            "tsift search hookcaps --exact --path /tmp/x --timeout 0"
23052        );
23053    }
23054
23055    #[test]
23056    fn rewrite_run_preserves_explicit_search_timeout() {
23057        let result = effective_rewrite_run_command(
23058            "tsift search hookcaps --exact --path /tmp/x --timeout 5",
23059        );
23060        assert_eq!(
23061            result,
23062            "tsift search hookcaps --exact --path /tmp/x --timeout 5"
23063        );
23064    }
23065
23066    #[test]
23067    fn rewrite_unrelated_passthrough() {
23068        let result = rewrite_command("echo cargo build");
23069        assert_eq!(result, None);
23070    }
23071
23072    #[test]
23073    fn rewrite_rg_quoted_pattern() {
23074        let result = rewrite_command("rg \"fn main\"");
23075        assert_eq!(
23076            result,
23077            Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
23078        );
23079    }
23080
23081    #[test]
23082    fn rewrite_git_diff_to_diff_digest() {
23083        let result = rewrite_command("git diff");
23084        assert_eq!(result, Some("tsift diff-digest .".to_string()));
23085    }
23086
23087    #[test]
23088    fn rewrite_git_diff_cached_to_diff_digest() {
23089        let result = rewrite_command("git diff --cached");
23090        assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
23091    }
23092
23093    #[test]
23094    fn rewrite_git_diff_with_path_to_diff_digest() {
23095        let result = rewrite_command("git diff -- src/");
23096        assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
23097    }
23098
23099    #[test]
23100    fn rewrite_git_diff_with_revision_passthrough() {
23101        let result = rewrite_command("git diff HEAD~1");
23102        assert_eq!(result, None);
23103    }
23104
23105    #[test]
23106    fn rewrite_git_show_to_revision_diff_digest() {
23107        let result = rewrite_command("git show HEAD~1");
23108        assert_eq!(
23109            result,
23110            Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
23111        );
23112    }
23113
23114    #[test]
23115    fn rewrite_git_log_patch_history_to_revision_diff_digest() {
23116        let result = rewrite_command("git log -p -1 HEAD~2");
23117        assert_eq!(
23118            result,
23119            Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
23120        );
23121    }
23122
23123    #[test]
23124    fn rewrite_cat_long_agent_doc_session_to_session_digest() {
23125        let dir = tempfile::tempdir().unwrap();
23126        let session = dir.path().join("tsift.md");
23127        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23128        for index in 0..90 {
23129            body.push_str(&format!("❯ prompt {index}?\n"));
23130        }
23131        fs::write(&session, body).unwrap();
23132
23133        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23134        assert_eq!(
23135            result,
23136            Some(format!(
23137                "tsift session-digest --path {} --input {} --source markdown",
23138                shell_quote(&resolve_digest_context_path(&session)),
23139                shell_quote(session.to_str().unwrap())
23140            ))
23141        );
23142    }
23143
23144    #[test]
23145    fn rewrite_head_long_claude_jsonl_to_session_digest() {
23146        let dir = tempfile::tempdir().unwrap();
23147        let session = dir.path().join("session.jsonl");
23148        let line =
23149            r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
23150        let body = std::iter::repeat_n(line, 120)
23151            .collect::<Vec<_>>()
23152            .join("\n");
23153        fs::write(&session, format!("{body}\n")).unwrap();
23154
23155        let result = rewrite_command(&format!(
23156            "head -n 120 {}",
23157            shell_quote(session.to_str().unwrap())
23158        ));
23159        assert_eq!(
23160            result,
23161            Some(format!(
23162                "tsift session-digest --path {} --input {} --source claude-jsonl",
23163                shell_quote(&resolve_digest_context_path(&session)),
23164                shell_quote(session.to_str().unwrap())
23165            ))
23166        );
23167    }
23168
23169    #[test]
23170    fn rewrite_head_long_codex_jsonl_to_session_digest() {
23171        let dir = tempfile::tempdir().unwrap();
23172        let session = dir.path().join("codex.jsonl");
23173        let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
23174        let body = std::iter::repeat_n(line, 120)
23175            .collect::<Vec<_>>()
23176            .join("\n");
23177        fs::write(&session, format!("{body}\n")).unwrap();
23178
23179        let result = rewrite_command(&format!(
23180            "head -n 120 {}",
23181            shell_quote(session.to_str().unwrap())
23182        ));
23183        assert_eq!(
23184            result,
23185            Some(format!(
23186                "tsift session-digest --path {} --input {} --source codex-jsonl",
23187                shell_quote(&resolve_digest_context_path(&session)),
23188                shell_quote(session.to_str().unwrap())
23189            ))
23190        );
23191    }
23192
23193    #[test]
23194    fn rewrite_small_transcript_window_passthrough() {
23195        let dir = tempfile::tempdir().unwrap();
23196        let session = dir.path().join("session.jsonl");
23197        let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
23198        let body = std::iter::repeat_n(line, 120)
23199            .collect::<Vec<_>>()
23200            .join("\n");
23201        fs::write(&session, format!("{body}\n")).unwrap();
23202
23203        let result = rewrite_command(&format!(
23204            "tail -n 20 {}",
23205            shell_quote(session.to_str().unwrap())
23206        ));
23207        assert_eq!(result, None);
23208    }
23209
23210    #[test]
23211    fn rewrite_sed_large_agent_doc_range_to_session_digest() {
23212        let dir = tempfile::tempdir().unwrap();
23213        let session = dir.path().join("tsift.md");
23214        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23215        for index in 0..120 {
23216            body.push_str(&format!("### Re: topic {index}\n"));
23217        }
23218        fs::write(&session, body).unwrap();
23219
23220        let result = rewrite_command(&format!(
23221            "sed -n '1,120p' {}",
23222            shell_quote(session.to_str().unwrap())
23223        ));
23224        assert_eq!(
23225            result,
23226            Some(format!(
23227                "tsift session-digest --path {} --input {} --source markdown",
23228                shell_quote(&resolve_digest_context_path(&session)),
23229                shell_quote(session.to_str().unwrap())
23230            ))
23231        );
23232    }
23233
23234    #[test]
23235    fn rewrite_cat_large_agent_doc_log_to_session_digest() {
23236        let dir = tempfile::tempdir().unwrap();
23237        let session = dir.path().join("tsift.log");
23238        let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
23239        let body = std::iter::repeat_n(line, 120)
23240            .collect::<Vec<_>>()
23241            .join("\n");
23242        fs::write(&session, format!("{body}\n")).unwrap();
23243
23244        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23245        assert_eq!(
23246            result,
23247            Some(format!(
23248                "tsift session-digest --path {} --input {} --source agent-doc-log",
23249                shell_quote(&resolve_digest_context_path(&session)),
23250                shell_quote(session.to_str().unwrap())
23251            ))
23252        );
23253    }
23254
23255    #[test]
23256    fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
23257        let dir = tempfile::tempdir().unwrap();
23258        fs::write(
23259            dir.path().join(".gitmodules"),
23260            r#"[submodule "src/tsift"]
23261	path = src/tsift
23262	url = https://example.com/tsift
23263"#,
23264        )
23265        .unwrap();
23266        let submodule = dir.path().join("src/tsift");
23267        fs::create_dir_all(submodule.join("tasks")).unwrap();
23268        fs::write(
23269            submodule.join(".git"),
23270            "gitdir: ../../.git/modules/src/tsift\n",
23271        )
23272        .unwrap();
23273        let session = submodule.join("tasks/plan.md");
23274        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23275        for index in 0..90 {
23276            body.push_str(&format!("❯ prompt {index}?\n"));
23277        }
23278        fs::write(&session, body).unwrap();
23279
23280        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23281
23282        assert_eq!(
23283            result,
23284            Some(format!(
23285                "tsift session-digest --path {} --input {} --source markdown",
23286                shell_quote(submodule.to_str().unwrap()),
23287                shell_quote(session.to_str().unwrap())
23288            ))
23289        );
23290    }
23291
23292    #[test]
23293    fn rewrite_regular_markdown_read_passthrough() {
23294        let dir = tempfile::tempdir().unwrap();
23295        let readme = dir.path().join("README.md");
23296        let body = std::iter::repeat_n("plain markdown", 120)
23297            .collect::<Vec<_>>()
23298            .join("\n");
23299        fs::write(&readme, format!("{body}\n")).unwrap();
23300
23301        let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
23302        assert_eq!(result, None);
23303    }
23304
23305    #[test]
23306    fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
23307        let dir = tempfile::tempdir().unwrap();
23308        write_empty_root_index(dir.path());
23309        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23310
23311        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23312
23313        assert_eq!(
23314            result,
23315            Some(format!(
23316                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
23317                shell_quote(&dir.path().to_string_lossy())
23318            ))
23319        );
23320    }
23321
23322    #[test]
23323    fn rewrite_head_small_source_window_passthrough() {
23324        let dir = tempfile::tempdir().unwrap();
23325        write_empty_root_index(dir.path());
23326        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23327
23328        let result = rewrite_command(&format!(
23329            "head -n 20 {}",
23330            shell_quote(source.to_str().unwrap())
23331        ));
23332
23333        assert_eq!(result, None);
23334    }
23335
23336    #[test]
23337    fn rewrite_sed_large_source_range_to_source_read() {
23338        let dir = tempfile::tempdir().unwrap();
23339        write_empty_root_index(dir.path());
23340        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23341
23342        let result = rewrite_command(&format!(
23343            "sed -n '40,160p' {}",
23344            shell_quote(source.to_str().unwrap())
23345        ));
23346
23347        assert_eq!(
23348            result,
23349            Some(format!(
23350                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
23351                shell_quote(&dir.path().to_string_lossy())
23352            ))
23353        );
23354    }
23355
23356    #[test]
23357    fn rewrite_tail_large_source_window_preserves_tail_anchor() {
23358        let dir = tempfile::tempdir().unwrap();
23359        write_empty_root_index(dir.path());
23360        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23361
23362        let result = rewrite_command(&format!(
23363            "tail -n 120 {}",
23364            shell_quote(source.to_str().unwrap())
23365        ));
23366
23367        assert_eq!(
23368            result,
23369            Some(format!(
23370                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
23371                shell_quote(&dir.path().to_string_lossy())
23372            ))
23373        );
23374    }
23375
23376    #[test]
23377    fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
23378        let dir = tempfile::tempdir().unwrap();
23379        write_empty_root_index(dir.path());
23380        let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
23381
23382        let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
23383
23384        assert_eq!(result, None);
23385    }
23386
23387    #[test]
23388    fn rewrite_large_source_read_passthrough_without_index() {
23389        let dir = tempfile::tempdir().unwrap();
23390        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23391
23392        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23393
23394        assert_eq!(result, None);
23395    }
23396
23397    #[test]
23398    fn rewrite_cargo_test_to_digest_runner() {
23399        let result = rewrite_command("cargo test --lib");
23400        assert_eq!(
23401            result,
23402            Some(
23403                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
23404            )
23405        );
23406    }
23407
23408    #[test]
23409    fn rewrite_pytest_to_digest_runner() {
23410        let result = rewrite_command("pytest -q tests/test_cli.py");
23411        assert_eq!(
23412            result,
23413            Some(
23414                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
23415            )
23416        );
23417    }
23418
23419    #[test]
23420    fn rewrite_python_m_pytest_to_digest_runner() {
23421        let result = rewrite_command("python -m pytest tests/test_cli.py");
23422        assert_eq!(
23423            result,
23424            Some(
23425                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
23426            )
23427        );
23428    }
23429
23430    #[test]
23431    fn rewrite_cargo_build_to_log_digest_runner() {
23432        let result = rewrite_command("cargo build --release");
23433        assert_eq!(
23434            result,
23435            Some(
23436                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
23437            )
23438        );
23439    }
23440
23441    #[test]
23442    fn rewrite_cargo_install_to_log_digest_runner() {
23443        let result = rewrite_command("cargo install --path . --force");
23444        assert_eq!(
23445            result,
23446            Some(
23447                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
23448            )
23449        );
23450    }
23451
23452    #[test]
23453    fn rewrite_metacharacter_command_passthrough() {
23454        let result = rewrite_command("cargo test | head");
23455        assert_eq!(result, None);
23456    }
23457
23458    #[test]
23459    fn rewrite_output_cap_detects_search_even_with_global_flag() {
23460        let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
23461        assert_eq!(cap.max_lines, 50);
23462        assert_eq!(cap.strip_prefix, Some("Strategy:"));
23463    }
23464
23465    #[test]
23466    fn rewrite_output_cap_skips_structured_output() {
23467        assert!(rewrite_output_cap("tsift search foo --json").is_none());
23468        assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
23469        assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
23470    }
23471
23472    #[test]
23473    fn rewrite_output_format_forwards_envelope_to_digest_runner() {
23474        let command = rewrite_command("cargo test --lib").expect("rewrite");
23475        let forwarded = apply_rewrite_output_format(
23476            &command,
23477            OutputFormat {
23478                json_output: true,
23479                compact: false,
23480                pretty: false,
23481                terse: false,
23482                ultra_terse: false,
23483                schema: false,
23484                envelope: true,
23485            },
23486        );
23487        assert_eq!(
23488            forwarded,
23489            "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
23490        );
23491    }
23492
23493    #[test]
23494    fn rewrite_output_format_forwards_json_when_requested() {
23495        let command = rewrite_command("cargo build --release").expect("rewrite");
23496        let forwarded = apply_rewrite_output_format(
23497            &command,
23498            OutputFormat {
23499                json_output: true,
23500                compact: false,
23501                pretty: true,
23502                terse: false,
23503                ultra_terse: false,
23504                schema: false,
23505                envelope: false,
23506            },
23507        );
23508        assert_eq!(
23509            forwarded,
23510            "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
23511        );
23512    }
23513
23514    #[test]
23515    fn output_cap_strips_search_header_and_truncates() {
23516        let capped = apply_output_cap(
23517            b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
23518            OutputCap {
23519                max_lines: 2,
23520                strip_prefix: Some("Strategy:"),
23521            },
23522        );
23523        assert_eq!(
23524            capped,
23525            "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
23526        );
23527    }
23528
23529    #[test]
23530    fn sql_schema_overview_lists_tables() {
23531        let (_tmp, conn) = setup_test_db();
23532        let tables = schema_overview(&conn).unwrap();
23533        let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
23534        assert_eq!(names, &["posts", "users"]);
23535    }
23536
23537    #[test]
23538    fn sql_schema_overview_row_counts() {
23539        let (_tmp, conn) = setup_test_db();
23540        let tables = schema_overview(&conn).unwrap();
23541        let users = tables.iter().find(|t| t.name == "users").unwrap();
23542        let posts = tables.iter().find(|t| t.name == "posts").unwrap();
23543        assert_eq!(users.row_count, 2);
23544        assert_eq!(posts.row_count, 3);
23545    }
23546
23547    #[test]
23548    fn sql_table_columns_metadata() {
23549        let (_tmp, conn) = setup_test_db();
23550        let cols = table_columns(&conn, "users").unwrap();
23551        assert_eq!(cols.len(), 3);
23552        assert_eq!(cols[0].name, "id");
23553        assert!(cols[0].pk);
23554        assert_eq!(cols[1].name, "name");
23555        assert!(cols[1].notnull);
23556        assert_eq!(cols[2].name, "email");
23557        assert!(!cols[2].notnull);
23558    }
23559
23560    #[test]
23561    fn sql_execute_query_returns_rows() {
23562        let (_tmp, conn) = setup_test_db();
23563        let (columns, rows) =
23564            execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
23565        assert_eq!(columns, &["name", "email"]);
23566        assert_eq!(rows.len(), 2);
23567        assert_eq!(rows[0][0], serde_json::json!("Alice"));
23568        assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
23569        assert_eq!(rows[1][1], serde_json::Value::Null);
23570    }
23571
23572    #[test]
23573    fn sql_execute_query_aggregate() {
23574        let (_tmp, conn) = setup_test_db();
23575        let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
23576        assert_eq!(columns, &["cnt"]);
23577        assert_eq!(rows[0][0], serde_json::json!(3));
23578    }
23579
23580    #[test]
23581    fn sql_execute_query_join() {
23582        let (_tmp, conn) = setup_test_db();
23583        let (_cols, rows) = execute_query(
23584            &conn,
23585            "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
23586        )
23587        .unwrap();
23588        assert_eq!(rows.len(), 3);
23589        assert_eq!(rows[0][0], serde_json::json!("Alice"));
23590        assert_eq!(rows[2][0], serde_json::json!("Bob"));
23591    }
23592
23593    #[test]
23594    fn sql_open_db_read_only() {
23595        let (tmp, _conn) = setup_test_db();
23596        drop(_conn);
23597        let ro_conn = open_db(tmp.path()).unwrap();
23598        let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
23599        assert!(result.is_err(), "read-only connection should reject writes");
23600    }
23601
23602    #[test]
23603    fn sql_empty_table_schema() {
23604        let tmp = tempfile::NamedTempFile::new().unwrap();
23605        let conn = Connection::open(tmp.path()).unwrap();
23606        conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
23607            .unwrap();
23608        let tables = schema_overview(&conn).unwrap();
23609        assert_eq!(tables[0].row_count, 0);
23610        assert_eq!(tables[0].columns.len(), 2);
23611    }
23612
23613    // --- graph command ---
23614
23615    fn setup_graph_index() -> tempfile::TempDir {
23616        let dir = tempfile::tempdir().unwrap();
23617        std::fs::write(
23618            dir.path().join("main.rs"),
23619            "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
23620        )
23621        .unwrap();
23622        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23623        db.apply_changes(dir.path()).unwrap();
23624        dir
23625    }
23626
23627    fn setup_traversal_project() -> tempfile::TempDir {
23628        let dir = setup_graph_index();
23629        let task_dir = dir.path().join("tasks/software");
23630        std::fs::create_dir_all(&task_dir).unwrap();
23631        std::fs::write(
23632            task_dir.join("tsift.md"),
23633            r#"---
23634agent_doc_session: tsift-v0.1
23635agent_doc_format: template
23636---
23637
23638## Exchange
23639
23640<!-- agent:exchange patch=append -->
23641❯ do [#kgnv]
23642Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
23643<!-- /agent:exchange -->
23644
23645<!-- agent:queue -->
23646dispatch #spec-test-build-install-commit-push
23647- do [#kgnv]
23648<!-- /agent:queue -->
23649
23650## Backlog
23651
23652<!-- agent:backlog -->
23653- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
23654<!-- /agent:backlog -->
23655"#,
23656        )
23657        .unwrap();
23658        dir
23659    }
23660
23661    fn resolve_ast_span_node<'a>(
23662        graph: &'a TraversalGraphBuild,
23663        label: &str,
23664        symbol_kind: &str,
23665    ) -> &'a TraversalNode {
23666        graph
23667            .nodes
23668            .values()
23669            .find(|node| {
23670                node.kind == "ast_span"
23671                    && node.label == label
23672                    && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
23673            })
23674            .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
23675    }
23676
23677    fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
23678        let dir = tempfile::tempdir().unwrap();
23679        std::fs::write(
23680            dir.path().join("rust.rs"),
23681            r#"mod fixture_nav_rust_mod {
23682    pub fn fixture_nav_rust_helper() {}
23683    pub fn fixture_nav_rust_entry() {
23684        fixture_nav_rust_helper();
23685    }
23686}
23687"#,
23688        )
23689        .unwrap();
23690        std::fs::write(
23691            dir.path().join("python.py"),
23692            r#"def fixture_nav_python_helper():
23693    return 1
23694
23695def fixture_nav_python_entry():
23696    return fixture_nav_python_helper()
23697"#,
23698        )
23699        .unwrap();
23700        std::fs::write(
23701            dir.path().join("typescript.ts"),
23702            r#"export function fixture_nav_typescript_entry(): number {
23703    return fixtureNavTsHelper();
23704}
23705
23706function fixtureNavTsHelper(): number {
23707    return 1;
23708}
23709"#,
23710        )
23711        .unwrap();
23712        std::fs::write(
23713            dir.path().join("javascript.js"),
23714            r#"function fixture_nav_javascript_entry() {
23715    return fixtureNavJsHelper();
23716}
23717
23718function fixtureNavJsHelper() {
23719    return 1;
23720}
23721"#,
23722        )
23723        .unwrap();
23724        std::fs::write(
23725            dir.path().join("kotlin.kt"),
23726            r#"fun fixture_nav_kotlin_entry(): Int {
23727    return fixtureNavKotlinHelper()
23728}
23729
23730fun fixtureNavKotlinHelper(): Int = 1
23731"#,
23732        )
23733        .unwrap();
23734        std::fs::write(
23735            dir.path().join("zig.zig"),
23736            r#"pub fn fixture_nav_zig_entry() i32 {
23737    return fixtureNavZigHelper();
23738}
23739
23740fn fixtureNavZigHelper() i32 {
23741    return 1;
23742}
23743"#,
23744        )
23745        .unwrap();
23746        std::fs::write(
23747            dir.path().join("bash.sh"),
23748            r#"#!/usr/bin/env bash
23749fixture_nav_bash_entry() {
23750    fixture_nav_bash_helper
23751}
23752
23753fixture_nav_bash_helper() {
23754    echo ok
23755}
23756
23757alias fixture_nav_bash_alias='echo alias'
23758"#,
23759        )
23760        .unwrap();
23761        std::fs::write(
23762            dir.path().join("README.md"),
23763            r#"# Fixture Guide
23764
23765## Fixture Section
23766
23767- Fixture step
23768  - Nested fixture step
23769
23770```python
23771def fixture_nav_markdown_embedded():
23772    return 1
23773```
23774"#,
23775        )
23776        .unwrap();
23777
23778        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23779        db.apply_changes(dir.path()).unwrap();
23780        dir
23781    }
23782
23783    fn assert_cli_expand_command_parses(command: &str) {
23784        let args = shell_split(command)
23785            .into_iter()
23786            .map(str::to_string)
23787            .collect::<Vec<_>>();
23788        assert!(
23789            try_parse_cli(args).is_ok(),
23790            "expand command should parse as a tsift CLI command: {command}"
23791        );
23792    }
23793
23794    fn setup_multiplicity_project() -> tempfile::TempDir {
23795        let dir = tempfile::tempdir().unwrap();
23796        std::fs::write(
23797            dir.path().join("Cargo.toml"),
23798            r#"[workspace]
23799members = ["crates/core-lib", "crates/cli-app"]
23800"#,
23801        )
23802        .unwrap();
23803        std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
23804        std::fs::write(
23805            dir.path().join("crates/core-lib/Cargo.toml"),
23806            r#"[package]
23807name = "core-lib"
23808
23809[lib]
23810name = "core_lib"
23811
23812[features]
23813default = []
23814"#,
23815        )
23816        .unwrap();
23817        std::fs::write(
23818            dir.path().join("crates/core-lib/src/lib.rs"),
23819            "pub fn run() {}\n",
23820        )
23821        .unwrap();
23822        std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
23823        std::fs::write(
23824            dir.path().join("crates/cli-app/Cargo.toml"),
23825            r#"[package]
23826name = "cli-app"
23827
23828[[bin]]
23829name = "cli-app"
23830
23831[dependencies]
23832core-lib = { path = "../core-lib" }
23833"#,
23834        )
23835        .unwrap();
23836        std::fs::write(
23837            dir.path().join("crates/cli-app/src/main.rs"),
23838            "use core_lib::run;\nfn main() { run(); }\n",
23839        )
23840        .unwrap();
23841        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23842        db.apply_changes(dir.path()).unwrap();
23843
23844        let task_dir = dir.path().join("tasks/software");
23845        std::fs::create_dir_all(&task_dir).unwrap();
23846        std::fs::write(
23847            task_dir.join("tsift.md"),
23848            r#"---
23849agent_doc_session: tsift-multiplicity
23850agent_doc_format: template
23851---
23852
23853## Backlog
23854
23855<!-- agent:backlog -->
23856- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
23857<!-- /agent:backlog -->
23858"#,
23859        )
23860        .unwrap();
23861        init_git_repo(dir.path());
23862        dir
23863    }
23864
23865    fn setup_dependency_dag_project() -> tempfile::TempDir {
23866        let dir = tempfile::tempdir().unwrap();
23867        std::fs::write(
23868            dir.path().join("main.rs"),
23869            "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
23870        )
23871        .unwrap();
23872        std::fs::write(
23873            dir.path().join("Cargo.toml"),
23874            "[package]\nname = \"dag-fixture\"\n",
23875        )
23876        .unwrap();
23877        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23878        db.apply_changes(dir.path()).unwrap();
23879
23880        let task_dir = dir.path().join("tasks/software");
23881        std::fs::create_dir_all(&task_dir).unwrap();
23882        std::fs::write(
23883            task_dir.join("tsift.md"),
23884            r#"---
23885agent_doc_session: tsift-dag
23886agent_doc_format: template
23887---
23888
23889## Exchange
23890
23891<!-- agent:exchange patch=append -->
23892Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
23893<!-- /agent:exchange -->
23894
23895## Backlog
23896
23897<!-- agent:backlog -->
23898- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
23899- [ ] [#alpha] Update shared_helper in main.rs after #prep.
23900- [ ] [#beta] Refactor shared_helper tests in main.rs.
23901- [ ] [#gamma] Follow-up review for graph navigation.
23902<!-- /agent:backlog -->
23903"#,
23904        )
23905        .unwrap();
23906        dir
23907    }
23908
23909    fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
23910        let dir = setup_graph_index();
23911        let task_dir = dir.path().join("tasks/software");
23912        std::fs::create_dir_all(&task_dir).unwrap();
23913        std::fs::write(
23914            task_dir.join("tsift.md"),
23915            r#"---
23916agent_doc_session: tsift-dag-cycle
23917agent_doc_format: template
23918---
23919
23920## Backlog
23921
23922<!-- agent:backlog -->
23923- [ ] [#left] Left side depends on #right.
23924- [ ] [#right] Right side depends on #left.
23925<!-- /agent:backlog -->
23926"#,
23927        )
23928        .unwrap();
23929        dir
23930    }
23931
23932    fn seed_traversal_semantic_summaries(dir: &Path) {
23933        let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23934        summary_db
23935            .insert(&summarize::Summary {
23936                id: 0,
23937                symbol_name: "helper".to_string(),
23938                file_path: "main.rs".to_string(),
23939                content_hash: "hash-main".to_string(),
23940                summary: "helper builds graph navigation handles for traversal.".to_string(),
23941                entities: Some(vec![
23942                    summarize::Entity {
23943                        name: "helper".to_string(),
23944                        kind: "function".to_string(),
23945                        description: "Builds graph navigation handles.".to_string(),
23946                    },
23947                    summarize::Entity {
23948                        name: "TraversalGraph".to_string(),
23949                        kind: "type".to_string(),
23950                        description: "Carries GraphStore-backed traversal rows.".to_string(),
23951                    },
23952                ]),
23953                relationships: Some(vec![summarize::Relationship {
23954                    from: "helper".to_string(),
23955                    to: "TraversalGraph".to_string(),
23956                    kind: "uses".to_string(),
23957                }]),
23958                concept_labels: Some(vec![
23959                    "graph navigation".to_string(),
23960                    "semantic extraction".to_string(),
23961                ]),
23962                extracted_at: "1700000000".to_string(),
23963                model: "test-model".to_string(),
23964                tokens_input: Some(10),
23965                tokens_output: Some(5),
23966            })
23967            .unwrap();
23968    }
23969
23970    fn seed_tsift_memory_graph_db(dir: &Path) {
23971        let db = dir.join(".tsift").join("memory.db");
23972        let store = MemoryStore::open_or_create(&db).unwrap();
23973        let project = dir.to_string_lossy().to_string();
23974        let observation = MemoryEvent::new(
23975            MemoryEventKind::ImportedObservation,
23976            "claude-mem:observations:1",
23977            [
23978                "Graph memory adapter",
23979                "read-only projection",
23980                "graph-db should retrieve tsift memory observations",
23981                "Project memory is queried from .tsift/memory.db",
23982                "graph memory, tsift memory, semantic query",
23983            ]
23984            .join("\n\n"),
23985        )
23986        .with_session_id("claude-session-a")
23987        .with_observed_at_unix(1_700_000_000)
23988        .with_import("claude-mem", "observations:1")
23989        .with_metadata("project", project.clone())
23990        .with_metadata("observation_type", "fact")
23991        .with_metadata("prompt_number", "7")
23992        .with_metadata("discovery_tokens", "42")
23993        .with_metadata("content_hash", "hash-observation-1");
23994        store.insert_event(&observation).unwrap();
23995
23996        let summary = MemoryEvent::new(
23997            MemoryEventKind::ImportedSessionSummary,
23998            "claude-mem:session_summaries:2",
23999            [
24000                "Query old memory from graph-db",
24001                "Read-only tsift memory SQLite projection",
24002                "Semantic graph rows can point at existing memory",
24003                "Projected source and session nodes",
24004                "Keep capture ownership inside tsift-memory",
24005                "summary note",
24006            ]
24007            .join("\n\n"),
24008        )
24009        .with_session_id("claude-session-a")
24010        .with_observed_at_unix(1_700_000_010)
24011        .with_import("claude-mem", "session_summaries:2")
24012        .with_metadata("project", project)
24013        .with_metadata("prompt_number", "8")
24014        .with_metadata("discovery_tokens", "36");
24015        store.insert_event(&summary).unwrap();
24016
24017        let prompt = MemoryEvent::new(
24018            MemoryEventKind::ImportedUserPrompt,
24019            "claude-mem:user_prompts:3",
24020            "How can graph-db query tsift memory semantic history?",
24021        )
24022        .with_session_id("claude-session-a")
24023        .with_observed_at_unix(1_700_000_020)
24024        .with_import("claude-mem", "user_prompts:3")
24025        .with_metadata("prompt_number", "9");
24026        store.insert_event(&prompt).unwrap();
24027    }
24028
24029    #[test]
24030    fn graph_callers_query() {
24031        let dir = setup_graph_index();
24032        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24033        let callers = db.callers_of("helper").unwrap();
24034        assert_eq!(callers.len(), 1);
24035        assert_eq!(callers[0].caller_name, "main");
24036    }
24037
24038    #[test]
24039    fn graph_callees_query() {
24040        let dir = setup_graph_index();
24041        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24042        let callees = db.callees_of("main").unwrap();
24043        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
24044        assert!(names.contains(&"helper"));
24045        assert!(names.contains(&"new"));
24046    }
24047
24048    #[test]
24049    fn graph_no_callers_returns_empty() {
24050        let dir = setup_graph_index();
24051        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24052        let callers = db.callers_of("nonexistent").unwrap();
24053        assert!(callers.is_empty());
24054    }
24055
24056    #[test]
24057    fn graph_cmd_autoindexes_missing_index_by_default() {
24058        let dir = tempfile::tempdir().unwrap();
24059        std::fs::write(
24060            dir.path().join("main.rs"),
24061            "fn helper() {}\nfn main() { helper(); }\n",
24062        )
24063        .unwrap();
24064        let result = cmd_graph(
24065            "helper",
24066            dir.path(),
24067            true,
24068            false,
24069            None,
24070            20,
24071            false,
24072            true,
24073            false,
24074            false,
24075            false,
24076            false,
24077            false,
24078            TagpathSearchOpts::default(),
24079        );
24080
24081        assert!(result.is_ok());
24082        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
24083        let summary = db.compute_changes(dir.path()).unwrap();
24084        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
24085    }
24086
24087    #[test]
24088    fn traversal_graph_has_stable_typed_handles() {
24089        let dir = setup_traversal_project();
24090        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24091        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24092
24093        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
24094        let symbol = resolve_traversal_node(&graph, "helper").unwrap();
24095        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24096        let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
24097
24098        assert!(file.handle.starts_with("gfil-"));
24099        assert!(symbol.handle.starts_with("gsym-"));
24100        assert!(backlog.handle.starts_with("gbak-"));
24101        assert!(session.handle.starts_with("gses-"));
24102
24103        assert_eq!(
24104            symbol.handle,
24105            resolve_traversal_node(&graph_again, "helper")
24106                .unwrap()
24107                .handle
24108        );
24109        assert_eq!(
24110            backlog.handle,
24111            resolve_traversal_node(&graph_again, "#kgnv")
24112                .unwrap()
24113                .handle
24114        );
24115    }
24116
24117    #[test]
24118    fn traversal_graph_links_backlog_items_to_code_tokens() {
24119        let dir = setup_traversal_project();
24120        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24121        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24122        let helper = resolve_traversal_node(&graph, "helper").unwrap();
24123
24124        assert!(graph.edges.iter().any(|edge| {
24125            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24126        }));
24127    }
24128
24129    #[test]
24130    fn session_hinted_traversal_skips_global_call_edges() {
24131        let dir = setup_traversal_project();
24132        let session = dir.path().join("tasks/software/tsift.md");
24133        let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
24134        let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
24135        let helper = resolve_traversal_node(&bounded, "helper").unwrap();
24136
24137        assert!(bounded.edges.iter().any(|edge| {
24138            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24139        }));
24140        assert!(
24141            !bounded.edges.iter().any(|edge| edge.relation == "calls"),
24142            "session-hinted graph-db projections should not materialize unrelated global call edges"
24143        );
24144
24145        let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24146        assert!(
24147            full.edges.iter().any(|edge| edge.relation == "calls"),
24148            "root/full projections still carry the complete indexed call graph"
24149        );
24150    }
24151
24152    #[test]
24153    fn agent_doc_task_path_infers_matching_workspace_scope() {
24154        let dir = tempfile::tempdir().unwrap();
24155        std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
24156        std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
24157        std::fs::write(
24158            dir.path().join(".gitmodules"),
24159            "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
24160        )
24161        .unwrap();
24162        let task = dir.path().join("tasks/software/tsift.md");
24163        std::fs::write(&task, "# tsift\n").unwrap();
24164
24165        let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
24166        let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
24167        let cfg = config::Config::load(dir.path()).unwrap();
24168
24169        assert_eq!(targets.len(), 1);
24170        assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
24171        assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
24172        assert!(
24173            targets[0]
24174                .db_path
24175                .ends_with(".tsift/indexes/tsift/index.db")
24176        );
24177        assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
24178    }
24179
24180    #[test]
24181    fn cargo_package_scope_selector_indexes_package_db() {
24182        let dir = setup_multiplicity_project();
24183        let targets =
24184            resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
24185
24186        assert_eq!(targets.len(), 1);
24187        assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
24188        assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
24189        assert!(
24190            targets[0]
24191                .db_path
24192                .ends_with(".tsift/indexes/cargo/core-lib/index.db")
24193        );
24194
24195        cmd_index(
24196            dir.path(),
24197            false,
24198            false,
24199            false,
24200            false,
24201            true,
24202            false,
24203            Some("core_lib"),
24204            false,
24205            true,
24206            false,
24207            false,
24208            false,
24209            false,
24210        )
24211        .unwrap();
24212        assert!(targets[0].db_path.exists());
24213    }
24214
24215    #[test]
24216    fn source_read_symbols_build_cargo_package_index_on_demand() {
24217        // A workspace member that has never been queried has no per-package cargo
24218        // index yet. source-read must build it on demand and return AST symbol
24219        // refs rather than silently degrading to window-only output with an
24220        // "index refs unavailable" warning while `tsift status` reports fresh
24221        // (#cargoidxcov).
24222        let dir = setup_multiplicity_project();
24223        let cargo_index = dir.path().join(".tsift/indexes/cargo/core-lib/index.db");
24224        assert!(
24225            !cargo_index.exists(),
24226            "core-lib cargo index should not exist before the first source-read"
24227        );
24228
24229        let file_abs = dir.path().join("crates/core-lib/src/lib.rs");
24230        let source = std::fs::read(&file_abs).unwrap();
24231        let mut warnings = Vec::new();
24232        let symbols = load_source_symbols(
24233            dir.path(),
24234            &file_abs,
24235            "crates/core-lib/src/lib.rs",
24236            &source,
24237            None,
24238            1,
24239            usize::MAX,
24240            10,
24241            4096,
24242            &mut warnings,
24243        );
24244
24245        assert!(
24246            warnings.is_empty(),
24247            "source-read must build the index on demand instead of warning: {warnings:?}"
24248        );
24249        let symbol_names = symbols
24250            .iter()
24251            .map(|symbol| symbol.name.as_str())
24252            .collect::<Vec<_>>();
24253        assert!(
24254            symbol_names.contains(&"run"),
24255            "source-read should resolve `run` from the on-demand-built cargo index: {symbol_names:?}"
24256        );
24257        assert!(
24258            cargo_index.exists(),
24259            "source-read should have built the core-lib cargo index on demand"
24260        );
24261    }
24262
24263    #[test]
24264    fn path_inference_prefers_nested_cargo_package_without_submodule() {
24265        let dir = setup_multiplicity_project();
24266        let source = dir.path().join("crates/cli-app/src/main.rs");
24267        let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
24268
24269        assert_eq!(targets.len(), 1);
24270        assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
24271        assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
24272    }
24273
24274    #[test]
24275    fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
24276        let dir = setup_multiplicity_project();
24277        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24278        let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
24279        let core = resolve_traversal_node(&graph, "core-lib").unwrap();
24280        let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
24281        let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
24282
24283        assert_eq!(workspace.kind, "cargo_workspace");
24284        assert_eq!(core.kind, "cargo_package");
24285        assert_eq!(
24286            core.properties.get("features"),
24287            Some(&"default".to_string())
24288        );
24289        assert!(graph.edges.iter().any(|edge| {
24290            edge.from == workspace.handle
24291                && edge.to == core.handle
24292                && edge.relation == "contains_package"
24293        }));
24294        assert!(graph.edges.iter().any(|edge| {
24295            edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
24296        }));
24297        assert!(graph.edges.iter().any(|edge| {
24298            edge.from == cli.handle
24299                && edge.to == core.handle
24300                && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
24301        }));
24302    }
24303
24304    #[test]
24305    fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
24306        let dir = setup_multiplicity_project();
24307        let session = dir.path().join("tasks/software/tsift.md");
24308        let report =
24309            build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
24310                .unwrap();
24311
24312        assert!(report.per_target_fail_closed.is_empty());
24313        let candidate = report
24314            .candidates
24315            .iter()
24316            .find(|candidate| candidate.target == "corepkg")
24317            .unwrap();
24318        assert!(
24319            candidate
24320                .owned_files
24321                .iter()
24322                .any(|file| file == "crates/core-lib/Cargo.toml"),
24323            "{:?}",
24324            candidate.owned_files
24325        );
24326    }
24327
24328    #[test]
24329    fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
24330        let dir = setup_traversal_project();
24331        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24332        let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
24333        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24334
24335        assert_eq!(job.kind, "job_packet");
24336        assert!(job.handle.starts_with("gjob-"));
24337        assert!(graph.edges.iter().any(|edge| {
24338            edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
24339        }));
24340
24341        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24342        let jobs = store.nodes_by_kind("job_packet").unwrap();
24343        assert!(
24344            jobs.iter()
24345                .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24346            "expected queued job packet in graph store, got {jobs:?}"
24347        );
24348    }
24349
24350    #[test]
24351    fn traversal_graph_includes_routes_and_handler_edges() {
24352        let dir = tempfile::tempdir().unwrap();
24353        std::fs::write(
24354            dir.path().join("api.py"),
24355            r#"@router.get("/items")
24356def list_items():
24357    return []
24358"#,
24359        )
24360        .unwrap();
24361        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24362        db.apply_changes(dir.path()).unwrap();
24363
24364        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24365        let route = resolve_traversal_node(&graph, "/items").unwrap();
24366        let handler = resolve_traversal_node(&graph, "list_items").unwrap();
24367
24368        assert_eq!(route.kind, "route");
24369        assert!(graph.edges.iter().any(|edge| {
24370            edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
24371        }));
24372    }
24373
24374    #[test]
24375    fn traversal_graph_projects_rust_ast_navigation_edges() {
24376        let dir = tempfile::tempdir().unwrap();
24377        std::fs::write(
24378            dir.path().join("main.rs"),
24379            r#"mod api {
24380    pub fn helper() {}
24381    pub fn handler() { helper(); }
24382}
24383
24384fn main() { api::handler(); }
24385"#,
24386        )
24387        .unwrap();
24388        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24389        db.apply_changes(dir.path()).unwrap();
24390
24391        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24392        let api = resolve_ast_span_node(&graph, "api", "mod");
24393        let helper = resolve_ast_span_node(&graph, "helper", "function");
24394        let handler = resolve_ast_span_node(&graph, "handler", "function");
24395
24396        assert_eq!(helper.kind, "ast_span");
24397        assert!(helper.handle.starts_with("span-"));
24398        assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
24399        assert!(graph.edges.iter().any(|edge| {
24400            edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
24401        }));
24402        assert!(graph.edges.iter().any(|edge| {
24403            edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
24404        }));
24405        assert!(graph.edges.iter().any(|edge| {
24406            edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
24407        }));
24408        assert!(graph.edges.iter().any(|edge| {
24409            edge.from == helper.handle
24410                && edge.to == handler.handle
24411                && edge.relation == "next_sibling"
24412        }));
24413        assert!(graph.edges.iter().any(|edge| {
24414            edge.from == handler.handle
24415                && edge.to == helper.handle
24416                && edge.relation == "previous_sibling"
24417        }));
24418        assert!(graph.edges.iter().any(|edge| {
24419            edge.from == helper.handle
24420                && edge.to == api.handle
24421                && edge.relation == "enclosing_module"
24422        }));
24423        assert!(graph.edges.iter().any(|edge| {
24424            edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
24425        }));
24426
24427        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24428        let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
24429        assert!(
24430            ast_nodes.iter().any(|node| node.id == helper.handle
24431                && node.properties.get("symbol_kind") == Some(&"function".to_string())),
24432            "expected helper AST span in graph store, got {ast_nodes:?}"
24433        );
24434        assert!(
24435            store
24436                .outgoing_edges(&helper.handle, Some("parent"))
24437                .unwrap()
24438                .iter()
24439                .any(|edge| edge.to_id == api.handle),
24440            "expected persisted AST parent edge"
24441        );
24442    }
24443
24444    #[test]
24445    fn traversal_graph_projects_markdown_section_block_edges() {
24446        let dir = tempfile::tempdir().unwrap();
24447        std::fs::write(
24448            dir.path().join("README.md"),
24449            "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
24450        )
24451        .unwrap();
24452        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24453        db.apply_changes(dir.path()).unwrap();
24454
24455        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24456        let guide = resolve_ast_span_node(&graph, "Guide", "heading");
24457        let code = resolve_ast_span_node(&graph, "rust", "code_block");
24458        let embedded = resolve_ast_span_node(&graph, "demo", "function");
24459        let list_item = graph
24460            .nodes
24461            .values()
24462            .find(|node| {
24463                node.kind == "ast_span"
24464                    && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
24465                    && node.properties.get("section_handle") == Some(&guide.handle)
24466            })
24467            .expect("missing Markdown list item AST span");
24468
24469        assert_eq!(
24470            code.properties.get("markdown_block_kind"),
24471            Some(&"fenced_code_block".to_string())
24472        );
24473        assert_eq!(
24474            guide.properties.get("heading_level"),
24475            Some(&"1".to_string())
24476        );
24477        assert_eq!(
24478            embedded.properties.get("embedded"),
24479            Some(&"true".to_string())
24480        );
24481        assert_eq!(
24482            embedded.properties.get("language"),
24483            Some(&"rust".to_string())
24484        );
24485        assert_eq!(
24486            embedded.properties.get("markdown_block_handle"),
24487            Some(&code.handle)
24488        );
24489        assert!(graph.edges.iter().any(|edge| {
24490            edge.from == guide.handle
24491                && edge.to == code.handle
24492                && edge.relation == "contains_markdown_block"
24493        }));
24494        assert!(graph.edges.iter().any(|edge| {
24495            edge.from == code.handle
24496                && edge.to == guide.handle
24497                && edge.relation == "enclosing_section"
24498        }));
24499        assert!(graph.edges.iter().any(|edge| {
24500            edge.from == guide.handle
24501                && edge.to == list_item.handle
24502                && edge.relation == "contains_markdown_block"
24503        }));
24504        assert!(graph.edges.iter().any(|edge| {
24505            edge.from == code.handle
24506                && edge.to == embedded.handle
24507                && edge.relation == "contains_embedded_symbol"
24508        }));
24509        assert!(graph.edges.iter().any(|edge| {
24510            edge.from == embedded.handle
24511                && edge.to == code.handle
24512                && edge.relation == "embedded_in_fence"
24513        }));
24514        assert!(graph.edges.iter().any(|edge| {
24515            edge.from == guide.handle
24516                && edge.to == embedded.handle
24517                && edge.relation == "contains_embedded_code"
24518        }));
24519
24520        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24521        assert!(
24522            store
24523                .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
24524                .unwrap()
24525                .iter()
24526                .any(|edge| edge.to_id == code.handle),
24527            "expected persisted Markdown section/block edge"
24528        );
24529        assert!(
24530            store
24531                .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
24532                .unwrap()
24533                .iter()
24534                .any(|edge| edge.to_id == embedded.handle),
24535            "expected persisted Markdown fence/embedded symbol edge"
24536        );
24537    }
24538
24539    #[test]
24540    fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
24541        let dir = setup_multilingual_ast_navigation_project();
24542        let db =
24543            index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
24544        let symbols = db.all_symbols().unwrap();
24545        let expected_symbols = [
24546            ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
24547            (
24548                "python",
24549                "fixture_nav_python_entry",
24550                "function",
24551                "python.py",
24552            ),
24553            (
24554                "typescript",
24555                "fixture_nav_typescript_entry",
24556                "function",
24557                "typescript.ts",
24558            ),
24559            (
24560                "javascript",
24561                "fixture_nav_javascript_entry",
24562                "function",
24563                "javascript.js",
24564            ),
24565            (
24566                "kotlin",
24567                "fixture_nav_kotlin_entry",
24568                "function",
24569                "kotlin.kt",
24570            ),
24571            ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
24572            ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
24573            ("markdown", "Fixture Section", "heading", "README.md"),
24574            ("markdown", "Fixture step", "list_item", "README.md"),
24575            ("markdown", "python", "code_block", "README.md"),
24576        ];
24577
24578        for (language, name, kind, file) in expected_symbols {
24579            let symbol = symbols
24580                .iter()
24581                .find(|symbol| {
24582                    symbol.language == language
24583                        && symbol.name == name
24584                        && symbol.kind == kind
24585                        && symbol.file.ends_with(file)
24586                })
24587                .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
24588            assert!(
24589                symbol.start_byte.is_some() && symbol.end_byte.is_some(),
24590                "{language} {name} should carry AST byte spans"
24591            );
24592        }
24593
24594        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24595        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24596        let expected_ast_nodes = [
24597            ("fixture_nav_rust_entry", "function", "rust"),
24598            ("fixture_nav_python_entry", "function", "python"),
24599            ("fixture_nav_typescript_entry", "function", "typescript"),
24600            ("fixture_nav_javascript_entry", "function", "javascript"),
24601            ("fixture_nav_kotlin_entry", "function", "kotlin"),
24602            ("fixture_nav_zig_entry", "function", "zig"),
24603            ("fixture_nav_bash_entry", "function", "bash"),
24604            ("Fixture Section", "heading", "markdown"),
24605            ("Fixture step", "list_item", "markdown"),
24606            ("python", "code_block", "markdown"),
24607            ("fixture_nav_markdown_embedded", "function", "python"),
24608        ];
24609
24610        for (name, kind, language) in expected_ast_nodes {
24611            let node = resolve_ast_span_node(&graph, name, kind);
24612            let repeated = resolve_ast_span_node(&graph_again, name, kind);
24613            assert!(
24614                node.handle.starts_with("span-"),
24615                "{name} handle: {}",
24616                node.handle
24617            );
24618            assert_eq!(
24619                node.handle, repeated.handle,
24620                "{language} {name} handle drifted"
24621            );
24622            assert_eq!(
24623                node.properties.get("language"),
24624                Some(&language.to_string()),
24625                "{name} should keep its language label"
24626            );
24627        }
24628
24629        let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
24630        let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
24631        let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
24632        assert!(graph.edges.iter().any(|edge| {
24633            edge.from == markdown_section.handle
24634                && edge.to == markdown_code.handle
24635                && edge.relation == "contains_markdown_block"
24636        }));
24637        assert!(graph.edges.iter().any(|edge| {
24638            edge.from == markdown_code.handle
24639                && edge.to == embedded.handle
24640                && edge.relation == "contains_embedded_symbol"
24641        }));
24642        assert!(
24643            graph.nodes.len() <= 80,
24644            "multilingual AST fixture should stay bounded, got {} nodes",
24645            graph.nodes.len()
24646        );
24647        assert!(
24648            graph.edges.len() <= 180,
24649            "multilingual AST fixture should stay bounded, got {} edges",
24650            graph.edges.len()
24651        );
24652
24653        let response = empty_search_response(dir.path(), "lexical");
24654        let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
24655        let report = build_relative_search_budget_report(
24656            "fixture_nav_python_entry",
24657            "lexical",
24658            dir.path(),
24659            &response,
24660            &symbol_hits,
24661            ResponseBudget::new(Some(8), Some(120)),
24662            &SearchFacetFilters::default(),
24663        );
24664        let report_again = build_relative_search_budget_report(
24665            "fixture_nav_python_entry",
24666            "lexical",
24667            dir.path(),
24668            &response,
24669            &symbol_hits,
24670            ResponseBudget::new(Some(8), Some(120)),
24671            &SearchFacetFilters::default(),
24672        );
24673
24674        let top = report
24675            .ranked
24676            .first()
24677            .expect("ranked preview should not be empty");
24678        assert_eq!(top.source, "symbol_span");
24679        assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
24680        assert!(top.handle.starts_with("srnk-"));
24681        assert_eq!(top.handle, report_again.ranked[0].handle);
24682        assert!(
24683            top.reasons.iter().any(|reason| reason == "ast_span"),
24684            "expected AST span ranking reason, got {:?}",
24685            top.reasons
24686        );
24687        assert!(report.ranked.len() <= 8);
24688        assert!(report.symbols.len() <= 8);
24689
24690        let symbol = report
24691            .symbols
24692            .iter()
24693            .find(|symbol| symbol.name == "fixture_nav_python_entry")
24694            .expect("missing search preview symbol");
24695        assert_cli_expand_command_parses(&symbol.expand);
24696        let ast = symbol
24697            .ast
24698            .as_ref()
24699            .expect("search symbol should expose AST");
24700        assert_cli_expand_command_parses(&ast.expand.source_window);
24701        assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
24702        assert_cli_expand_command_parses(&ast.expand.symbol_read);
24703
24704        let markdown_hits = db.symbol_search("python", 20).unwrap();
24705        let markdown_report = build_relative_search_budget_report(
24706            "python",
24707            "lexical",
24708            dir.path(),
24709            &response,
24710            &markdown_hits,
24711            ResponseBudget::new(Some(8), Some(120)),
24712            &SearchFacetFilters::default(),
24713        );
24714        let markdown_symbol = markdown_report
24715            .symbols
24716            .iter()
24717            .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
24718            .expect("missing Markdown code-block symbol");
24719        let markdown_ast = markdown_symbol
24720            .ast
24721            .as_ref()
24722            .expect("Markdown code block should expose AST");
24723        assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
24724        assert_eq!(
24725            markdown_ast
24726                .span
24727                .markdown
24728                .as_ref()
24729                .unwrap()
24730                .embedded_symbols[0]
24731                .name,
24732            "fixture_nav_markdown_embedded"
24733        );
24734    }
24735
24736    #[test]
24737    fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
24738        let edges = vec![
24739            TraversalEdge {
24740                from: "origin".to_string(),
24741                to: "aaa_low".to_string(),
24742                relation: "unknown".to_string(),
24743                label: None,
24744                weight: 1,
24745            },
24746            TraversalEdge {
24747                from: "origin".to_string(),
24748                to: "zzz_high".to_string(),
24749                relation: "mentions".to_string(),
24750                label: None,
24751                weight: 1,
24752            },
24753        ];
24754
24755        let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
24756
24757        assert!(handles.contains("origin"));
24758        assert!(handles.contains("zzz_high"), "{handles:?}");
24759        assert!(!handles.contains("aaa_low"), "{handles:?}");
24760    }
24761
24762    #[test]
24763    fn traversal_materializes_provider_neutral_sqlite_graph() {
24764        let dir = setup_traversal_project();
24765        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24766        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24767
24768        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24769        let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
24770        assert!(
24771            backlog_nodes.iter().any(|node| node.id == backlog.handle
24772                && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24773            "expected materialized backlog node, got {backlog_nodes:?}"
24774        );
24775        assert!(
24776            store
24777                .all_nodes()
24778                .unwrap()
24779                .iter()
24780                .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
24781                    && node.properties.get("projection_version")
24782                        == Some(&GRAPH_PROJECTION_VERSION.to_string())),
24783            "expected projection metadata node"
24784        );
24785        let source_handles = store.nodes_by_kind("source_handle").unwrap();
24786        assert!(
24787            source_handles
24788                .iter()
24789                .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
24790            "expected bounded source_handle rows, got {source_handles:?}"
24791        );
24792        let worker_context = store.nodes_by_kind("worker_context").unwrap();
24793        assert!(
24794            worker_context
24795                .iter()
24796                .any(|node| node.properties.get("target")
24797                    == Some(&"tasks/software/tsift.md".to_string())),
24798            "expected bounded worker_context rows, got {worker_context:?}"
24799        );
24800        let worker_results = store.nodes_by_kind("worker_result").unwrap();
24801        assert!(
24802            worker_results.iter().any(|node| {
24803                node.properties.get("ref_id") == Some(&"kgnv".to_string())
24804                    && node.properties.get("status") == Some(&"completed".to_string())
24805                    && node.properties.get("touched_files") == Some(&"main.rs".to_string())
24806                    && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
24807            }),
24808            "expected worker_result rows, got {worker_results:?}"
24809        );
24810    }
24811
24812    #[test]
24813    fn traversal_projection_materializes_cached_semantic_rows() {
24814        let dir = setup_traversal_project();
24815        seed_traversal_semantic_summaries(dir.path());
24816        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24817        let helper = resolve_traversal_node(&graph, "helper").unwrap();
24818        let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
24819        let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
24820
24821        assert_eq!(concept.kind, "semantic_concept");
24822        assert_eq!(entity.kind, "semantic_entity");
24823        assert!(concept.handle.starts_with("gcon-"));
24824        assert!(entity.handle.starts_with("gent-"));
24825
24826        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24827        assert!(
24828            store
24829                .nodes_by_kind("semantic_concept")
24830                .unwrap()
24831                .iter()
24832                .any(|node| node.label == "semantic extraction"
24833                    && node.properties.contains_key("embedding")),
24834            "expected persisted concept embeddings"
24835        );
24836        assert!(
24837            store
24838                .outgoing_edges(&helper.handle, Some("mentions_concept"))
24839                .unwrap()
24840                .iter()
24841                .any(|edge| edge.to_id == concept.handle),
24842            "expected helper symbol to link to cached summary concept"
24843        );
24844        assert!(
24845            store
24846                .outgoing_edges(
24847                    &semantic_entity_handle("helper", "function"),
24848                    Some("semantic_relation")
24849                )
24850                .unwrap()
24851                .iter()
24852                .any(|edge| edge.to_id == entity.handle
24853                    && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
24854            "expected LLM relationship rows projected into GraphStore"
24855        );
24856    }
24857
24858    #[test]
24859    fn traversal_projection_materializes_tsift_memory_rows() {
24860        let dir = setup_traversal_project();
24861        seed_tsift_memory_graph_db(dir.path());
24862        let memory_db = dir.path().join(".tsift").join("memory.db");
24863        let store = MemoryStore::open_or_create(&memory_db).unwrap();
24864        for summary in ["first closeout", "second closeout"] {
24865            let event = MemoryEvent::new(
24866                MemoryEventKind::ResponseSummary,
24867                "tasks/software/tsift.md",
24868                summary,
24869            )
24870            .with_session_id("tasks/software/tsift.md")
24871            .with_observed_at_unix(1_700_000_100);
24872            store.insert_event(&event).unwrap();
24873        }
24874        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24875        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24876
24877        let native_sources = store
24878            .nodes_by_kind("source_handle")
24879            .unwrap()
24880            .into_iter()
24881            .filter(|node| {
24882                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24883                    && node.properties.get("source_ref")
24884                        == Some(&"tasks/software/tsift.md".to_string())
24885            })
24886            .collect::<Vec<_>>();
24887        assert_eq!(
24888            native_sources.len(),
24889            2,
24890            "same-source native memory events must get distinct source handles"
24891        );
24892
24893        let source = store
24894            .nodes_by_kind("source_handle")
24895            .unwrap()
24896            .into_iter()
24897            .find(|node| {
24898                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24899            })
24900            .expect("expected tsift-memory source handle");
24901        let session = store
24902            .nodes_by_kind("memory_session")
24903            .unwrap()
24904            .into_iter()
24905            .find(|node| {
24906                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24907                    && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
24908            })
24909            .expect("expected tsift-memory session node");
24910        let event = store
24911            .nodes_by_kind("memory_event")
24912            .unwrap()
24913            .into_iter()
24914            .find(|node| {
24915                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24916                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24917                    && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
24918            })
24919            .expect("expected tsift-memory event node");
24920        let concept = store
24921            .nodes_by_kind("semantic_concept")
24922            .unwrap()
24923            .into_iter()
24924            .find(|node| {
24925                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24926                    && node.label.contains("Graph memory adapter")
24927                    && node.properties.contains_key("embedding")
24928            })
24929            .expect("expected tsift-memory semantic concept");
24930
24931        assert!(
24932            store
24933                .outgoing_edges(&session.id, Some("records_memory_source"))
24934                .unwrap()
24935                .iter()
24936                .any(|edge| edge.to_id == source.id),
24937            "expected session to link to source handle"
24938        );
24939        assert!(
24940            store
24941                .outgoing_edges(&session.id, Some("records_memory_event"))
24942                .unwrap()
24943                .iter()
24944                .any(|edge| edge.to_id == event.id),
24945            "expected session to link to memory event"
24946        );
24947        assert!(
24948            store
24949                .outgoing_edges(&event.id, Some("projects_source"))
24950                .unwrap()
24951                .iter()
24952                .any(|edge| edge.to_id == source.id),
24953            "expected memory event to project source handle"
24954        );
24955        assert!(
24956            store
24957                .outgoing_edges(&source.id, Some("mentions_concept"))
24958                .unwrap()
24959                .iter()
24960                .any(|edge| edge.to_id == concept.id),
24961            "expected source handle to seed semantic concept"
24962        );
24963
24964        let related = semantic_related_report_from_store(
24965            dir.path(),
24966            None,
24967            "tsift memory graph adapter",
24968            5,
24969            SemanticRelatedKind::Concept,
24970            &store,
24971        )
24972        .unwrap();
24973        assert!(
24974            related
24975                .items
24976                .iter()
24977                .any(|item| item.handle == concept.id && item.score > 0.0),
24978            "expected semantic query to retrieve tsift-memory concept, got {:?}",
24979            related.items
24980        );
24981
24982        let graph_related = graph_db_report_from_store(
24983            dir.path(),
24984            None,
24985            "sqlite",
24986            GraphDbQuery::Related {
24987                query: "tsift memory graph adapter".to_string(),
24988                kind: SemanticRelatedKind::Concept,
24989                depth: 1,
24990                seed_limit: 5,
24991                limit: 20,
24992            },
24993            &store,
24994            sqlite_graph_freshness(&store, "root").unwrap(),
24995            Vec::new(),
24996        )
24997        .unwrap();
24998        assert_eq!(
24999            graph_related
25000                .readiness
25001                .as_ref()
25002                .map(|readiness| readiness.status.as_str()),
25003            Some("ready"),
25004            "tsift-memory semantic rows should satisfy graph-db related readiness"
25005        );
25006        assert!(
25007            graph_related.nodes.iter().any(|node| {
25008                node.kind == "semantic_concept"
25009                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
25010            }),
25011            "expected related graph output to include tsift-memory semantic rows"
25012        );
25013    }
25014
25015    #[test]
25016    fn semantic_related_query_uses_persisted_graph_embeddings() {
25017        let dir = setup_traversal_project();
25018        seed_traversal_semantic_summaries(dir.path());
25019        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25020        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25021        let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
25022            .unwrap()
25023            .query_row(
25024                "SELECT COUNT(*) FROM graph_node_semantic_vectors",
25025                [],
25026                |row| row_usize(row, 0),
25027            )
25028            .unwrap();
25029        assert!(semantic_vector_rows > 0);
25030
25031        let report = semantic_related_report_from_store(
25032            dir.path(),
25033            None,
25034            "graph navigation",
25035            5,
25036            SemanticRelatedKind::Concept,
25037            &store,
25038        )
25039        .unwrap();
25040
25041        assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
25042        assert!(
25043            report
25044                .items
25045                .iter()
25046                .any(|item| item.label == "graph navigation"
25047                    && item.kind == "semantic_concept"
25048                    && item.score > 0.9),
25049            "expected nearest concept match from graph embeddings, got {:?}",
25050            report.items
25051        );
25052    }
25053
25054    #[test]
25055    fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
25056        let dir = setup_traversal_project();
25057        seed_traversal_semantic_summaries(dir.path());
25058        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25059        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25060
25061        let report = graph_db_report_from_store(
25062            dir.path(),
25063            None,
25064            "sqlite",
25065            GraphDbQuery::Related {
25066                query: "graph navigation".to_string(),
25067                kind: SemanticRelatedKind::All,
25068                depth: 1,
25069                seed_limit: 2,
25070                limit: 20,
25071            },
25072            &store,
25073            sqlite_graph_freshness(&store, "root").unwrap(),
25074            Vec::new(),
25075        )
25076        .unwrap();
25077
25078        let knowledge = report.knowledge_retrieval.as_ref().unwrap();
25079        assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
25080        assert_eq!(knowledge.seed_kind, "all");
25081        assert_eq!(knowledge.depth, 1);
25082        assert_eq!(
25083            report
25084                .readiness
25085                .as_ref()
25086                .map(|readiness| readiness.status.as_str()),
25087            Some("ready")
25088        );
25089        assert!(
25090            knowledge
25091                .diagnostics
25092                .iter()
25093                .any(|diagnostic| diagnostic.contains("incident"))
25094        );
25095        assert!(
25096            report
25097                .semantic_related
25098                .iter()
25099                .any(|item| item.label == "graph navigation"
25100                    && item.kind == "semantic_concept"
25101                    && item.score > 0.9),
25102            "expected natural-language query to seed the graph navigation concept, got {:?}",
25103            report.semantic_related
25104        );
25105        assert!(
25106            report
25107                .nodes
25108                .iter()
25109                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
25110        );
25111        assert!(
25112            report
25113                .nodes
25114                .iter()
25115                .any(|node| node.kind == "symbol" && node.label == "helper"),
25116            "incident expansion from semantic seed should recover source symbols, got {:?}",
25117            report
25118                .nodes
25119                .iter()
25120                .map(|node| (&node.kind, &node.label))
25121                .collect::<Vec<_>>()
25122        );
25123        assert!(
25124            report
25125                .edges
25126                .iter()
25127                .any(|edge| edge.kind == "mentions_concept")
25128        );
25129        assert!(
25130            report.output_budget.as_ref().is_some_and(|budget| budget
25131                .diagnostics
25132                .iter()
25133                .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
25134            "expected related output budget diagnostics, got {:?}",
25135            report.output_budget
25136        );
25137    }
25138
25139    #[test]
25140    fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
25141        let dir = setup_graph_index();
25142        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25143        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25144
25145        let report = graph_db_report_from_store(
25146            dir.path(),
25147            None,
25148            "sqlite",
25149            GraphDbQuery::Related {
25150                query: "graph navigation".to_string(),
25151                kind: SemanticRelatedKind::All,
25152                depth: 1,
25153                seed_limit: 2,
25154                limit: 20,
25155            },
25156            &store,
25157            sqlite_graph_freshness(&store, "root").unwrap(),
25158            Vec::new(),
25159        )
25160        .unwrap();
25161
25162        let readiness = report.readiness.as_ref().unwrap();
25163        assert_eq!(readiness.status, "blocked");
25164        assert_eq!(readiness.reason, "summary_cache_empty");
25165        assert!(readiness.fail_closed);
25166        assert_eq!(
25167            readiness.next_commands,
25168            vec![
25169                "tsift summarize --extract .".to_string(),
25170                graph_db_refresh_command(dir.path(), None)
25171            ]
25172        );
25173        assert!(
25174            report
25175                .knowledge_retrieval
25176                .as_ref()
25177                .unwrap()
25178                .diagnostics
25179                .iter()
25180                .any(|diagnostic| diagnostic.contains("summary cache empty")
25181                    && diagnostic.contains("graph-db materialized code/session rows")),
25182            "expected related diagnostics to carry readiness gate, got {:?}",
25183            report.knowledge_retrieval.as_ref().unwrap().diagnostics
25184        );
25185    }
25186
25187    #[test]
25188    fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
25189        let mut nodes = vec![
25190            SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
25191            SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
25192        ];
25193        let mut edges = vec![SubstrateGraphEdge::new(
25194            "zzz_high",
25195            "seed",
25196            "mentions_concept",
25197        )];
25198        for idx in 0..24 {
25199            let id = format!("aaa_low_{idx:02}");
25200            nodes.push(SubstrateGraphNode::new(
25201                id.clone(),
25202                "note",
25203                format!("low {idx}"),
25204            ));
25205            edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
25206        }
25207        let mut store = SqliteGraphStore::in_memory().unwrap();
25208        store
25209            .replace_projection(&GraphProjection { nodes, edges })
25210            .unwrap();
25211
25212        let subgraph =
25213            graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
25214
25215        assert_eq!(subgraph.nodes.len(), 3);
25216        assert_eq!(subgraph.nodes[0].id, "seed");
25217        assert_eq!(
25218            subgraph.nodes[1].id, "zzz_high",
25219            "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
25220            subgraph.nodes
25221        );
25222        assert!(subgraph.truncated);
25223        assert!(
25224            subgraph
25225                .diagnostics
25226                .iter()
25227                .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
25228            "{:?}",
25229            subgraph.diagnostics
25230        );
25231        assert!(
25232            subgraph
25233                .diagnostics
25234                .iter()
25235                .any(|diagnostic| diagnostic.contains("skipped")),
25236            "{:?}",
25237            subgraph.diagnostics
25238        );
25239    }
25240
25241    #[test]
25242    fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
25243        let dir = setup_traversal_project();
25244        seed_traversal_semantic_summaries(dir.path());
25245        init_git_repo(dir.path());
25246        let session = dir.path().join("tasks/software/tsift.md");
25247        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25248        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25249        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25250        let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25251            root: dir.path(),
25252            scope: None,
25253            backend: "sqlite",
25254            target: "kgnv",
25255            preferred_path: None,
25256            depth: 4,
25257            limit: 8,
25258            cursor: None,
25259            store: &store,
25260            freshness,
25261            warnings: Vec::new(),
25262        })
25263        .unwrap();
25264        assert!(
25265            evidence
25266                .semantic_related
25267                .iter()
25268                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
25269            "expected semantic evidence rows, got {:?}",
25270            evidence
25271                .semantic_related
25272                .iter()
25273                .map(|node| (&node.kind, &node.label))
25274                .collect::<Vec<_>>()
25275        );
25276        assert!(
25277            evidence
25278                .output_budget
25279                .as_ref()
25280                .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
25281                    diagnostic.contains("semantic_match")
25282                        && diagnostic.contains("source_handle_coverage")
25283                })),
25284            "expected evidence output budget diagnostics, got {:?}",
25285            evidence.output_budget
25286        );
25287
25288        let cached_diff = diff_digest::compute(
25289            dir.path(),
25290            diff_digest::DiffDigestOptions {
25291                cached: true,
25292                revision: None,
25293                max_parsed_files: None,
25294            },
25295        )
25296        .unwrap();
25297        let impact_report = impact::compute(
25298            dir.path(),
25299            impact::ImpactOptions {
25300                cached: true,
25301                revision: None,
25302                scope: None,
25303                limit: 10,
25304            },
25305        )
25306        .unwrap();
25307        let graph_nodes = store.all_nodes().unwrap();
25308        let graph_index = conflict_matrix_graph_index(&graph_nodes);
25309        let semantic_candidate = conflict_matrix_candidate_from_evidence(
25310            dir.path(),
25311            &evidence,
25312            &graph_index,
25313            &cached_diff,
25314            &impact_report,
25315        );
25316        assert!(semantic_candidate.semantic_dispatch_score > 0);
25317        assert!(
25318            semantic_candidate
25319                .semantic_dispatch_reasons
25320                .iter()
25321                .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
25322            "expected semantic ranking explanations, got {:?}",
25323            semantic_candidate.semantic_dispatch_reasons
25324        );
25325        assert!(
25326            semantic_candidate
25327                .semantic_related
25328                .iter()
25329                .any(|item| item.label == "graph navigation")
25330        );
25331
25332        let mut plain_candidate = semantic_candidate.clone();
25333        plain_candidate.target = "plain".to_string();
25334        plain_candidate.semantic_related.clear();
25335        plain_candidate.semantic_dispatch_score = 0;
25336        plain_candidate.semantic_dispatch_reasons.clear();
25337        let mut ranked = [plain_candidate, semantic_candidate];
25338        ranked.sort_by(|left, right| {
25339            left.risk
25340                .cmp(&right.risk)
25341                .then_with(|| left.risk_score.cmp(&right.risk_score))
25342                .then_with(|| {
25343                    right
25344                        .semantic_dispatch_score
25345                        .cmp(&left.semantic_dispatch_score)
25346                })
25347                .then_with(|| left.target.cmp(&right.target))
25348        });
25349        assert_eq!(ranked[0].target, "kgnv");
25350    }
25351
25352    #[test]
25353    fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
25354        let dir = setup_dependency_dag_project();
25355        let session = dir.path().join("tasks/software/tsift.md");
25356        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25357
25358        assert_eq!(report.contract_version, "dependency-dag-v1");
25359        assert_eq!(
25360            report.targets,
25361            vec![
25362                "prep".to_string(),
25363                "alpha".to_string(),
25364                "beta".to_string(),
25365                "gamma".to_string()
25366            ]
25367        );
25368        assert!(report.edges.iter().any(|edge| {
25369            edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
25370        }));
25371        assert!(report.edges.iter().any(|edge| {
25372            edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
25373        }));
25374        assert!(report.edges.iter().any(|edge| {
25375            edge.from == "alpha"
25376                && edge.to == "beta"
25377                && edge.kind == "shared_resource"
25378                && edge.shared_files.contains(&"main.rs".to_string())
25379                && edge.shared_symbols.contains(&"shared_helper".to_string())
25380        }));
25381        assert!(
25382            !report.cycle_diagnostics.has_cycles,
25383            "{:?}",
25384            report.cycle_diagnostics
25385        );
25386        assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
25387        assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
25388        assert!(
25389            report.replay_commands[0].contains("dependency-dag"),
25390            "{:?}",
25391            report.replay_commands
25392        );
25393
25394        cmd_dependency_dag(
25395            &session,
25396            None,
25397            &["alpha".to_string(), "beta".to_string()],
25398            4,
25399            12,
25400            OutputFormat {
25401                json_output: true,
25402                compact: false,
25403                pretty: false,
25404                terse: false,
25405                ultra_terse: false,
25406                schema: false,
25407                envelope: false,
25408            },
25409        )
25410        .unwrap();
25411    }
25412
25413    #[test]
25414    fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
25415        let dir = setup_dependency_dag_cycle_project();
25416        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25417
25418        assert!(report.cycle_diagnostics.has_cycles);
25419        assert_eq!(
25420            report.cycle_diagnostics.blocked_nodes,
25421            vec!["left".to_string(), "right".to_string()]
25422        );
25423        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25424            edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
25425        }));
25426        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25427            edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
25428        }));
25429    }
25430
25431    #[test]
25432    fn traversal_projection_queries_match_sqlite_and_convex_stores() {
25433        let dir = setup_traversal_project();
25434        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
25435        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
25436
25437        let mut sqlite = SqliteGraphStore::in_memory().unwrap();
25438        sqlite.replace_projection(&projection).unwrap();
25439        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25440        projection.upsert_into(&convex).unwrap();
25441
25442        let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
25443        let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
25444        assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
25445        assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
25446
25447        let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
25448        let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
25449        assert!(convex_graph.edges.iter().any(|edge| {
25450            edge.from == sqlite_backlog.handle
25451                && edge.to == convex_helper.handle
25452                && edge.relation == "mentions"
25453        }));
25454    }
25455
25456    #[test]
25457    fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
25458        let dir = setup_traversal_project();
25459        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25460        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25461        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25462        assert_eq!(freshness.status, "current");
25463
25464        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25465        let report = graph_db_report_from_store(
25466            dir.path(),
25467            None,
25468            "sqlite",
25469            GraphDbQuery::Neighborhood {
25470                id: backlog.handle.clone(),
25471                depth: 1,
25472                edge_kind: Some("mentions".to_string()),
25473                cursor: None,
25474                limit: None,
25475                property_filters: Vec::new(),
25476            },
25477            &store,
25478            freshness,
25479            Vec::new(),
25480        )
25481        .unwrap();
25482        assert!(
25483            report
25484                .edges
25485                .iter()
25486                .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
25487            "expected backlog mention edge, got {:?}",
25488            report.edges
25489        );
25490        assert!(
25491            report.ranked_neighbors.iter().any(|neighbor| {
25492                neighbor.depth == Some(1)
25493                    && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
25494                    && neighbor.node_id != backlog.handle
25495                    && neighbor.handle_coverage_pct >= 95.0
25496                    && neighbor.duplicate_name_precision >= 0.99
25497            }),
25498            "expected ranked neighborhood neighbors with quality scores, got {:?}",
25499            report.ranked_neighbors
25500        );
25501        assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
25502        let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
25503        assert!(!ranking_gate.ranked_output_default);
25504        assert_eq!(ranking_gate.default_order, "stable_node_id");
25505        assert!(
25506            ranking_gate
25507                .diagnostics
25508                .iter()
25509                .any(|diagnostic| diagnostic.contains("score-capped")),
25510            "{ranking_gate:?}"
25511        );
25512        assert!(
25513            ranking_gate
25514                .required_metrics
25515                .iter()
25516                .any(|metric| metric == "handle_coverage_pct")
25517        );
25518        assert!(
25519            ranking_gate
25520                .required_metrics
25521                .iter()
25522                .any(|metric| metric == "duplicate_name_precision")
25523        );
25524        assert!(
25525            report
25526                .page
25527                .as_ref()
25528                .unwrap()
25529                .diagnostics
25530                .iter()
25531                .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
25532            "expected SQLite neighborhood query plan diagnostics, got {:?}",
25533            report.page.as_ref().unwrap().diagnostics
25534        );
25535        let edges_report = graph_db_report_from_store(
25536            dir.path(),
25537            None,
25538            "sqlite",
25539            GraphDbQuery::Edges {
25540                edge_kind: Some("mentions".to_string()),
25541                cursor: None,
25542                limit: Some(2),
25543                property_filters: Vec::new(),
25544            },
25545            &store,
25546            sqlite_graph_freshness(&store, "root").unwrap(),
25547            Vec::new(),
25548        )
25549        .unwrap();
25550        let edge_id = edges_report
25551            .edges
25552            .first()
25553            .map(|edge| edge.id.clone())
25554            .expect("expected at least one paged mentions edge");
25555        assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
25556        assert_eq!(
25557            edges_report.page.as_ref().unwrap().returned_edges,
25558            edges_report.edges.len()
25559        );
25560
25561        let edge_report = graph_db_report_from_store(
25562            dir.path(),
25563            None,
25564            "sqlite",
25565            GraphDbQuery::Edge {
25566                id: edge_id.clone(),
25567            },
25568            &store,
25569            sqlite_graph_freshness(&store, "root").unwrap(),
25570            Vec::new(),
25571        )
25572        .unwrap();
25573        assert_eq!(
25574            edge_report
25575                .edge
25576                .as_ref()
25577                .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
25578            Some(edge_id.clone())
25579        );
25580
25581        let incident_report = graph_db_report_from_store(
25582            dir.path(),
25583            None,
25584            "sqlite",
25585            GraphDbQuery::Incident {
25586                id: backlog.handle.clone(),
25587                edge_kind: Some("mentions".to_string()),
25588                cursor: None,
25589                limit: Some(1),
25590                property_filters: Vec::new(),
25591            },
25592            &store,
25593            sqlite_graph_freshness(&store, "root").unwrap(),
25594            Vec::new(),
25595        )
25596        .unwrap();
25597        assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
25598        assert!(
25599            incident_report
25600                .edges
25601                .iter()
25602                .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
25603            "{:?}",
25604            incident_report.edges
25605        );
25606
25607        let schema_report = graph_db_report_from_store(
25608            dir.path(),
25609            None,
25610            "sqlite",
25611            GraphDbQuery::Schema,
25612            &store,
25613            sqlite_graph_freshness(&store, "root").unwrap(),
25614            Vec::new(),
25615        )
25616        .unwrap();
25617        assert!(
25618            schema_report
25619                .schema
25620                .unwrap()
25621                .operations
25622                .iter()
25623                .any(|operation| operation.command.starts_with("neighborhood"))
25624        );
25625    }
25626
25627    #[test]
25628    fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
25629        let mut nodes = vec![SubstrateGraphNode::new(
25630            "origin",
25631            "backlog",
25632            "#budgeted-neighborhood",
25633        )];
25634        let mut edges = Vec::new();
25635        for idx in 0..32 {
25636            let id = format!("src-{idx:02}");
25637            nodes.push(
25638                SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
25639                    .with_property("source_ref", format!("fixture:{idx}"))
25640                    .with_property("detail", "x".repeat(600)),
25641            );
25642            edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
25643        }
25644        let store = SqliteGraphStore::in_memory().unwrap();
25645        GraphProjection { nodes, edges }
25646            .upsert_into(&store)
25647            .unwrap();
25648
25649        let report = graph_db_report_from_store(
25650            Path::new("."),
25651            None,
25652            "fixture",
25653            GraphDbQuery::Neighborhood {
25654                id: "origin".to_string(),
25655                depth: 1,
25656                edge_kind: None,
25657                cursor: None,
25658                limit: None,
25659                property_filters: Vec::new(),
25660            },
25661            &store,
25662            current_graph_db_freshness(),
25663            Vec::new(),
25664        )
25665        .unwrap();
25666        let budget = report.output_budget.as_ref().unwrap();
25667        assert!(budget.selected_nodes < budget.candidate_nodes);
25668        assert!(
25669            budget.dropped_by_budget.iter().any(|drop| {
25670                drop.item == "node"
25671                    && drop.kind == "source_handle"
25672                    && drop.reason == "per_kind_quota"
25673            }),
25674            "expected source_handle budget drops, got {:?}",
25675            budget.dropped_by_budget
25676        );
25677        assert!(report.page.as_ref().unwrap().truncated);
25678        assert!(
25679            report
25680                .page
25681                .as_ref()
25682                .unwrap()
25683                .diagnostics
25684                .iter()
25685                .any(|diagnostic| diagnostic.contains("budget ranking signals")),
25686            "{:?}",
25687            report.page
25688        );
25689    }
25690
25691    #[test]
25692    fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
25693        let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
25694        let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
25695        for idx in 0..8 {
25696            let id = format!("far-{idx:02}");
25697            nodes.push(SubstrateGraphNode::new(
25698                id.clone(),
25699                "note",
25700                format!("aaa deeper row {idx}"),
25701            ));
25702            depth_by_id.insert(id, 6);
25703        }
25704
25705        let origin_ids = vec!["target".to_string()];
25706        let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
25707            &origin_ids,
25708            &BTreeMap::new(),
25709            nodes,
25710            Vec::new(),
25711            Some(3),
25712            Some(&depth_by_id),
25713            None,
25714        );
25715
25716        assert!(
25717            budgeted.nodes.iter().any(|node| node.id == "near"),
25718            "expected the shallow evidence row to outrank deeper rows, got {:?}",
25719            budgeted
25720                .nodes
25721                .iter()
25722                .map(|node| (&node.id, &node.label))
25723                .collect::<Vec<_>>()
25724        );
25725        assert!(
25726            budgeted.report.dropped_by_budget.iter().any(|drop| {
25727                drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
25728            }),
25729            "expected node quota drops, got {:?}",
25730            budgeted.report.dropped_by_budget
25731        );
25732        assert!(
25733            budgeted
25734                .report
25735                .diagnostics
25736                .iter()
25737                .any(|diagnostic| diagnostic.contains("depth")),
25738            "{:?}",
25739            budgeted.report.diagnostics
25740        );
25741    }
25742
25743    #[test]
25744    fn evidence_pagination_returns_next_cursor_when_truncated() {
25745        let mut nodes = vec![SubstrateGraphNode::new(
25746            "target".to_string(),
25747            "backlog_item",
25748            "target item".to_string(),
25749        )];
25750        let mut depth_by_id = BTreeMap::new();
25751        depth_by_id.insert("target".to_string(), 0);
25752        for idx in 0..20 {
25753            let id = format!("ev-{idx}");
25754            nodes.push(
25755                SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
25756                    .with_property("detail", "x".repeat(400)),
25757            );
25758            depth_by_id.insert(id, 1);
25759        }
25760        let origin_ids = vec!["target".to_string()];
25761        let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
25762            &origin_ids,
25763            &BTreeMap::new(),
25764            nodes.clone(),
25765            Vec::new(),
25766            Some(3),
25767            Some(&depth_by_id),
25768            None,
25769        );
25770        assert!(
25771            first_page.truncated,
25772            "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
25773            first_page.nodes.len(),
25774            first_page.report.candidate_nodes
25775        );
25776        assert!(
25777            first_page.next_cursor.is_some(),
25778            "expected next_cursor when truncated"
25779        );
25780        let cursor = first_page.next_cursor.unwrap();
25781        assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
25782        let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
25783        let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
25784            &origin_ids,
25785            &BTreeMap::new(),
25786            nodes.clone(),
25787            Vec::new(),
25788            Some(3),
25789            Some(&depth_by_id),
25790            Some(&cursor),
25791        );
25792        let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
25793        let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
25794        assert!(
25795            overlap.is_empty(),
25796            "pages should not overlap, but found shared ids: {overlap:?}"
25797        );
25798        assert!(
25799            second_page
25800                .report
25801                .diagnostics
25802                .iter()
25803                .any(|d| d.contains("cursor skipped")),
25804            "expected cursor skip diagnostic, got {:?}",
25805            second_page.report.diagnostics
25806        );
25807    }
25808
25809    #[test]
25810    fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
25811        let mut nodes = vec![SubstrateGraphNode::new(
25812            "target".to_string(),
25813            "backlog_item",
25814            "target item".to_string(),
25815        )];
25816        let mut depth_by_id = BTreeMap::new();
25817        depth_by_id.insert("target".to_string(), 0);
25818        for idx in 0..3 {
25819            let id = format!("ev-{idx}");
25820            nodes.push(SubstrateGraphNode::new(
25821                id.clone(),
25822                "source_handle",
25823                format!("evidence row {idx}"),
25824            ));
25825            depth_by_id.insert(id, 1);
25826        }
25827        let origin_ids = vec!["target".to_string()];
25828        let result = graph_db_apply_output_budget_with_depths_and_cursor(
25829            &origin_ids,
25830            &BTreeMap::new(),
25831            nodes,
25832            Vec::new(),
25833            None,
25834            Some(&depth_by_id),
25835            None,
25836        );
25837        assert!(
25838            !result.truncated,
25839            "expected no truncation with small candidate set and default budget"
25840        );
25841        assert!(
25842            result.next_cursor.is_none(),
25843            "expected no next_cursor when not truncated"
25844        );
25845    }
25846
25847    #[test]
25848    fn evidence_pagination_invalid_cursor_returns_first_page() {
25849        let mut nodes = vec![SubstrateGraphNode::new(
25850            "target".to_string(),
25851            "backlog_item",
25852            "target item".to_string(),
25853        )];
25854        let mut depth_by_id = BTreeMap::new();
25855        depth_by_id.insert("target".to_string(), 0);
25856        for idx in 0..5 {
25857            let id = format!("ev-{idx}");
25858            nodes.push(SubstrateGraphNode::new(
25859                id.clone(),
25860                "source_handle",
25861                format!("evidence row {idx}"),
25862            ));
25863            depth_by_id.insert(id, 1);
25864        }
25865        let origin_ids = vec!["target".to_string()];
25866        let result = graph_db_apply_output_budget_with_depths_and_cursor(
25867            &origin_ids,
25868            &BTreeMap::new(),
25869            nodes.clone(),
25870            Vec::new(),
25871            None,
25872            Some(&depth_by_id),
25873            Some("nonexistent-id"),
25874        );
25875        assert!(
25876            result
25877                .report
25878                .diagnostics
25879                .iter()
25880                .any(|d| d.contains("cursor skipped 0")),
25881            "invalid cursor should skip 0 candidates, got {:?}",
25882            result.report.diagnostics
25883        );
25884    }
25885
25886    #[test]
25887    fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
25888        let dir = setup_traversal_project();
25889        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25890        let graph_db = dir.path().join(".tsift/graph.db");
25891        let _lock = hold_rollback_journal_lock(&graph_db);
25892
25893        let report =
25894            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25895                .unwrap();
25896
25897        assert_eq!(report.status, "current");
25898        assert_eq!(
25899            report.recovery,
25900            Some(index::ReadOnlyRecovery::SnapshotFallback)
25901        );
25902        assert!(
25903            report
25904                .warnings
25905                .iter()
25906                .any(|warning| warning.contains("rollback-journal lock")),
25907            "expected rollback-journal recovery warning, got {:?}",
25908            report.warnings
25909        );
25910    }
25911
25912    #[test]
25913    fn graph_db_status_copies_wal_sidecars_when_locked() {
25914        let dir = setup_traversal_project();
25915        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25916        let graph_db = dir.path().join(".tsift/graph.db");
25917        let _lock = hold_wal_database_lock(&graph_db);
25918
25919        let report =
25920            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25921                .unwrap();
25922
25923        assert_eq!(report.status, "current");
25924        assert_eq!(
25925            report.recovery,
25926            Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
25927        );
25928        assert!(
25929            report
25930                .warnings
25931                .iter()
25932                .any(|warning| warning.contains("WAL-aware snapshot fallback")),
25933            "expected WAL recovery warning, got {:?}",
25934            report.warnings
25935        );
25936    }
25937
25938    #[test]
25939    fn graph_db_doctor_reports_snapshot_fallback_when_rollback_journal_is_locked() {
25940        let dir = setup_traversal_project();
25941        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25942        let graph_db = dir.path().join(".tsift/graph.db");
25943        let _lock = hold_rollback_journal_lock(&graph_db);
25944
25945        let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25946        append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25947        report.finalize();
25948
25949        assert_eq!(report.status, "ok");
25950        assert!(!report.fail_closed);
25951        let recovery_check = report
25952            .checks
25953            .iter()
25954            .find(|check| check.name == "sqlite_graph_db_read_recovery")
25955            .expect("doctor should include read recovery diagnostic");
25956        assert_eq!(recovery_check.status, "recovered");
25957        assert!(
25958            recovery_check
25959                .diagnostics
25960                .iter()
25961                .any(|diagnostic| diagnostic.contains("rollback-journal lock")),
25962            "expected rollback-journal recovery diagnostic, got {:?}",
25963            recovery_check.diagnostics
25964        );
25965    }
25966
25967    #[test]
25968    fn graph_db_doctor_reports_wal_snapshot_fallback_when_locked() {
25969        let dir = setup_traversal_project();
25970        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25971        let graph_db = dir.path().join(".tsift/graph.db");
25972        let _lock = hold_wal_database_lock(&graph_db);
25973
25974        let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25975        append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25976        report.finalize();
25977
25978        assert_eq!(report.status, "ok");
25979        assert!(!report.fail_closed);
25980        let recovery_check = report
25981            .checks
25982            .iter()
25983            .find(|check| check.name == "sqlite_graph_db_read_recovery")
25984            .expect("doctor should include WAL read recovery diagnostic");
25985        assert_eq!(recovery_check.status, "recovered");
25986        assert!(
25987            recovery_check
25988                .diagnostics
25989                .iter()
25990                .any(|diagnostic| diagnostic.contains("WAL-aware snapshot fallback")),
25991            "expected WAL recovery diagnostic, got {:?}",
25992            recovery_check.diagnostics
25993        );
25994    }
25995
25996    #[test]
25997    fn graph_db_snapshot_export_import_round_trip_preserves_projection_metadata() {
25998        let dir = setup_traversal_project();
25999        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26000        let artifact = dir.path().join("graph.db.gz");
26001
26002        let exported =
26003            commands::infra::graph_db_snapshot_export_report(dir.path(), None, &artifact, false)
26004                .unwrap();
26005        let exported_projection_version = exported.freshness.projection_version.clone();
26006        let exported_content_hash = exported.freshness.content_hash.clone();
26007        let exported_source_watermark = exported.freshness.source_watermark.clone();
26008        let exported_nodes = exported.counts.nodes;
26009        let exported_edges = exported.counts.edges;
26010        assert_eq!(exported.operation, "snapshot-export");
26011        assert!(exported.status.starts_with("exported"));
26012        assert!(artifact.exists());
26013        assert!(exported.artifact_bytes > 0);
26014        assert_eq!(exported.compression, "gzip");
26015
26016        fs::remove_file(dir.path().join(".tsift/graph.db")).unwrap();
26017
26018        let imported =
26019            commands::infra::graph_db_snapshot_import_report(dir.path(), None, &artifact, false)
26020                .unwrap();
26021        assert_eq!(imported.operation, "snapshot-import");
26022        assert!(imported.status.starts_with("imported"));
26023        assert_eq!(
26024            imported.freshness.projection_version,
26025            exported_projection_version
26026        );
26027        assert_eq!(imported.freshness.content_hash, exported_content_hash);
26028        assert_eq!(
26029            imported.freshness.source_watermark,
26030            exported_source_watermark
26031        );
26032        assert_eq!(imported.counts.nodes, exported_nodes);
26033        assert_eq!(imported.counts.edges, exported_edges);
26034        assert!(dir.path().join(".tsift/graph.db").exists());
26035    }
26036
26037    #[test]
26038    fn graph_db_snapshot_export_fails_closed_when_wal_lock_requires_recovery() {
26039        let dir = setup_traversal_project();
26040        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26041        let graph_db = dir.path().join(".tsift/graph.db");
26042        let _lock = hold_wal_database_lock(&graph_db);
26043
26044        let err = match commands::infra::graph_db_snapshot_export_report(
26045            dir.path(),
26046            None,
26047            &dir.path().join("graph.db.gz"),
26048            false,
26049        ) {
26050            Ok(report) => panic!("expected snapshot export to fail, got {}", report.status),
26051            Err(err) => err,
26052        };
26053
26054        // The resilient open succeeds via the recovery fallback in this WAL-lock
26055        // case, so the live-lock signal surfaces at the recovery gate. Its
26056        // wording is now unified with the `map_live_lock` open-path diagnostic so
26057        // the operator gets the same actionable guidance regardless of which gate
26058        // trips (#tsreviewcleanup).
26059        let message = err.to_string();
26060        assert!(
26061            message.contains("recovered path")
26062                && message.contains("database is locked")
26063                && message.contains("wait for it to finish before retrying the export"),
26064            "expected unified live-lock recovery diagnostic, got {err:#}"
26065        );
26066    }
26067
26068    #[test]
26069    fn graph_db_snapshot_clean_export_maps_database_locked_to_live_lock_diagnostic() {
26070        let dir = setup_traversal_project();
26071        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26072        let graph_db = dir.path().join(".tsift/graph.db");
26073
26074        // Hold a plain EXCLUSIVE lock with no recovery sidecar so the export
26075        // clears the recovery fail-closed gate and reaches VACUUM INTO, which
26076        // then fails with a raw SQLite "database is locked".
26077        let blocker = Connection::open(&graph_db).unwrap();
26078        blocker
26079            .execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26080            .unwrap();
26081        assert!(!substrate::rollback_journal_path(&graph_db).exists());
26082
26083        let clean_path = dir.path().join("graph-clean-export.db");
26084        let err = match commands::infra::graph_db_snapshot_clean_export_copy(&graph_db, &clean_path)
26085        {
26086            Ok(bytes) => panic!("expected export to fail under live lock, got {bytes} bytes"),
26087            Err(err) => err,
26088        };
26089
26090        let message = err.to_string();
26091        assert!(
26092            message.contains("concurrent graph-db refresh or snapshot-import is in progress"),
26093            "expected actionable live-lock diagnostic, got {err:#}"
26094        );
26095        assert!(
26096            message.contains("wait for it to finish before retrying"),
26097            "expected retry guidance, got {err:#}"
26098        );
26099        // The raw SQLite phrasing must not leak as the surfaced top-level error.
26100        assert!(
26101            !message.contains("creating clean graph-db export copy"),
26102            "live-lock case must not surface the generic VACUUM context, got {err:#}"
26103        );
26104
26105        drop(blocker);
26106    }
26107
26108    #[test]
26109    fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
26110        let dir = setup_traversal_project();
26111        let session = dir.path().join("tasks/software/tsift.md");
26112        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
26113        let graph_db = dir.path().join(".tsift/graph.db");
26114        let _lock = hold_rollback_journal_lock(&graph_db);
26115
26116        let result = cmd_graph_db(
26117            &session,
26118            None,
26119            GraphDbBackend::Sqlite,
26120            None,
26121            GraphDbQuery::Evidence {
26122                target: "kgnv".to_string(),
26123                depth: 3,
26124                limit: 8,
26125                cursor: None,
26126            },
26127            OutputFormat {
26128                json_output: false,
26129                compact: true,
26130                pretty: false,
26131                terse: false,
26132                ultra_terse: false,
26133                schema: false,
26134                envelope: false,
26135            },
26136        );
26137
26138        assert!(result.is_ok());
26139    }
26140
26141    fn current_graph_db_freshness() -> GraphDbFreshnessReport {
26142        GraphDbFreshnessReport {
26143            status: "current".to_string(),
26144            fail_closed: false,
26145            projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
26146            content_hash: Some("fixture".to_string()),
26147            source_watermark: None,
26148            diagnostics: Vec::new(),
26149        }
26150    }
26151
26152    #[test]
26153    fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
26154        let dir = setup_traversal_project();
26155        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26156        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
26157        let stale = GraphDbFreshnessReport {
26158            status: "stale".to_string(),
26159            fail_closed: true,
26160            projection_version: Some("old-v0".to_string()),
26161            content_hash: None,
26162            source_watermark: None,
26163            diagnostics: vec!["projection content hash is missing".to_string()],
26164        };
26165
26166        let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
26167            root: dir.path(),
26168            scope: None,
26169            backend: "sqlite",
26170            target: "kgnv",
26171            preferred_path: None,
26172            depth: 3,
26173            limit: 8,
26174            cursor: None,
26175            store: &store,
26176            freshness: stale,
26177            warnings: Vec::new(),
26178        }) {
26179            Ok(_) => panic!("stale graph freshness should fail closed"),
26180            Err(err) => err,
26181        };
26182        let message = err.to_string();
26183        assert!(message.contains("failed closed"), "{message}");
26184        assert!(message.contains("graph-db --path"), "{message}");
26185        assert!(message.contains("refresh --json"), "{message}");
26186    }
26187
26188    fn paged_graph_ids(
26189        store: &impl GraphStore,
26190        cursor: Option<&str>,
26191    ) -> (Vec<String>, GraphDbPageReport) {
26192        let report = graph_db_report_from_store(
26193            Path::new("."),
26194            None,
26195            "fixture",
26196            GraphDbQuery::Kind {
26197                kind: "backlog".to_string(),
26198                cursor: cursor.map(str::to_string),
26199                limit: Some(2),
26200                property_filters: vec!["phase=open".to_string()],
26201            },
26202            store,
26203            current_graph_db_freshness(),
26204            Vec::new(),
26205        )
26206        .unwrap();
26207        (
26208            report.nodes.iter().map(|node| node.id.clone()).collect(),
26209            report.page.unwrap(),
26210        )
26211    }
26212
26213    #[test]
26214    fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
26215        let nodes = (0..5)
26216            .map(|idx| {
26217                let phase = if idx == 1 { "closed" } else { "open" };
26218                SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
26219                    .with_property("phase", phase)
26220            })
26221            .collect::<Vec<_>>();
26222        let projection = GraphProjection {
26223            nodes,
26224            edges: Vec::new(),
26225        };
26226        let sqlite = SqliteGraphStore::in_memory().unwrap();
26227        projection.upsert_into(&sqlite).unwrap();
26228        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
26229        projection.upsert_into(&convex).unwrap();
26230
26231        let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
26232        let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
26233        assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
26234        assert_eq!(sqlite_first_ids, convex_first_ids);
26235        assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
26236        assert!(sqlite_first_page.truncated);
26237        assert_eq!(
26238            sqlite_first_page.returned_nodes,
26239            convex_first_page.returned_nodes
26240        );
26241        assert_eq!(
26242            sqlite_first_page.property_filters,
26243            convex_first_page.property_filters
26244        );
26245        assert!(
26246            sqlite_first_page
26247                .diagnostics
26248                .iter()
26249                .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
26250            "expected SQLite kind query plan diagnostics, got {:?}",
26251            sqlite_first_page.diagnostics
26252        );
26253
26254        let cursor = sqlite_first_page.next_cursor.as_deref();
26255        let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
26256        let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
26257        assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
26258        assert_eq!(sqlite_next_ids, convex_next_ids);
26259        assert_eq!(sqlite_next_page.next_cursor, None);
26260        assert!(!sqlite_next_page.truncated);
26261        assert_eq!(
26262            sqlite_next_page.returned_nodes,
26263            convex_next_page.returned_nodes
26264        );
26265        assert_eq!(
26266            sqlite_next_page.property_filters,
26267            convex_next_page.property_filters
26268        );
26269    }
26270
26271    #[test]
26272    fn traversal_shortest_path_crosses_artifacts_and_symbols() {
26273        let dir = setup_traversal_project();
26274        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26275        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
26276        let main = resolve_traversal_node(&graph, "main").unwrap();
26277
26278        let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
26279        assert_eq!(path.first(), Some(&backlog.handle));
26280        assert_eq!(path.last(), Some(&main.handle));
26281        assert!(
26282            path.len() >= 3,
26283            "expected backlog -> symbol -> main, got {path:?}"
26284        );
26285    }
26286
26287    #[test]
26288    fn traversal_report_recommends_next_bugfix_nodes() {
26289        let dir = setup_traversal_project();
26290        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26291        let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
26292
26293        assert_eq!(report.mode, "neighborhood");
26294        assert!(
26295            report
26296                .recommendations
26297                .iter()
26298                .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
26299            "expected helper recommendation, got {:?}",
26300            report.recommendations
26301        );
26302        assert!(
26303            !report.exploration.source_windows.is_empty(),
26304            "expected exploration source windows"
26305        );
26306        assert!(
26307            report
26308                .exploration
26309                .no_reread_guidance
26310                .contains("avoid whole-file reads")
26311        );
26312    }
26313
26314    #[test]
26315    fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
26316        let dir = setup_traversal_project();
26317        std::thread::sleep(std::time::Duration::from_millis(50));
26318        std::fs::write(
26319            dir.path().join("main.rs"),
26320            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26321        )
26322        .unwrap();
26323
26324        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26325
26326        assert!(
26327            graph
26328                .warnings
26329                .iter()
26330                .any(|warning| warning.contains("index refreshed")
26331                    && warning.contains("graph traversal packet")),
26332            "expected refresh diagnostic, got {:?}",
26333            graph.warnings
26334        );
26335        assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
26336
26337        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26338        let summary = db.compute_changes(dir.path()).unwrap();
26339        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26340    }
26341
26342    #[test]
26343    fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
26344        let dir = setup_traversal_project();
26345        let db_path = dir.path().join(".tsift/index.db");
26346        let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
26347        std::thread::sleep(std::time::Duration::from_millis(50));
26348        std::fs::write(
26349            dir.path().join("main.rs"),
26350            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26351        )
26352        .unwrap();
26353
26354        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26355        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
26356
26357        assert!(
26358            graph
26359                .warnings
26360                .iter()
26361                .any(|warning| warning.contains("falling back to raw source file nodes")),
26362            "expected raw-source fallback diagnostic, got {:?}",
26363            graph.warnings
26364        );
26365        assert!(
26366            file.detail
26367                .as_deref()
26368                .is_some_and(|detail| detail.contains("raw source fallback")),
26369            "expected raw-source detail, got {:?}",
26370            file.detail
26371        );
26372        assert!(
26373            file.expand.contains("source-read"),
26374            "expected source-read fallback command, got {}",
26375            file.expand
26376        );
26377        assert!(
26378            resolve_traversal_node(&graph, "helper").is_none(),
26379            "stale symbol evidence should be skipped when refresh is blocked"
26380        );
26381    }
26382
26383    #[test]
26384    fn traversal_cmd_supports_json_and_html_outputs() {
26385        let dir = setup_traversal_project();
26386        cmd_traverse(
26387            Some("#kgnv"),
26388            Some("main"),
26389            dir.path(),
26390            None,
26391            1,
26392            50,
26393            TraverseFormat::Json,
26394            false,
26395            false,
26396            false,
26397            None,
26398        )
26399        .unwrap();
26400        cmd_traverse(
26401            None,
26402            None,
26403            dir.path(),
26404            None,
26405            1,
26406            50,
26407            TraverseFormat::Html,
26408            false,
26409            false,
26410            false,
26411            None,
26412        )
26413        .unwrap();
26414    }
26415
26416    #[test]
26417    fn traversal_html_renders_inline_graph_visualization() {
26418        let dir = setup_traversal_project();
26419        seed_traversal_semantic_summaries(dir.path());
26420        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26421        let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
26422        let html = traversal_report_html(&report).unwrap();
26423
26424        assert!(html.contains("id=\"graph-canvas\""));
26425        assert!(html.contains("semantic_concept"));
26426        assert!(html.contains("graph navigation"));
26427        assert!(html.contains("JSON.parse"));
26428    }
26429
26430    #[test]
26431    fn compact_helpers_trim_scores_and_snippets() {
26432        assert_eq!(format_score(0.12345, true), "0.12");
26433        assert_eq!(format_score(0.12345, false), "0.1235");
26434        let snippet = compact_snippet("    first line with useful context\nsecond");
26435        assert_eq!(snippet.as_deref(), Some("first line with useful context"));
26436    }
26437
26438    #[test]
26439    fn compact_members_caps_list() {
26440        let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
26441            .iter()
26442            .map(|n| graph::CommunityMember::new(*n))
26443            .collect();
26444        assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
26445    }
26446
26447    #[test]
26448    fn abbreviate_kind_maps_common_kinds() {
26449        assert_eq!(abbreviate_kind("function"), "fn");
26450        assert_eq!(abbreviate_kind("method"), "meth");
26451        assert_eq!(abbreviate_kind("class"), "cls");
26452        assert_eq!(abbreviate_kind("interface"), "iface");
26453        assert_eq!(abbreviate_kind("type_alias"), "type");
26454        assert_eq!(abbreviate_kind("data_class"), "data_cls");
26455        assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
26456        assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
26457        assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
26458        assert_eq!(abbreviate_kind("object"), "obj");
26459        assert_eq!(abbreviate_kind("heading"), "h");
26460        assert_eq!(abbreviate_kind("code_block"), "code");
26461        // short kinds pass through
26462        assert_eq!(abbreviate_kind("struct"), "struct");
26463        assert_eq!(abbreviate_kind("trait"), "trait");
26464        assert_eq!(abbreviate_kind("enum"), "enum");
26465        assert_eq!(abbreviate_kind("const"), "const");
26466        assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
26467    }
26468
26469    #[test]
26470    fn abbreviate_match_type_maps_search_types() {
26471        assert_eq!(abbreviate_match_type("exact_name"), "exact");
26472        assert_eq!(abbreviate_match_type("partial_tags"), "partial");
26473        assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
26474        assert_eq!(abbreviate_match_type("other_type"), "other_type");
26475    }
26476
26477    #[test]
26478    fn explain_compact_groups_edges_by_file() {
26479        let edges = vec![
26480            index::StoredEdge {
26481                caller_file: "src/main.rs".to_string(),
26482                caller_name: "main".to_string(),
26483                caller_line: 1,
26484                callee_name: "helper".to_string(),
26485                call_site_line: 2,
26486                tagpath_handle: None,
26487            },
26488            index::StoredEdge {
26489                caller_file: "src/main.rs".to_string(),
26490                caller_name: "main".to_string(),
26491                caller_line: 1,
26492                callee_name: "render".to_string(),
26493                call_site_line: 3,
26494                tagpath_handle: None,
26495            },
26496        ];
26497        let lines = format_edge_groups(&edges, false);
26498        assert_eq!(lines, vec!["  src/main.rs (2): helper, render"]);
26499    }
26500
26501    #[test]
26502    fn search_hit_groups_preserve_file_counts_and_samples() {
26503        let dir = tempfile::tempdir().unwrap();
26504        let root = dir.path();
26505        let main_rs = root.join("src/main.rs");
26506        fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
26507        fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
26508        let freshness = exact_search_file_timestamp(&main_rs);
26509        let hits = vec![
26510            sift::SearchHit {
26511                artifact_id: "a".to_string(),
26512                artifact_kind: sift::ContextArtifactKind::File,
26513                path: main_rs.display().to_string(),
26514                rank: 1,
26515                score: 10.0,
26516                confidence: sift::ScoreConfidence::High,
26517                location: Some("line 3".to_string()),
26518                snippet: "claudescore-3 anchor".to_string(),
26519                provenance: sift::ArtifactProvenance {
26520                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26521                    source: "ripgrep -F".to_string(),
26522                    synthetic: false,
26523                },
26524                freshness: freshness.clone(),
26525                budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
26526            },
26527            sift::SearchHit {
26528                artifact_id: "b".to_string(),
26529                artifact_kind: sift::ContextArtifactKind::File,
26530                path: main_rs.display().to_string(),
26531                rank: 2,
26532                score: 9.0,
26533                confidence: sift::ScoreConfidence::High,
26534                location: Some("line 7".to_string()),
26535                snippet: "claudescore-3 follow-up".to_string(),
26536                provenance: sift::ArtifactProvenance {
26537                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26538                    source: "ripgrep -F".to_string(),
26539                    synthetic: false,
26540                },
26541                freshness: freshness.clone(),
26542                budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
26543            },
26544            sift::SearchHit {
26545                artifact_id: "c".to_string(),
26546                artifact_kind: sift::ContextArtifactKind::File,
26547                path: main_rs.display().to_string(),
26548                rank: 3,
26549                score: 8.0,
26550                confidence: sift::ScoreConfidence::High,
26551                location: Some("line 9".to_string()),
26552                snippet: "claudescore-3 tail".to_string(),
26553                provenance: sift::ArtifactProvenance {
26554                    adapter: sift::AcquisitionAdapterKind::FileSystem,
26555                    source: "ripgrep -F".to_string(),
26556                    synthetic: false,
26557                },
26558                freshness,
26559                budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
26560            },
26561        ];
26562
26563        let groups = group_search_hits(&hits, root, false);
26564        assert_eq!(groups.len(), 1);
26565        assert_eq!(groups[0].path, "src/main.rs");
26566        assert_eq!(groups[0].hits, 3);
26567        assert_eq!(
26568            groups[0].samples,
26569            vec![
26570                "line 3: claudescore-3 anchor".to_string(),
26571                "line 7: claudescore-3 follow-up".to_string()
26572            ]
26573        );
26574        assert!(should_collapse_search_hits(&hits, root, false));
26575    }
26576
26577    #[test]
26578    fn dense_edge_groups_trigger_collapse() {
26579        let edges = vec![
26580            index::StoredEdge {
26581                caller_file: "src/main.rs".to_string(),
26582                caller_name: "main".to_string(),
26583                caller_line: 1,
26584                callee_name: "helper".to_string(),
26585                call_site_line: 2,
26586                tagpath_handle: None,
26587            },
26588            index::StoredEdge {
26589                caller_file: "src/main.rs".to_string(),
26590                caller_name: "beta".to_string(),
26591                caller_line: 5,
26592                callee_name: "helper".to_string(),
26593                call_site_line: 6,
26594                tagpath_handle: None,
26595            },
26596            index::StoredEdge {
26597                caller_file: "src/main.rs".to_string(),
26598                caller_name: "gamma".to_string(),
26599                caller_line: 9,
26600                callee_name: "helper".to_string(),
26601                call_site_line: 10,
26602                tagpath_handle: None,
26603            },
26604        ];
26605        assert!(should_collapse_edge_groups(&edges));
26606    }
26607
26608    // --- workspace indexing ---
26609
26610    fn setup_workspace() -> tempfile::TempDir {
26611        let dir = tempfile::tempdir().unwrap();
26612        let root = dir.path();
26613        std::fs::write(
26614            root.join(".gitmodules"),
26615            r#"[submodule "src/alpha"]
26616	path = src/alpha
26617	url = https://example.com/alpha
26618[submodule "src/beta"]
26619	path = src/beta
26620	url = https://example.com/beta
26621"#,
26622        )
26623        .unwrap();
26624        let alpha = root.join("src/alpha");
26625        let beta = root.join("src/beta");
26626        std::fs::create_dir_all(&alpha).unwrap();
26627        std::fs::create_dir_all(&beta).unwrap();
26628        std::fs::write(
26629            alpha.join("lib.rs"),
26630            "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
26631        )
26632        .unwrap();
26633        std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
26634        dir
26635    }
26636
26637    fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
26638        let dir = tempfile::tempdir().unwrap();
26639        let root = dir.path();
26640        std::fs::write(
26641            root.join(".gitmodules"),
26642            r#"[submodule "pkg/app/foo"]
26643	path = pkg/app/foo
26644	url = https://example.com/pkg-app-foo
26645[submodule "vendor/foo"]
26646	path = vendor/foo
26647	url = https://example.com/vendor-foo
26648"#,
26649        )
26650        .unwrap();
26651        let pkg_foo = root.join("pkg/app/foo");
26652        let vendor_foo = root.join("vendor/foo");
26653        std::fs::create_dir_all(&pkg_foo).unwrap();
26654        std::fs::create_dir_all(&vendor_foo).unwrap();
26655        std::fs::write(
26656            pkg_foo.join("lib.rs"),
26657            "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
26658        )
26659        .unwrap();
26660        std::fs::write(
26661            vendor_foo.join("lib.rs"),
26662            "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
26663        )
26664        .unwrap();
26665        dir
26666    }
26667
26668    #[test]
26669    fn workspace_index_creates_per_submodule_dbs() {
26670        let dir = setup_workspace();
26671        cmd_index(
26672            dir.path(),
26673            false,
26674            false,
26675            false,
26676            false,
26677            false,
26678            true,
26679            None,
26680            false,
26681            false,
26682            false,
26683            false,
26684            false,
26685            false,
26686        )
26687        .unwrap();
26688        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26689        assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
26690    }
26691
26692    #[test]
26693    fn workspace_index_single_submodule() {
26694        let dir = setup_workspace();
26695        cmd_index(
26696            dir.path(),
26697            false,
26698            false,
26699            false,
26700            false,
26701            false,
26702            false,
26703            Some("alpha"),
26704            false,
26705            false,
26706            false,
26707            false,
26708            false,
26709            false,
26710        )
26711        .unwrap();
26712        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26713        assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
26714    }
26715
26716    #[test]
26717    fn workspace_index_single_submodule_errors_on_unknown_scope() {
26718        let dir = setup_workspace();
26719
26720        let err = cmd_index(
26721            dir.path(),
26722            false,
26723            false,
26724            false,
26725            false,
26726            false,
26727            false,
26728            Some("missing"),
26729            false,
26730            false,
26731            false,
26732            false,
26733            false,
26734            false,
26735        )
26736        .unwrap_err();
26737
26738        let msg = err.to_string();
26739        assert!(msg.contains("unknown scope `missing`"));
26740        assert!(msg.contains("Available scopes: alpha, beta"));
26741        assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
26742    }
26743
26744    #[test]
26745    fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
26746        let dir = setup_workspace_with_duplicate_leaf_names();
26747        cmd_index(
26748            dir.path(),
26749            false,
26750            false,
26751            false,
26752            false,
26753            false,
26754            true,
26755            None,
26756            false,
26757            false,
26758            false,
26759            false,
26760            false,
26761            false,
26762        )
26763        .unwrap();
26764
26765        assert!(
26766            dir.path()
26767                .join(".tsift/indexes/pkg/app/foo/index.db")
26768                .exists()
26769        );
26770        assert!(
26771            dir.path()
26772                .join(".tsift/indexes/vendor/foo/index.db")
26773                .exists()
26774        );
26775    }
26776
26777    #[test]
26778    fn federated_search_across_submodules() {
26779        let dir = setup_workspace();
26780        cmd_index(
26781            dir.path(),
26782            false,
26783            false,
26784            false,
26785            false,
26786            false,
26787            true,
26788            None,
26789            false,
26790            false,
26791            false,
26792            false,
26793            false,
26794            false,
26795        )
26796        .unwrap();
26797        let (hits, _diag) = federated_symbol_search(
26798            dir.path(),
26799            "alpha_helper",
26800            10,
26801            &TagpathSearchOpts {
26802                no_tagpath: true,
26803                strict: false,
26804            },
26805        )
26806        .unwrap();
26807        assert!(
26808            !hits.is_empty(),
26809            "should find alpha_helper via federated search"
26810        );
26811    }
26812
26813    #[test]
26814    fn federated_search_respects_isolation() {
26815        let dir = setup_workspace();
26816        let tsift_dir = dir.path().join(".tsift");
26817        std::fs::create_dir_all(&tsift_dir).unwrap();
26818        std::fs::write(
26819            tsift_dir.join("config.toml"),
26820            r#"
26821[overrides.alpha]
26822tier = "isolated"
26823"#,
26824        )
26825        .unwrap();
26826        cmd_index(
26827            dir.path(),
26828            false,
26829            false,
26830            false,
26831            false,
26832            false,
26833            true,
26834            None,
26835            false,
26836            false,
26837            false,
26838            false,
26839            false,
26840            false,
26841        )
26842        .unwrap();
26843        let (hits, _diag) = federated_symbol_search(
26844            dir.path(),
26845            "alpha_helper",
26846            10,
26847            &TagpathSearchOpts {
26848                no_tagpath: true,
26849                strict: false,
26850            },
26851        )
26852        .unwrap();
26853        assert!(
26854            hits.is_empty(),
26855            "isolated submodule should not appear in federated search"
26856        );
26857    }
26858
26859    #[test]
26860    fn federated_lexical_search_respects_isolation() {
26861        let dir = setup_workspace();
26862        let tsift_dir = dir.path().join(".tsift");
26863        std::fs::create_dir_all(&tsift_dir).unwrap();
26864        std::fs::write(
26865            tsift_dir.join("config.toml"),
26866            r#"
26867[overrides.alpha]
26868tier = "isolated"
26869"#,
26870        )
26871        .unwrap();
26872        cmd_index(
26873            dir.path(),
26874            false,
26875            false,
26876            false,
26877            false,
26878            false,
26879            true,
26880            None,
26881            false,
26882            false,
26883            false,
26884            false,
26885            false,
26886            false,
26887        )
26888        .unwrap();
26889
26890        let response = federated_sift_search(
26891            dir.path(),
26892            &dir.path().join(".tsift/search-cache"),
26893            "fn",
26894            10,
26895            0,
26896            "lexical",
26897            None,
26898        )
26899        .unwrap();
26900
26901        assert!(
26902            !response.hits.is_empty(),
26903            "shared scopes should still contribute lexical hits"
26904        );
26905        assert!(
26906            response
26907                .hits
26908                .iter()
26909                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26910            "isolated scope should not leak lexical hits: {:?}",
26911            response.hits
26912        );
26913    }
26914
26915    #[test]
26916    fn federated_lexical_search_respects_private_tier() {
26917        let dir = setup_workspace();
26918        let tsift_dir = dir.path().join(".tsift");
26919        std::fs::create_dir_all(&tsift_dir).unwrap();
26920        std::fs::write(
26921            tsift_dir.join("config.toml"),
26922            r#"
26923[overrides.alpha]
26924tier = "private"
26925"#,
26926        )
26927        .unwrap();
26928        cmd_index(
26929            dir.path(),
26930            false,
26931            false,
26932            false,
26933            false,
26934            false,
26935            true,
26936            None,
26937            false,
26938            false,
26939            false,
26940            false,
26941            false,
26942            false,
26943        )
26944        .unwrap();
26945
26946        let response = federated_sift_search(
26947            dir.path(),
26948            &dir.path().join(".tsift/search-cache"),
26949            "fn",
26950            10,
26951            0,
26952            "lexical",
26953            None,
26954        )
26955        .unwrap();
26956
26957        assert!(
26958            !response.hits.is_empty(),
26959            "shared scopes should still contribute lexical hits"
26960        );
26961        assert!(
26962            response
26963                .hits
26964                .iter()
26965                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26966            "private scope should not leak lexical hits: {:?}",
26967            response.hits
26968        );
26969    }
26970
26971    #[test]
26972    fn scoped_search_finds_submodule_symbols() {
26973        let dir = setup_workspace();
26974        cmd_index(
26975            dir.path(),
26976            false,
26977            false,
26978            false,
26979            false,
26980            false,
26981            true,
26982            None,
26983            false,
26984            false,
26985            false,
26986            false,
26987            false,
26988            false,
26989        )
26990        .unwrap();
26991        let cfg = config::Config::load(dir.path()).unwrap();
26992        let db_path = cfg.db_path_for(dir.path(), "alpha");
26993        let db = index::IndexDb::open(&db_path).unwrap();
26994        let hits = db.symbol_search("alpha_main", 10).unwrap();
26995        assert!(!hits.is_empty());
26996        assert_eq!(hits[0].name, "alpha_main");
26997    }
26998
26999    #[test]
27000    fn scoped_search_cmd_errors_on_unknown_scope() {
27001        let dir = setup_workspace();
27002
27003        let err = cmd_search(
27004            "alpha_main".to_string(),
27005            Some(dir.path().to_path_buf()),
27006            5,
27007            Some("lexical".to_string()),
27008            Some("missing".to_string()),
27009            false,
27010            false,
27011            false,
27012            0,
27013            false,
27014            false,
27015            false,
27016            false,
27017            false,
27018            false,
27019            false,
27020        )
27021        .unwrap_err();
27022
27023        let msg = err.to_string();
27024        assert!(msg.contains("unknown scope `missing`"));
27025        assert!(msg.contains("Available scopes: alpha, beta"));
27026    }
27027
27028    #[test]
27029    fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
27030        let dir = setup_workspace_with_duplicate_leaf_names();
27031        cmd_index(
27032            dir.path(),
27033            false,
27034            false,
27035            false,
27036            false,
27037            false,
27038            true,
27039            None,
27040            false,
27041            false,
27042            false,
27043            false,
27044            false,
27045            false,
27046        )
27047        .unwrap();
27048
27049        let err = cmd_search(
27050            "vendor_only".to_string(),
27051            Some(dir.path().to_path_buf()),
27052            5,
27053            Some("lexical".to_string()),
27054            Some("foo".to_string()),
27055            false,
27056            false,
27057            false,
27058            0,
27059            false,
27060            false,
27061            false,
27062            false,
27063            false,
27064            false,
27065            false,
27066        )
27067        .unwrap_err();
27068
27069        let msg = err.to_string();
27070        assert!(msg.contains("ambiguous scope `foo`"));
27071        assert!(msg.contains("pkg/app/foo"));
27072        assert!(msg.contains("vendor/foo"));
27073    }
27074
27075    #[test]
27076    fn scoped_graph_query() {
27077        let dir = setup_workspace();
27078        cmd_index(
27079            dir.path(),
27080            false,
27081            false,
27082            false,
27083            false,
27084            false,
27085            true,
27086            None,
27087            false,
27088            false,
27089            false,
27090            false,
27091            false,
27092            false,
27093        )
27094        .unwrap();
27095        let cfg = config::Config::load(dir.path()).unwrap();
27096        let db_path = cfg.db_path_for(dir.path(), "alpha");
27097        let db = index::IndexDb::open(&db_path).unwrap();
27098        let callees = db.callees_of("alpha_main").unwrap();
27099        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
27100        assert!(names.contains(&"alpha_helper"));
27101    }
27102
27103    fn assert_workspace_query_requires_scope(err: anyhow::Error) {
27104        let msg = err.to_string();
27105        assert!(msg.contains("require `--scope <scope>`"), "{msg}");
27106        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27107        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27108        assert!(
27109            !msg.contains("no index found at"),
27110            "workspace query should fail with scope guidance, got: {msg}"
27111        );
27112    }
27113
27114    fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
27115        let msg = err.to_string();
27116        assert!(
27117            msg.contains("requires `--scope <scope>` or `--federated`"),
27118            "{msg}"
27119        );
27120        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
27121        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
27122        assert!(
27123            !msg.contains("autoindexing index"),
27124            "workspace search should fail before creating a shared root index: {msg}"
27125        );
27126    }
27127
27128    #[test]
27129    fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
27130        let dir = setup_workspace();
27131        cmd_index(
27132            dir.path(),
27133            false,
27134            false,
27135            false,
27136            false,
27137            false,
27138            true,
27139            None,
27140            false,
27141            false,
27142            false,
27143            false,
27144            false,
27145            false,
27146        )
27147        .unwrap();
27148
27149        let err = cmd_graph(
27150            "alpha_main",
27151            dir.path(),
27152            false,
27153            false,
27154            None,
27155            20,
27156            false,
27157            false,
27158            false,
27159            false,
27160            false,
27161            false,
27162            false,
27163            TagpathSearchOpts::default(),
27164        )
27165        .unwrap_err();
27166
27167        assert_workspace_query_requires_scope(err);
27168    }
27169
27170    #[test]
27171    fn graph_cmd_infers_scope_from_nested_workspace_path() {
27172        let dir = setup_workspace();
27173        cmd_index(
27174            dir.path(),
27175            false,
27176            false,
27177            false,
27178            false,
27179            false,
27180            true,
27181            None,
27182            false,
27183            false,
27184            false,
27185            false,
27186            false,
27187            false,
27188        )
27189        .unwrap();
27190        let nested = dir.path().join("src/alpha/nested");
27191        std::fs::create_dir_all(&nested).unwrap();
27192
27193        let result = cmd_graph(
27194            "alpha_main",
27195            &nested,
27196            false,
27197            false,
27198            None,
27199            20,
27200            false,
27201            false,
27202            false,
27203            false,
27204            false,
27205            false,
27206            false,
27207            TagpathSearchOpts::default(),
27208        );
27209
27210        assert!(result.is_ok());
27211    }
27212
27213    #[test]
27214    fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
27215        let dir = setup_workspace();
27216        cmd_index(
27217            dir.path(),
27218            false,
27219            false,
27220            false,
27221            false,
27222            false,
27223            true,
27224            None,
27225            false,
27226            false,
27227            false,
27228            false,
27229            false,
27230            false,
27231        )
27232        .unwrap();
27233
27234        let err = cmd_communities(
27235            dir.path(),
27236            None,
27237            1,
27238            10,
27239            false,
27240            false,
27241            false,
27242            false,
27243            false,
27244            false,
27245            TagpathSearchOpts::default(),
27246        )
27247        .unwrap_err();
27248
27249        assert_workspace_query_requires_scope(err);
27250    }
27251
27252    #[test]
27253    fn communities_cmd_infers_scope_from_nested_workspace_path() {
27254        let dir = setup_workspace();
27255        cmd_index(
27256            dir.path(),
27257            false,
27258            false,
27259            false,
27260            false,
27261            false,
27262            true,
27263            None,
27264            false,
27265            false,
27266            false,
27267            false,
27268            false,
27269            false,
27270        )
27271        .unwrap();
27272        let nested = dir.path().join("src/alpha/nested");
27273        std::fs::create_dir_all(&nested).unwrap();
27274
27275        let result = cmd_communities(
27276            &nested,
27277            None,
27278            1,
27279            10,
27280            false,
27281            false,
27282            false,
27283            false,
27284            false,
27285            false,
27286            TagpathSearchOpts::default(),
27287        );
27288
27289        assert!(result.is_ok());
27290    }
27291
27292    #[test]
27293    fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
27294        let dir = setup_workspace();
27295        cmd_index(
27296            dir.path(),
27297            false,
27298            false,
27299            false,
27300            false,
27301            false,
27302            true,
27303            None,
27304            false,
27305            false,
27306            false,
27307            false,
27308            false,
27309            false,
27310        )
27311        .unwrap();
27312
27313        let err = cmd_path(
27314            "alpha_main",
27315            "alpha_helper",
27316            dir.path(),
27317            None,
27318            false,
27319            false,
27320            false,
27321            false,
27322            false,
27323            TagpathSearchOpts::default(),
27324        )
27325        .unwrap_err();
27326
27327        assert_workspace_query_requires_scope(err);
27328    }
27329
27330    #[test]
27331    fn path_cmd_infers_scope_from_nested_workspace_path() {
27332        let dir = setup_workspace();
27333        cmd_index(
27334            dir.path(),
27335            false,
27336            false,
27337            false,
27338            false,
27339            false,
27340            true,
27341            None,
27342            false,
27343            false,
27344            false,
27345            false,
27346            false,
27347            false,
27348        )
27349        .unwrap();
27350        let nested = dir.path().join("src/alpha/nested");
27351        std::fs::create_dir_all(&nested).unwrap();
27352
27353        let result = cmd_path(
27354            "alpha_main",
27355            "alpha_helper",
27356            &nested,
27357            None,
27358            false,
27359            false,
27360            false,
27361            false,
27362            false,
27363            TagpathSearchOpts::default(),
27364        );
27365
27366        assert!(result.is_ok());
27367    }
27368
27369    #[test]
27370    fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27371        let dir = setup_graph_index();
27372        let db_path = dir.path().join(".tsift/index.db");
27373        let _lock = hold_rollback_journal_lock(&db_path);
27374
27375        let result = cmd_path(
27376            "main",
27377            "helper",
27378            dir.path(),
27379            None,
27380            false,
27381            false,
27382            false,
27383            false,
27384            false,
27385            TagpathSearchOpts::default(),
27386        );
27387
27388        assert!(result.is_ok());
27389    }
27390
27391    #[test]
27392    fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
27393        let dir = setup_workspace();
27394        cmd_index(
27395            dir.path(),
27396            false,
27397            false,
27398            false,
27399            false,
27400            false,
27401            true,
27402            None,
27403            false,
27404            false,
27405            false,
27406            false,
27407            false,
27408            false,
27409        )
27410        .unwrap();
27411
27412        let err = cmd_explain(
27413            "alpha_main",
27414            dir.path(),
27415            None,
27416            15,
27417            false,
27418            false,
27419            false,
27420            false,
27421            false,
27422            false,
27423            false,
27424            false,
27425        )
27426        .unwrap_err();
27427
27428        assert_workspace_query_requires_scope(err);
27429    }
27430
27431    #[test]
27432    fn explain_cmd_infers_scope_from_nested_workspace_path() {
27433        let dir = setup_workspace();
27434        cmd_index(
27435            dir.path(),
27436            false,
27437            false,
27438            false,
27439            false,
27440            false,
27441            true,
27442            None,
27443            false,
27444            false,
27445            false,
27446            false,
27447            false,
27448            false,
27449        )
27450        .unwrap();
27451        let nested = dir.path().join("src/alpha/nested");
27452        std::fs::create_dir_all(&nested).unwrap();
27453
27454        let result = cmd_explain(
27455            "alpha_main",
27456            &nested,
27457            None,
27458            15,
27459            false,
27460            false,
27461            false,
27462            false,
27463            false,
27464            false,
27465            false,
27466            false,
27467        );
27468
27469        assert!(result.is_ok());
27470    }
27471
27472    #[test]
27473    fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27474        let dir = setup_graph_index();
27475        let db_path = dir.path().join(".tsift/index.db");
27476        let _lock = hold_rollback_journal_lock(&db_path);
27477
27478        let result = cmd_explain(
27479            "main",
27480            dir.path(),
27481            None,
27482            15,
27483            false,
27484            false,
27485            false,
27486            false,
27487            false,
27488            false,
27489            false,
27490            false,
27491        );
27492
27493        assert!(result.is_ok());
27494    }
27495
27496    // --- community detection ---
27497
27498    #[test]
27499    fn community_detection_groups_related() {
27500        let dir = setup_graph_index();
27501        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27502        let edges = db.all_edges().unwrap();
27503        let result = graph::detect_communities(&edges);
27504        assert!(result.node_count > 0);
27505        assert!(!result.communities.is_empty());
27506    }
27507
27508    #[test]
27509    fn community_cmd_autoindexes_missing_index_by_default() {
27510        let dir = tempfile::tempdir().unwrap();
27511        let result = cmd_communities(
27512            dir.path(),
27513            None,
27514            2,
27515            10,
27516            false,
27517            false,
27518            false,
27519            false,
27520            false,
27521            false,
27522            TagpathSearchOpts::default(),
27523        );
27524
27525        assert!(result.is_ok());
27526        assert!(dir.path().join(".tsift/index.db").exists());
27527    }
27528
27529    // --- path ---
27530
27531    #[test]
27532    fn path_finds_connected_symbols() {
27533        let dir = setup_graph_index();
27534        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27535        let edges = db.all_edges().unwrap();
27536        let result = graph::shortest_path(&edges, "main", "helper");
27537        assert!(result.is_some());
27538        let path = result.unwrap();
27539        assert_eq!(path.hops, 1);
27540    }
27541
27542    #[test]
27543    fn path_returns_none_for_unknown() {
27544        let dir = setup_graph_index();
27545        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27546        let edges = db.all_edges().unwrap();
27547        assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
27548    }
27549
27550    #[test]
27551    fn path_cmd_autoindexes_missing_index_by_default() {
27552        let dir = tempfile::tempdir().unwrap();
27553        let result = cmd_path(
27554            "a",
27555            "b",
27556            dir.path(),
27557            None,
27558            false,
27559            false,
27560            false,
27561            false,
27562            false,
27563            TagpathSearchOpts::default(),
27564        );
27565
27566        assert!(result.is_ok());
27567        assert!(dir.path().join(".tsift/index.db").exists());
27568    }
27569
27570    // --- explain ---
27571
27572    #[test]
27573    fn explain_shows_symbol_info() {
27574        let dir = setup_graph_index();
27575        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27576        let symbols = db.symbol_info("main").unwrap();
27577        assert!(!symbols.is_empty());
27578        assert_eq!(symbols[0].name, "main");
27579        assert_eq!(symbols[0].kind, "function");
27580    }
27581
27582    #[test]
27583    fn explain_cmd_autoindexes_missing_index_by_default() {
27584        let dir = tempfile::tempdir().unwrap();
27585        let result = cmd_explain(
27586            "main",
27587            dir.path(),
27588            None,
27589            15,
27590            false,
27591            false,
27592            false,
27593            false,
27594            false,
27595            false,
27596            false,
27597            false,
27598        );
27599
27600        assert!(result.is_ok());
27601        assert!(dir.path().join(".tsift/index.db").exists());
27602    }
27603
27604    fn hold_write_lock(db_path: &std::path::Path) -> Connection {
27605        let conn = Connection::open(db_path).unwrap();
27606        conn.execute_batch("BEGIN IMMEDIATE").unwrap();
27607        conn
27608    }
27609
27610    fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
27611        use fs4::fs_std::FileExt;
27612        use std::io::Write;
27613
27614        let mut file = std::fs::OpenOptions::new()
27615            .read(true)
27616            .write(true)
27617            .create(true)
27618            .truncate(false)
27619            .open(lock_path)
27620            .unwrap();
27621        assert!(file.try_lock_exclusive().unwrap());
27622        writeln!(file, "{}", std::process::id()).unwrap();
27623        file
27624    }
27625
27626    fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
27627        let conn = Connection::open(db_path).unwrap();
27628        conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
27629            .unwrap();
27630        std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
27631        conn
27632    }
27633
27634    fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
27635        let conn = Connection::open(db_path).unwrap();
27636        conn.execute_batch(
27637            "PRAGMA journal_mode=WAL;
27638             PRAGMA wal_autocheckpoint=0;
27639             CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
27640             INSERT INTO wal_lock_probe DEFAULT VALUES;
27641             PRAGMA locking_mode=EXCLUSIVE;
27642             BEGIN EXCLUSIVE;",
27643        )
27644        .unwrap();
27645        assert!(substrate::wal_sidecar_path(db_path).exists());
27646        conn
27647    }
27648
27649    #[test]
27650    fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
27651        let dir = setup_graph_index();
27652        let db_path = dir.path().join(".tsift/index.db");
27653        let _lock = hold_wal_database_lock(&db_path);
27654
27655        let err = cmd_index(
27656            dir.path(),
27657            false,
27658            false,
27659            false,
27660            false,
27661            false,
27662            false,
27663            None,
27664            false,
27665            false,
27666            false,
27667            false,
27668            false,
27669            false,
27670        )
27671        .unwrap_err();
27672
27673        let msg = err.to_string();
27674        assert!(msg.contains("indexing"));
27675        assert!(msg.contains("lock diagnostics:"));
27676        assert!(msg.contains("lock: absent"));
27677        assert!(msg.contains("wal: present") || msg.contains("shm: present"));
27678        assert!(msg.contains("wedged writer holding live WAL sidecars"));
27679        assert!(msg.contains("snapshot fallback"));
27680    }
27681
27682    #[test]
27683    fn search_cmd_succeeds_while_writer_lock_is_held() {
27684        let dir = setup_graph_index();
27685        let db_path = dir.path().join(".tsift/index.db");
27686        let _lock = hold_write_lock(&db_path);
27687
27688        let result = cmd_search(
27689            "main".to_string(),
27690            Some(dir.path().to_path_buf()),
27691            5,
27692            Some("lexical".to_string()),
27693            None,
27694            false,
27695            false,
27696            false,
27697            0,
27698            true,
27699            false,
27700            false,
27701            false,
27702            false,
27703            false,
27704            false,
27705        );
27706
27707        assert!(result.is_ok());
27708    }
27709
27710    #[test]
27711    fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
27712        let dir = setup_graph_index();
27713        let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
27714
27715        let result = cmd_search(
27716            "main".to_string(),
27717            Some(dir.path().to_path_buf()),
27718            5,
27719            Some("lexical".to_string()),
27720            None,
27721            false,
27722            false,
27723            false,
27724            0,
27725            true,
27726            false,
27727            false,
27728            false,
27729            false,
27730            false,
27731            false,
27732        );
27733
27734        assert!(result.is_ok());
27735    }
27736
27737    #[test]
27738    fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
27739        let dir = setup_graph_index();
27740        let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
27741
27742        let result = cmd_search(
27743            "main".to_string(),
27744            Some(dir.path().to_path_buf()),
27745            5,
27746            Some("lexical".to_string()),
27747            None,
27748            false,
27749            false,
27750            false,
27751            0,
27752            true,
27753            false,
27754            false,
27755            false,
27756            false,
27757            false,
27758            false,
27759        );
27760
27761        assert!(result.is_ok());
27762    }
27763
27764    #[test]
27765    fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
27766        let dir = setup_graph_index();
27767        std::thread::sleep(std::time::Duration::from_millis(50));
27768        std::fs::write(
27769            dir.path().join("main.rs"),
27770            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27771        )
27772        .unwrap();
27773
27774        let err = cmd_search(
27775            "helper".to_string(),
27776            Some(dir.path().to_path_buf()),
27777            5,
27778            Some("lexical".to_string()),
27779            None,
27780            false,
27781            false,
27782            false,
27783            0,
27784            false,
27785            false,
27786            false,
27787            false,
27788            false,
27789            false,
27790            false,
27791        )
27792        .unwrap_err();
27793
27794        assert!(err.to_string().contains("search aborted"));
27795        assert!(err.to_string().contains("index is stale"));
27796        assert!(err.to_string().contains("--no-autoindex"));
27797    }
27798
27799    #[test]
27800    fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
27801        let dir = setup_graph_index();
27802        std::thread::sleep(std::time::Duration::from_millis(50));
27803        std::fs::write(
27804            dir.path().join("main.rs"),
27805            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27806        )
27807        .unwrap();
27808        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27809
27810        let err = cmd_search(
27811            "helper".to_string(),
27812            Some(dir.path().to_path_buf()),
27813            5,
27814            Some("lexical".to_string()),
27815            None,
27816            false,
27817            false,
27818            false,
27819            0,
27820            false,
27821            false,
27822            false,
27823            false,
27824            false,
27825            false,
27826            false,
27827        )
27828        .unwrap_err();
27829
27830        assert!(err.to_string().contains("search aborted"));
27831        assert!(err.to_string().contains("index is stale"));
27832        assert!(!err.to_string().contains("database is locked"));
27833    }
27834
27835    #[test]
27836    fn search_cmd_autoindexes_stale_index_by_default() {
27837        let dir = setup_graph_index();
27838        std::thread::sleep(std::time::Duration::from_millis(50));
27839        std::fs::write(
27840            dir.path().join("main.rs"),
27841            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27842        )
27843        .unwrap();
27844
27845        let result = cmd_search(
27846            "helper".to_string(),
27847            Some(dir.path().to_path_buf()),
27848            5,
27849            Some("lexical".to_string()),
27850            None,
27851            false,
27852            false,
27853            true,
27854            0,
27855            false,
27856            false,
27857            false,
27858            false,
27859            false,
27860            false,
27861            false,
27862        );
27863
27864        assert!(result.is_ok());
27865
27866        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27867        let summary = db.compute_changes(dir.path()).unwrap();
27868        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27869    }
27870
27871    #[test]
27872    fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
27873        let dir = setup_graph_index();
27874        std::thread::sleep(std::time::Duration::from_millis(50));
27875        std::fs::write(
27876            dir.path().join("main.rs"),
27877            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27878        )
27879        .unwrap();
27880        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27881
27882        let result = cmd_search(
27883            "helper".to_string(),
27884            Some(dir.path().to_path_buf()),
27885            5,
27886            Some("lexical".to_string()),
27887            None,
27888            false,
27889            false,
27890            true,
27891            0,
27892            false,
27893            false,
27894            false,
27895            false,
27896            false,
27897            false,
27898            false,
27899        );
27900
27901        assert!(result.is_ok());
27902
27903        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27904        let summary = db.compute_changes(dir.path()).unwrap();
27905        assert_eq!(summary.modified, 1);
27906    }
27907
27908    #[test]
27909    fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
27910        let dir = setup_graph_index();
27911        std::thread::sleep(std::time::Duration::from_millis(50));
27912        std::fs::write(
27913            dir.path().join("main.rs"),
27914            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27915        )
27916        .unwrap();
27917        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27918
27919        let err = cmd_search(
27920            "helper".to_string(),
27921            Some(dir.path().to_path_buf()),
27922            5,
27923            Some("lexical".to_string()),
27924            None,
27925            false,
27926            false,
27927            true,
27928            0,
27929            false,
27930            false,
27931            false,
27932            false,
27933            false,
27934            false,
27935            false,
27936        )
27937        .unwrap_err();
27938
27939        let msg = err.to_string();
27940        assert!(msg.contains("autoindexing index"));
27941        assert!(msg.contains("lock diagnostics:"));
27942        assert!(msg.contains("journal: present"));
27943        assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
27944    }
27945
27946    #[test]
27947    fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
27948        let dir = setup_graph_index();
27949        let nested = dir.path().join("src/nested");
27950        std::fs::create_dir_all(&nested).unwrap();
27951
27952        let result = cmd_search(
27953            "helper".to_string(),
27954            Some(nested.clone()),
27955            5,
27956            Some("lexical".to_string()),
27957            None,
27958            false,
27959            false,
27960            true,
27961            0,
27962            false,
27963            false,
27964            false,
27965            false,
27966            false,
27967            false,
27968            false,
27969        );
27970
27971        assert!(result.is_ok());
27972        assert!(!nested.join(".tsift/index.db").exists());
27973    }
27974
27975    #[test]
27976    fn exact_search_returns_literal_matches() {
27977        let dir = tempfile::tempdir().unwrap();
27978        std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
27979
27980        let response = run_exact_search_with_timeout(
27981            std::slice::from_ref(&dir.path().to_path_buf()),
27982            "claudescore-3",
27983            5,
27984            0,
27985        )
27986        .unwrap();
27987
27988        assert_eq!(response.strategy, "exact");
27989        assert_eq!(response.hits.len(), 1);
27990        assert!(response.hits[0].path.ends_with("notes.txt"));
27991        assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
27992        assert!(response.hits[0].snippet.contains("claudescore-3"));
27993    }
27994
27995    #[test]
27996    fn exact_search_skips_stale_index_precheck() {
27997        let dir = setup_graph_index();
27998        std::thread::sleep(std::time::Duration::from_millis(50));
27999        std::fs::write(
28000            dir.path().join("main.rs"),
28001            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
28002        )
28003        .unwrap();
28004
28005        let result = cmd_search(
28006            "println!(\"updated\")".to_string(),
28007            Some(dir.path().to_path_buf()),
28008            5,
28009            Some("exact".to_string()),
28010            None,
28011            false,
28012            false,
28013            false,
28014            0,
28015            false,
28016            false,
28017            false,
28018            false,
28019            false,
28020            false,
28021            false,
28022        );
28023
28024        assert!(result.is_ok());
28025    }
28026
28027    #[test]
28028    fn workspace_exact_search_does_not_require_shared_root_index() {
28029        let dir = setup_workspace();
28030        cmd_index(
28031            dir.path(),
28032            false,
28033            false,
28034            false,
28035            false,
28036            false,
28037            true,
28038            None,
28039            false,
28040            false,
28041            false,
28042            false,
28043            false,
28044            false,
28045        )
28046        .unwrap();
28047
28048        let result = cmd_search(
28049            "alpha_helper".to_string(),
28050            Some(dir.path().to_path_buf()),
28051            5,
28052            Some("exact".to_string()),
28053            None,
28054            false,
28055            false,
28056            false,
28057            0,
28058            false,
28059            false,
28060            false,
28061            false,
28062            false,
28063            false,
28064            false,
28065        );
28066
28067        assert!(result.is_ok());
28068        assert!(!dir.path().join(".tsift/index.db").exists());
28069    }
28070
28071    #[test]
28072    fn identifier_like_query_prefers_exact_search() {
28073        assert!(query_prefers_exact_search("claudescore-3"));
28074        assert!(query_prefers_exact_search("alpha_helper"));
28075        assert!(query_prefers_exact_search("src/main.rs"));
28076        assert!(query_prefers_exact_search("crate::module"));
28077        assert!(!query_prefers_exact_search("authenticate"));
28078        assert!(!query_prefers_exact_search("fn main"));
28079        assert!(!query_prefers_exact_search("."));
28080    }
28081
28082    #[test]
28083    fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
28084        assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
28085        assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
28086        assert_eq!(
28087            resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
28088            "hybrid"
28089        );
28090    }
28091
28092    #[test]
28093    fn workspace_identifier_like_search_auto_uses_exact_backend() {
28094        let dir = setup_workspace();
28095        cmd_index(
28096            dir.path(),
28097            false,
28098            false,
28099            false,
28100            false,
28101            false,
28102            true,
28103            None,
28104            false,
28105            false,
28106            false,
28107            false,
28108            false,
28109            false,
28110        )
28111        .unwrap();
28112
28113        let result = cmd_search(
28114            "alpha_helper".to_string(),
28115            Some(dir.path().to_path_buf()),
28116            5,
28117            None,
28118            None,
28119            false,
28120            false,
28121            false,
28122            0,
28123            false,
28124            false,
28125            false,
28126            false,
28127            false,
28128            false,
28129            false,
28130        );
28131
28132        assert!(result.is_ok());
28133        assert!(!dir.path().join(".tsift/index.db").exists());
28134    }
28135
28136    #[test]
28137    fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
28138        let dir = setup_graph_index();
28139        let nested = dir.path().join("src/nested");
28140        std::fs::create_dir_all(&nested).unwrap();
28141        std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
28142
28143        let result = cmd_index(
28144            &nested, false, false, false, false, false, false, None, false, false, false, false,
28145            false, false,
28146        );
28147
28148        assert!(result.is_ok());
28149        assert!(dir.path().join(".tsift/index.db").exists());
28150        assert!(!nested.join(".tsift/index.db").exists());
28151    }
28152
28153    #[test]
28154    fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
28155        let dir = setup_workspace();
28156        let nested = dir.path().join("docs/nested");
28157        std::fs::create_dir_all(&nested).unwrap();
28158
28159        let result = cmd_index(
28160            &nested, false, false, false, false, false, true, None, false, false, false, false,
28161            false, false,
28162        );
28163
28164        let cfg = config::Config::load(dir.path()).unwrap();
28165
28166        assert!(result.is_ok());
28167        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28168        assert!(cfg.db_path_for(dir.path(), "beta").exists());
28169    }
28170
28171    #[test]
28172    fn status_cmd_autoindexes_missing_workspace_scopes() {
28173        let dir = setup_workspace();
28174        let cfg = config::Config::load(dir.path()).unwrap();
28175        let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
28176        let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
28177        let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
28178        alpha_db.apply_changes(&alpha.source_root).unwrap();
28179
28180        let beta_db_path = cfg.db_path_for(dir.path(), "beta");
28181        assert!(!beta_db_path.exists());
28182
28183        cmd_status(
28184            dir.path(),
28185            StatusCommandOptions {
28186                fix: false,
28187                no_fix: false,
28188                json_output: true,
28189                compact: false,
28190                pretty: false,
28191                terse: false,
28192                schema: false,
28193            },
28194        )
28195        .unwrap();
28196
28197        assert!(beta_db_path.exists());
28198        let report = status::check_status(dir.path()).unwrap();
28199        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28200    }
28201
28202    #[test]
28203    fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
28204        let dir = setup_workspace();
28205        let cfg = config::Config::load(dir.path()).unwrap();
28206
28207        cmd_status(
28208            dir.path(),
28209            StatusCommandOptions {
28210                fix: false,
28211                no_fix: false,
28212                json_output: true,
28213                compact: false,
28214                pretty: false,
28215                terse: false,
28216                schema: false,
28217            },
28218        )
28219        .unwrap();
28220
28221        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28222        assert!(cfg.db_path_for(dir.path(), "beta").exists());
28223        let report = status::check_status(dir.path()).unwrap();
28224        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28225    }
28226
28227    #[test]
28228    fn status_fix_targets_only_stale_workspace_scopes() {
28229        let dir = setup_workspace();
28230        let cfg = config::Config::load(dir.path()).unwrap();
28231        for scope_id in ["alpha", "beta"] {
28232            let scope = config::Config::resolve_submodule(dir.path(), scope_id).unwrap();
28233            let db = index::IndexDb::open(&cfg.db_path_for(dir.path(), scope_id)).unwrap();
28234            db.apply_changes(&scope.source_root).unwrap();
28235        }
28236
28237        std::thread::sleep(std::time::Duration::from_millis(50));
28238        std::fs::write(
28239            dir.path().join("src/alpha/lib.rs"),
28240            "fn alpha_helper() { println!(\"updated\"); }\n",
28241        )
28242        .unwrap();
28243
28244        let report = status::check_status(dir.path()).unwrap();
28245        let scope_ids = status_workspace_scope_ids_needing_fix(&report);
28246        assert_eq!(scope_ids, std::collections::HashSet::from(["alpha"]));
28247
28248        cmd_status(
28249            dir.path(),
28250            StatusCommandOptions {
28251                fix: false,
28252                no_fix: false,
28253                json_output: true,
28254                compact: false,
28255                pretty: false,
28256                terse: false,
28257                schema: false,
28258            },
28259        )
28260        .unwrap();
28261
28262        let report = status::check_status(dir.path()).unwrap();
28263        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28264    }
28265
28266    #[test]
28267    fn status_cmd_fix_refreshes_stale_index() {
28268        let dir = setup_graph_index();
28269        std::thread::sleep(std::time::Duration::from_millis(50));
28270        std::fs::write(
28271            dir.path().join("main.rs"),
28272            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28273        )
28274        .unwrap();
28275
28276        let report = status::check_status(dir.path()).unwrap();
28277        assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
28278
28279        cmd_status(
28280            dir.path(),
28281            StatusCommandOptions {
28282                fix: false,
28283                no_fix: false,
28284                json_output: true,
28285                compact: false,
28286                pretty: false,
28287                terse: false,
28288                schema: false,
28289            },
28290        )
28291        .unwrap();
28292
28293        let report = status::check_status(dir.path()).unwrap();
28294        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28295    }
28296
28297    #[test]
28298    fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
28299        let dir = setup_graph_index();
28300        let db_path = dir.path().join(".tsift/index.db");
28301        let _lock = hold_wal_database_lock(&db_path);
28302
28303        cmd_status(
28304            dir.path(),
28305            StatusCommandOptions {
28306                fix: false,
28307                no_fix: false,
28308                json_output: true,
28309                compact: false,
28310                pretty: false,
28311                terse: false,
28312                schema: false,
28313            },
28314        )
28315        .unwrap();
28316
28317        let report = status::check_status(dir.path()).unwrap();
28318        assert!(matches!(
28319            report.index,
28320            status::IndexStatus::Fresh {
28321                recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
28322                ..
28323            }
28324        ));
28325        let locks = status::check_locks(dir.path(), None, None).unwrap();
28326        assert!(matches!(
28327            locks.writer_lock,
28328            status::WriterLockStatus::Absent { .. }
28329        ));
28330        assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
28331        assert!(
28332            locks
28333                .recommended_action
28334                .contains("wedged writer holding live WAL sidecars")
28335        );
28336    }
28337
28338    #[test]
28339    fn locks_report_uses_ancestor_project_root_for_nested_paths() {
28340        let dir = setup_graph_index();
28341        let nested = dir.path().join("src/nested");
28342        std::fs::create_dir_all(&nested).unwrap();
28343
28344        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28345        let report = status::check_locks(&root, Some(&nested), None).unwrap();
28346
28347        assert_eq!(report.source_root, dir.path());
28348        assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
28349    }
28350
28351    #[test]
28352    fn workspace_locks_report_infers_scope_from_nested_path() {
28353        let dir = setup_workspace();
28354        cmd_index(
28355            dir.path(),
28356            false,
28357            false,
28358            false,
28359            false,
28360            false,
28361            true,
28362            None,
28363            false,
28364            false,
28365            false,
28366            false,
28367            false,
28368            false,
28369        )
28370        .unwrap();
28371        let nested = dir.path().join("src/alpha/nested");
28372        std::fs::create_dir_all(&nested).unwrap();
28373
28374        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28375        let report = status::check_locks(&root, Some(&nested), None).unwrap();
28376        let cfg = config::Config::load(dir.path()).unwrap();
28377
28378        assert_eq!(report.label, "submodule `alpha` index");
28379        assert_eq!(report.source_root, dir.path().join("src/alpha"));
28380        assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
28381        assert_eq!(
28382            report.reindex_command,
28383            format!("tsift index --submodule alpha {}", dir.path().display())
28384        );
28385    }
28386
28387    #[test]
28388    fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
28389        let dir = setup_workspace();
28390        cmd_index(
28391            dir.path(),
28392            false,
28393            false,
28394            false,
28395            false,
28396            false,
28397            true,
28398            None,
28399            false,
28400            false,
28401            false,
28402            false,
28403            false,
28404            false,
28405        )
28406        .unwrap();
28407
28408        let alpha = dir.path().join("src/alpha/lib.rs");
28409        std::thread::sleep(std::time::Duration::from_millis(50));
28410        std::fs::write(
28411            &alpha,
28412            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28413        )
28414        .unwrap();
28415
28416        let result = cmd_search(
28417            "alpha_helper".to_string(),
28418            Some(dir.path().to_path_buf()),
28419            5,
28420            Some("lexical".to_string()),
28421            Some("alpha".to_string()),
28422            false,
28423            false,
28424            true,
28425            0,
28426            false,
28427            false,
28428            false,
28429            false,
28430            false,
28431            false,
28432            false,
28433        );
28434
28435        assert!(result.is_ok());
28436
28437        let cfg = config::Config::load(dir.path()).unwrap();
28438        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28439        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28440        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28441    }
28442
28443    #[test]
28444    fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28445        let dir = setup_workspace();
28446        cmd_index(
28447            dir.path(),
28448            false,
28449            false,
28450            false,
28451            false,
28452            false,
28453            true,
28454            None,
28455            false,
28456            false,
28457            false,
28458            false,
28459            false,
28460            false,
28461        )
28462        .unwrap();
28463
28464        let alpha = dir.path().join("src/alpha/lib.rs");
28465        std::thread::sleep(std::time::Duration::from_millis(50));
28466        std::fs::write(
28467            &alpha,
28468            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28469        )
28470        .unwrap();
28471
28472        let cfg = config::Config::load(dir.path()).unwrap();
28473        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28474
28475        let err = cmd_search(
28476            "alpha_helper".to_string(),
28477            Some(dir.path().to_path_buf()),
28478            5,
28479            Some("lexical".to_string()),
28480            Some("alpha".to_string()),
28481            false,
28482            false,
28483            false,
28484            0,
28485            false,
28486            false,
28487            false,
28488            false,
28489            false,
28490            false,
28491            false,
28492        )
28493        .unwrap_err();
28494
28495        assert!(err.to_string().contains("search aborted"));
28496        assert!(err.to_string().contains("submodule `alpha` index"));
28497        assert!(!err.to_string().contains("database is locked"));
28498    }
28499
28500    #[test]
28501    fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
28502        let dir = setup_workspace();
28503        cmd_index(
28504            dir.path(),
28505            false,
28506            false,
28507            false,
28508            false,
28509            false,
28510            true,
28511            None,
28512            false,
28513            false,
28514            false,
28515            false,
28516            false,
28517            false,
28518        )
28519        .unwrap();
28520
28521        let alpha = dir.path().join("src/alpha/lib.rs");
28522        std::thread::sleep(std::time::Duration::from_millis(50));
28523        std::fs::write(
28524            &alpha,
28525            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28526        )
28527        .unwrap();
28528
28529        let result = cmd_search(
28530            "alpha_helper".to_string(),
28531            Some(dir.path().to_path_buf()),
28532            5,
28533            Some("lexical".to_string()),
28534            None,
28535            true,
28536            false,
28537            true,
28538            0,
28539            false,
28540            false,
28541            false,
28542            false,
28543            false,
28544            false,
28545            false,
28546        );
28547
28548        assert!(result.is_ok());
28549
28550        let cfg = config::Config::load(dir.path()).unwrap();
28551        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28552        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28553        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28554    }
28555
28556    #[test]
28557    fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28558        let dir = setup_workspace();
28559        cmd_index(
28560            dir.path(),
28561            false,
28562            false,
28563            false,
28564            false,
28565            false,
28566            true,
28567            None,
28568            false,
28569            false,
28570            false,
28571            false,
28572            false,
28573            false,
28574        )
28575        .unwrap();
28576
28577        let alpha = dir.path().join("src/alpha/lib.rs");
28578        std::thread::sleep(std::time::Duration::from_millis(50));
28579        std::fs::write(
28580            &alpha,
28581            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28582        )
28583        .unwrap();
28584
28585        let cfg = config::Config::load(dir.path()).unwrap();
28586        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28587
28588        let err = cmd_search(
28589            "alpha_helper".to_string(),
28590            Some(dir.path().to_path_buf()),
28591            5,
28592            Some("lexical".to_string()),
28593            None,
28594            true,
28595            false,
28596            false,
28597            30,
28598            false,
28599            false,
28600            false,
28601            false,
28602            false,
28603            false,
28604            false,
28605        )
28606        .unwrap_err();
28607
28608        assert!(err.to_string().contains("stale"));
28609        assert!(err.to_string().contains("submodule `alpha` index"));
28610        assert!(!err.to_string().contains("database is locked"));
28611    }
28612
28613    #[test]
28614    fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
28615        let dir = setup_workspace();
28616        cmd_index(
28617            dir.path(),
28618            false,
28619            false,
28620            false,
28621            false,
28622            false,
28623            true,
28624            None,
28625            false,
28626            false,
28627            false,
28628            false,
28629            false,
28630            false,
28631        )
28632        .unwrap();
28633
28634        let err = cmd_search(
28635            "alpha_helper".to_string(),
28636            Some(dir.path().to_path_buf()),
28637            5,
28638            Some("lexical".to_string()),
28639            None,
28640            false,
28641            false,
28642            true,
28643            0,
28644            false,
28645            false,
28646            false,
28647            false,
28648            false,
28649            false,
28650            false,
28651        )
28652        .unwrap_err();
28653
28654        assert_workspace_search_requires_explicit_target(err);
28655        assert!(!dir.path().join(".tsift/index.db").exists());
28656    }
28657
28658    #[test]
28659    fn workspace_search_cmd_infers_scope_from_nested_path() {
28660        let dir = setup_workspace();
28661        cmd_index(
28662            dir.path(),
28663            false,
28664            false,
28665            false,
28666            false,
28667            false,
28668            true,
28669            None,
28670            false,
28671            false,
28672            false,
28673            false,
28674            false,
28675            false,
28676        )
28677        .unwrap();
28678        let nested = dir.path().join("src/alpha/nested");
28679        std::fs::create_dir_all(&nested).unwrap();
28680
28681        let result = cmd_search(
28682            "alpha_helper".to_string(),
28683            Some(nested),
28684            5,
28685            Some("lexical".to_string()),
28686            None,
28687            false,
28688            false,
28689            false,
28690            0,
28691            false,
28692            false,
28693            false,
28694            false,
28695            false,
28696            false,
28697            false,
28698        );
28699
28700        assert!(result.is_ok());
28701    }
28702
28703    #[test]
28704    fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
28705        let dir = setup_workspace_with_duplicate_leaf_names();
28706        cmd_index(
28707            dir.path(),
28708            false,
28709            false,
28710            false,
28711            false,
28712            false,
28713            true,
28714            None,
28715            false,
28716            false,
28717            false,
28718            false,
28719            false,
28720            false,
28721        )
28722        .unwrap();
28723        let nested = dir.path().join("vendor/foo/nested");
28724        std::fs::create_dir_all(&nested).unwrap();
28725
28726        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28727        let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
28728        let cfg = config::Config::load(dir.path()).unwrap();
28729
28730        assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
28731    }
28732
28733    #[test]
28734    fn graph_cmd_succeeds_while_writer_lock_is_held() {
28735        let dir = setup_graph_index();
28736        let db_path = dir.path().join(".tsift/index.db");
28737        let _lock = hold_write_lock(&db_path);
28738
28739        let result = cmd_graph(
28740            "main",
28741            dir.path(),
28742            false,
28743            false,
28744            None,
28745            20,
28746            false,
28747            true,
28748            false,
28749            false,
28750            false,
28751            false,
28752            false,
28753            TagpathSearchOpts::default(),
28754        );
28755
28756        assert!(result.is_ok());
28757    }
28758
28759    #[test]
28760    fn graph_cmd_autoindexes_stale_index_by_default() {
28761        let dir = setup_graph_index();
28762        std::thread::sleep(std::time::Duration::from_millis(50));
28763        std::fs::write(
28764            dir.path().join("main.rs"),
28765            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28766        )
28767        .unwrap();
28768
28769        let result = cmd_graph(
28770            "helper",
28771            dir.path(),
28772            true,
28773            false,
28774            None,
28775            20,
28776            false,
28777            true,
28778            false,
28779            false,
28780            false,
28781            false,
28782            false,
28783            TagpathSearchOpts::default(),
28784        );
28785
28786        assert!(result.is_ok());
28787        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
28788        let summary = db.compute_changes(dir.path()).unwrap();
28789        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28790    }
28791
28792    #[test]
28793    fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28794        let dir = setup_graph_index();
28795        let db_path = dir.path().join(".tsift/index.db");
28796        let _lock = hold_rollback_journal_lock(&db_path);
28797
28798        let result = cmd_graph(
28799            "main",
28800            dir.path(),
28801            false,
28802            false,
28803            None,
28804            20,
28805            false,
28806            true,
28807            false,
28808            false,
28809            false,
28810            false,
28811            false,
28812            TagpathSearchOpts::default(),
28813        );
28814
28815        assert!(result.is_ok());
28816    }
28817
28818    #[test]
28819    fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
28820        let dir = setup_graph_index();
28821        let nested = dir.path().join("src/nested");
28822        std::fs::create_dir_all(&nested).unwrap();
28823
28824        let result = cmd_graph(
28825            "helper",
28826            &nested,
28827            true,
28828            false,
28829            None,
28830            20,
28831            false,
28832            false,
28833            false,
28834            false,
28835            false,
28836            false,
28837            false,
28838            TagpathSearchOpts::default(),
28839        );
28840
28841        assert!(result.is_ok());
28842    }
28843
28844    #[test]
28845    fn communities_cmd_succeeds_while_writer_lock_is_held() {
28846        let dir = setup_graph_index();
28847        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
28848
28849        let result = cmd_communities(
28850            dir.path(),
28851            None,
28852            1,
28853            10,
28854            false,
28855            false,
28856            false,
28857            false,
28858            false,
28859            false,
28860            TagpathSearchOpts::default(),
28861        );
28862
28863        assert!(result.is_ok());
28864    }
28865
28866    #[test]
28867    fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28868        let dir = setup_graph_index();
28869        let db_path = dir.path().join(".tsift/index.db");
28870        let _lock = hold_rollback_journal_lock(&db_path);
28871
28872        let result = cmd_communities(
28873            dir.path(),
28874            None,
28875            1,
28876            10,
28877            false,
28878            false,
28879            false,
28880            false,
28881            false,
28882            false,
28883            TagpathSearchOpts::default(),
28884        );
28885
28886        assert!(result.is_ok());
28887    }
28888
28889    #[test]
28890    fn lint_finds_entities_from_project_root_index_db() {
28891        let dir = tempfile::tempdir().unwrap();
28892        std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
28893        std::fs::write(
28894            dir.path().join("README.md"),
28895            "alpha_helper should be backticked.\n",
28896        )
28897        .unwrap();
28898        cmd_index(
28899            dir.path(),
28900            false,
28901            false,
28902            false,
28903            false,
28904            false,
28905            false,
28906            None,
28907            false,
28908            false,
28909            false,
28910            false,
28911            false,
28912            false,
28913        )
28914        .unwrap();
28915
28916        let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
28917            .unwrap()
28918            .unwrap();
28919        let entities = lint::collect_entities_from_index_path(&root).unwrap();
28920        let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
28921
28922        assert!(
28923            result
28924                .annotations
28925                .iter()
28926                .any(|ann| ann.text == "alpha_helper")
28927        );
28928    }
28929
28930    // --- search timeout ---
28931
28932    #[test]
28933    fn search_direct_runs_ok() {
28934        let dir = tempfile::tempdir().unwrap();
28935        let search_dir = dir.path().to_path_buf();
28936        let cache_dir = search_dir.join(".tsift/search-cache");
28937        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28938        let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical", None);
28939        assert!(result.is_ok(), "direct search should succeed");
28940        assert!(
28941            cache_dir.exists(),
28942            "search should create the configured cache dir"
28943        );
28944    }
28945
28946    #[test]
28947    fn search_timeout_zero_disables_timeout() {
28948        let dir = tempfile::tempdir().unwrap();
28949        let search_dir = dir.path().to_path_buf();
28950        let cache_dir = search_dir.join(".tsift/search-cache");
28951        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28952        let result =
28953            run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[], None);
28954        assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
28955        assert!(
28956            cache_dir.exists(),
28957            "timeout=0 should keep using the stable search cache dir"
28958        );
28959    }
28960
28961    #[test]
28962    fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
28963        let dir = tempfile::tempdir().unwrap();
28964        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
28965        cmd_index(
28966            dir.path(),
28967            false,
28968            false,
28969            false,
28970            false,
28971            false,
28972            false,
28973            None,
28974            false,
28975            false,
28976            false,
28977            false,
28978            false,
28979            false,
28980        )
28981        .unwrap();
28982        let db_path = dir.path().join(".tsift/index.db");
28983        std::fs::remove_file(&db_path).unwrap();
28984        let search_target = SearchIndexTarget {
28985            label: "index".to_string(),
28986            db_path,
28987            source_root: dir.path().to_path_buf(),
28988            scope_name: None,
28989            reindex_cmd: format!("tsift index {}", dir.path().display()),
28990        };
28991
28992        let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
28993
28994        assert!(message.contains("timed out after 1s"));
28995        assert!(message.contains("index is missing"));
28996        assert!(message.contains("Run `tsift index"));
28997        assert!(!message.contains("search root looks fresh"));
28998    }
28999
29000    #[test]
29001    fn search_worker_output_path_uses_json_suffix() {
29002        let path = next_search_worker_output_path();
29003        assert!(path.extension().is_some_and(|ext| ext == "json"));
29004    }
29005
29006    #[test]
29007    fn fts_search_flag_value_parses_falsy_escape_hatch() {
29008        // #015t Phase 4: FTS5 is the default; only falsy values force legacy.
29009        for falsy in ["0", "false", "FALSE", " no ", "Off"] {
29010            assert!(
29011                fts_flag_value_disabled(falsy),
29012                "{falsy:?} should force the legacy TokenIndex path"
29013            );
29014        }
29015        for keeps_default in ["", "1", "true", "yes", "on", "maybe"] {
29016            assert!(
29017                !fts_flag_value_disabled(keeps_default),
29018                "{keeps_default:?} should keep the FTS5 default"
29019            );
29020        }
29021    }
29022
29023    #[test]
29024    fn run_sift_search_defaults_to_fts_when_index_db_present() {
29025        // #015t Phase 4 cutover: with the flag unset and a root index.db present,
29026        // run_sift_search uses the FTS5 path by default (strategy "fts").
29027        let dir = tempfile::tempdir().unwrap();
29028        let root = dir.path();
29029        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29030        index::IndexDb::open(&root.join(".tsift/index.db"))
29031            .unwrap()
29032            .apply_changes(root)
29033            .unwrap();
29034        let cache_dir = root.join(".tsift/search-cache");
29035
29036        // Guard against an ambient escape-hatch env from a parallel test/shell.
29037        if fts_search_forced_off() {
29038            return;
29039        }
29040        let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
29041        assert_eq!(response.strategy, "fts");
29042        assert!(response.hits.iter().any(|h| h.path.ends_with("alpha.rs")));
29043    }
29044
29045    #[test]
29046    fn run_sift_search_falls_back_to_lexical_without_index_db() {
29047        // No root index.db (e.g. un-indexed root reaching here directly): the
29048        // legacy TokenIndex/lexical path still serves the query.
29049        let dir = tempfile::tempdir().unwrap();
29050        let root = dir.path();
29051        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29052        let cache_dir = root.join(".tsift/search-cache");
29053
29054        let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
29055        assert_eq!(response.strategy, "lexical");
29056    }
29057
29058    #[test]
29059    fn run_sift_search_honors_threaded_freshness_verdict() {
29060        // #015t Phase 4b: the caller's freshness verdict overrides the in-engine
29061        // inspect. Some(true) ⇒ FTS without re-walking; Some(false) ⇒ legacy path
29062        // even with a fresh index.db (the degraded-read-only live-results case).
29063        let dir = tempfile::tempdir().unwrap();
29064        let root = dir.path();
29065        std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
29066        index::IndexDb::open(&root.join(".tsift/index.db"))
29067            .unwrap()
29068            .apply_changes(root)
29069            .unwrap();
29070        let cache_dir = root.join(".tsift/search-cache");
29071
29072        if fts_search_forced_off() {
29073            return;
29074        }
29075        let fresh =
29076            run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(true)).unwrap();
29077        assert_eq!(fresh.strategy, "fts");
29078
29079        let stale =
29080            run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(false)).unwrap();
29081        assert_eq!(stale.strategy, "lexical");
29082    }
29083
29084    // --- index quiet mode ---
29085
29086    #[test]
29087    fn index_quiet_suppresses_file_list() {
29088        let dir = setup_graph_index();
29089        let result = cmd_index(
29090            dir.path(),
29091            false,
29092            true,
29093            false,
29094            false,
29095            true,
29096            false,
29097            None,
29098            false,
29099            false,
29100            false,
29101            false,
29102            false,
29103            false,
29104        );
29105        assert!(result.is_ok());
29106    }
29107
29108    #[test]
29109    fn index_exit_code_implies_quiet() {
29110        let dir = setup_graph_index();
29111        let result = cmd_index(
29112            dir.path(),
29113            false,
29114            true,
29115            false,
29116            false,
29117            false,
29118            false,
29119            None,
29120            false,
29121            false,
29122            false,
29123            false,
29124            false,
29125            false,
29126        );
29127        assert!(result.is_ok());
29128    }
29129
29130    #[test]
29131    fn index_quiet_json_omits_changes() {
29132        let dir = setup_graph_index();
29133        let result = cmd_index(
29134            dir.path(),
29135            false,
29136            true,
29137            false,
29138            false,
29139            true,
29140            false,
29141            None,
29142            true,
29143            false,
29144            false,
29145            false,
29146            false,
29147            false,
29148        );
29149        assert!(result.is_ok());
29150    }
29151
29152    #[test]
29153    fn cli_workflow_defaults_to_search_topic() {
29154        let cli = parse_cli(["tsift", "workflow"]);
29155        match cli.command {
29156            Some(Commands::Workflow { topic, json }) => {
29157                assert_eq!(topic, "search");
29158                assert!(!json);
29159            }
29160            _ => panic!("expected Workflow command"),
29161        }
29162    }
29163
29164    #[test]
29165    fn search_workflow_recipe_preserves_handles_across_expansions() {
29166        let recipe = workflow::search_workflow_recipe();
29167        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29168        assert_eq!(
29169            step_names,
29170            vec![
29171                "exact-anchor",
29172                "semantic-search",
29173                "explain-symbol",
29174                "summarize-selection",
29175                "digest-expansion"
29176            ]
29177        );
29178        assert!(
29179            recipe
29180                .handle_contract
29181                .iter()
29182                .any(|item| item.contains("originating command"))
29183        );
29184        assert!(
29185            recipe.steps[1]
29186                .preserves
29187                .iter()
29188                .any(|item| item.contains("sfam-*"))
29189        );
29190        assert!(
29191            recipe.steps[2]
29192                .preserves
29193                .iter()
29194                .any(|item| item.contains("ecall-*"))
29195        );
29196        assert!(
29197            recipe.steps[4]
29198                .preserves
29199                .iter()
29200                .any(|item| item.contains("artifact handles"))
29201        );
29202    }
29203
29204    #[test]
29205    fn kg_workflow_recipe_covers_extract_to_evidence() {
29206        let recipe = workflow::kg_workflow_recipe();
29207        assert_eq!(recipe.topic, "kg");
29208        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29209        assert_eq!(
29210            step_names,
29211            vec!["smoke-check", "extract", "status", "refresh", "evidence"]
29212        );
29213        // evidence uses --symbol (not a positional) and has no --budget flag
29214        let evidence = recipe.steps.last().unwrap();
29215        assert!(evidence.command.contains("kg evidence --symbol"));
29216        assert!(!evidence.command.contains("--budget"));
29217        // extract is the write step; reads should not re-extract
29218        assert!(
29219            recipe
29220                .handle_contract
29221                .iter()
29222                .any(|item| item.contains("Extract once"))
29223        );
29224    }
29225
29226    // --- JSON compact vs pretty ---
29227
29228    #[test]
29229    fn to_json_compact_default() {
29230        let val = serde_json::json!({"a": 1, "b": [2, 3]});
29231        let compact = to_json(&val, false, false).unwrap();
29232        assert!(!compact.contains('\n'));
29233        assert!(
29234            compact.contains("\"a\":1")
29235                || compact.contains("\"a\": 1")
29236                || compact.contains("\"a\":")
29237        );
29238    }
29239
29240    #[test]
29241    fn to_json_pretty_indents() {
29242        let val = serde_json::json!({"a": 1, "b": [2, 3]});
29243        let pretty = to_json(&val, true, false).unwrap();
29244        assert!(pretty.contains('\n'));
29245        assert!(pretty.contains("  "));
29246    }
29247
29248    #[test]
29249    fn to_json_compact_is_shorter() {
29250        let val =
29251            serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
29252        let compact = to_json(&val, false, false).unwrap();
29253        let pretty = to_json(&val, true, false).unwrap();
29254        assert!(compact.len() < pretty.len());
29255    }
29256
29257    #[test]
29258    fn terse_renames_keys() {
29259        let val =
29260            serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
29261        let result = to_json(&val, false, true).unwrap();
29262        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29263        assert!(parsed["_s"].is_object());
29264        let d = &parsed["d"];
29265        assert_eq!(d["cf"], "a.rs");
29266        assert_eq!(d["cn"], "main");
29267        assert_eq!(d["csl"], 10);
29268    }
29269
29270    #[test]
29271    fn terse_schema_only_includes_used_keys() {
29272        let val = serde_json::json!({"name": "test", "score": 0.5});
29273        let result = to_json(&val, false, true).unwrap();
29274        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29275        let schema = parsed["_s"].as_object().unwrap();
29276        assert_eq!(schema["n"], "name");
29277        assert_eq!(schema["sc"], "score");
29278        assert!(!schema.contains_key("cf"));
29279    }
29280
29281    #[test]
29282    fn terse_nested_arrays() {
29283        let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
29284        let result = to_json(&val, false, true).unwrap();
29285        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29286        let d = &parsed["d"];
29287        assert_eq!(d["crs"][0]["cn"], "a");
29288        assert_eq!(d["crs"][0]["cf"], "b.rs");
29289    }
29290
29291    #[test]
29292    fn terse_preserves_unknown_keys() {
29293        let val = serde_json::json!({"custom_field": "value", "name": "test"});
29294        let result = to_json(&val, false, true).unwrap();
29295        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29296        let d = &parsed["d"];
29297        assert_eq!(d["custom_field"], "value");
29298        assert_eq!(d["n"], "test");
29299    }
29300
29301    // --- ultra-terse ---
29302
29303    #[test]
29304    fn ultra_terse_strips_properties_from_graph_nodes() {
29305        let val = serde_json::json!({
29306            "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
29307        });
29308        let result = to_json_schema(&val, false, true, true, false).unwrap();
29309        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29310        let node = &parsed["d"]["nodes"][0];
29311        assert_eq!(node["id"], "fn:main");
29312        assert_eq!(node["k"], "fn");
29313        assert_eq!(node["n"], "main");
29314        assert!(node.get("properties").is_none());
29315    }
29316
29317    #[test]
29318    fn ultra_terse_strips_properties_from_graph_edges() {
29319        let val = serde_json::json!({
29320            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
29321        });
29322        let result = to_json_schema(&val, false, true, true, false).unwrap();
29323        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29324        let edge = &parsed["d"]["edges"][0];
29325        assert_eq!(edge["from_id"], "a");
29326        assert_eq!(edge["to_id"], "b");
29327        assert_eq!(edge["k"], "c");
29328        assert!(edge.get("properties").is_none());
29329    }
29330
29331    #[test]
29332    fn ultra_terse_abbreviates_edge_kinds() {
29333        let val = serde_json::json!({
29334            "edges": [
29335                {"from_id": "a", "to_id": "b", "kind": "defines"},
29336                {"from_id": "a", "to_id": "c", "kind": "contains"},
29337                {"from_id": "a", "to_id": "d", "kind": "imports"},
29338                {"from_id": "a", "to_id": "e", "kind": "mentions"},
29339                {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
29340                {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
29341                {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
29342                {"from_id": "a", "to_id": "i", "kind": "uses"},
29343                {"from_id": "a", "to_id": "j", "kind": "parent"},
29344                {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
29345            ]
29346        });
29347        let result = to_json_schema(&val, false, true, true, false).unwrap();
29348        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29349        let edges = &parsed["d"]["edges"].as_array().unwrap();
29350        assert_eq!(edges[0]["k"], "d");
29351        assert_eq!(edges[1]["k"], "ct");
29352        assert_eq!(edges[2]["k"], "i");
29353        assert_eq!(edges[3]["k"], "m");
29354        assert_eq!(edges[4]["k"], "sr");
29355        assert_eq!(edges[5]["k"], "bt");
29356        assert_eq!(edges[6]["k"], "sctx");
29357        assert_eq!(edges[7]["k"], "u");
29358        assert_eq!(edges[8]["k"], "p");
29359        assert_eq!(edges[9]["k"], "unknown_edge");
29360    }
29361
29362    #[test]
29363    fn ultra_terse_strips_provenance_freshness_from_edges() {
29364        let val = serde_json::json!({
29365            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
29366        });
29367        let result = to_json_schema(&val, false, true, true, false).unwrap();
29368        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29369        let edge = &parsed["d"]["edges"][0];
29370        assert!(edge.get("provenance").is_none());
29371        assert!(edge.get("freshness").is_none());
29372        assert_eq!(edge["k"], "c");
29373    }
29374
29375    #[test]
29376    fn ultra_terse_truncates_snippets() {
29377        let long_snippet = "x".repeat(120);
29378        let val = serde_json::json!({"snippet": long_snippet});
29379        let result = to_json_schema(&val, false, true, true, false).unwrap();
29380        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29381        let snipped = parsed["d"]["sn"].as_str().unwrap();
29382        assert_eq!(snipped.len(), 80);
29383        assert!(snipped.ends_with("..."));
29384    }
29385
29386    #[test]
29387    fn ultra_terse_truncates_abbreviated_snippet_key() {
29388        let long_snippet = "y".repeat(100);
29389        let val = serde_json::json!({"snippet": long_snippet});
29390        let result = to_json_schema(&val, false, true, true, false).unwrap();
29391        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29392        let snipped = parsed["d"]["sn"].as_str().unwrap();
29393        assert_eq!(snipped.len(), 80);
29394        assert!(snipped.ends_with("..."));
29395    }
29396
29397    #[test]
29398    fn ultra_terse_compacts_coverage_snapshot() {
29399        let val = serde_json::json!({
29400            "mode": "incremental",
29401            "total_sector_count": 10,
29402            "dirty_sector_count": 2,
29403            "active_rebuild": Some("rebuild-1"),
29404            "completed_dirty_sector_count": 1,
29405            "mounted_sector_count": 8,
29406            "rebuilding_sector_count": 1,
29407            "resumed_sector_count": 3,
29408            "reused_sector_count": 5
29409        });
29410        let result = to_json_schema(&val, false, true, true, false).unwrap();
29411        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29412        let d = &parsed["d"];
29413        assert_eq!(d["mode"], "incremental");
29414        assert_eq!(d["total_sector_count"], 10);
29415        assert_eq!(d["dirty_sector_count"], 2);
29416        assert!(d.get("active_rebuild").is_none());
29417        assert!(d.get("completed_dirty_sector_count").is_none());
29418        assert!(d.get("mounted_sector_count").is_none());
29419        assert!(d.get("rebuilding_sector_count").is_none());
29420        assert!(d.get("resumed_sector_count").is_none());
29421        assert!(d.get("reused_sector_count").is_none());
29422    }
29423
29424    #[test]
29425    fn ultra_terse_short_snippet_unchanged() {
29426        let val = serde_json::json!({"snippet": "short text"});
29427        let result = to_json_schema(&val, false, true, true, false).unwrap();
29428        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29429        assert_eq!(parsed["d"]["sn"], "short text");
29430    }
29431
29432    #[test]
29433    fn ultra_terse_non_graph_object_properties_preserved() {
29434        let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
29435        let result = to_json_schema(&val, false, true, true, false).unwrap();
29436        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29437        assert!(parsed["d"]["config"]["properties"].is_object());
29438    }
29439
29440    // --- schema-then-values ---
29441
29442    #[test]
29443    fn schema_converts_homogeneous_arrays() {
29444        let val = serde_json::json!({"symbols": [
29445            {"name": "foo", "kind": "fn", "line": 10},
29446            {"name": "bar", "kind": "fn", "line": 20}
29447        ]});
29448        let result = to_json_schema(&val, false, false, false, true).unwrap();
29449        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29450        let syms = &parsed["symbols"];
29451        let columns = syms["_c"]
29452            .as_array()
29453            .unwrap()
29454            .iter()
29455            .map(|value| value.as_str().unwrap())
29456            .collect::<Vec<_>>();
29457        let row0 = syms["_r"][0].as_array().unwrap();
29458        let row1 = syms["_r"][1].as_array().unwrap();
29459        let name_index = columns.iter().position(|column| *column == "name").unwrap();
29460        let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
29461        let line_index = columns.iter().position(|column| *column == "line").unwrap();
29462        assert_eq!(row0[name_index], "foo");
29463        assert_eq!(row0[kind_index], "fn");
29464        assert_eq!(row0[line_index], 10);
29465        assert_eq!(row1[name_index], "bar");
29466        assert_eq!(row1[kind_index], "fn");
29467        assert_eq!(row1[line_index], 20);
29468    }
29469
29470    #[test]
29471    fn schema_skips_short_arrays() {
29472        let val = serde_json::json!({"items": [{"name": "only"}]});
29473        let result = to_json_schema(&val, false, false, false, true).unwrap();
29474        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29475        assert!(parsed["items"].is_array());
29476        assert_eq!(parsed["items"][0]["name"], "only");
29477    }
29478
29479    #[test]
29480    fn schema_skips_heterogeneous_arrays() {
29481        let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
29482        let result = to_json_schema(&val, false, false, false, true).unwrap();
29483        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29484        assert!(parsed["items"].is_array());
29485        assert_eq!(parsed["items"][0]["a"], 1);
29486    }
29487
29488    #[test]
29489    fn schema_with_terse_combines() {
29490        let val = serde_json::json!({"callers": [
29491            {"caller_name": "a", "caller_file": "x.rs"},
29492            {"caller_name": "b", "caller_file": "y.rs"}
29493        ]});
29494        let result = to_json_schema(&val, false, true, false, true).unwrap();
29495        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29496        assert!(parsed["_s"].is_object());
29497        let d = &parsed["d"];
29498        let crs = &d["crs"];
29499        assert!(crs["_c"].is_array());
29500        assert!(crs["_r"].is_array());
29501        let columns = crs["_c"]
29502            .as_array()
29503            .unwrap()
29504            .iter()
29505            .map(|value| value.as_str().unwrap())
29506            .collect::<Vec<_>>();
29507        let row = crs["_r"][0].as_array().unwrap();
29508        let name_index = columns.iter().position(|column| *column == "cn").unwrap();
29509        let file_index = columns.iter().position(|column| *column == "cf").unwrap();
29510        assert_eq!(row[name_index], "a");
29511        assert_eq!(row[file_index], "x.rs");
29512    }
29513
29514    #[test]
29515    fn schema_preserves_non_object_arrays() {
29516        let val = serde_json::json!({"tags": ["a", "b", "c"]});
29517        let result = to_json_schema(&val, false, false, false, true).unwrap();
29518        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29519        assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
29520    }
29521
29522    #[test]
29523    fn cli_accepts_global_schema_flag() {
29524        let cli = parse_cli(["tsift", "--schema", "search", "test"]);
29525        assert!(cli.schema);
29526        assert!(matches!(cli.command, Some(Commands::Search { .. })));
29527    }
29528
29529    #[test]
29530    fn cli_accepts_global_envelope_flag() {
29531        let cli = parse_cli([
29532            "tsift",
29533            "--envelope",
29534            "context-pack",
29535            "tasks/software/tsift.md",
29536        ]);
29537        assert!(cli.envelope);
29538        assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
29539    }
29540
29541    #[test]
29542    fn cli_accepts_locks_command() {
29543        let cli = parse_cli(["tsift", "locks"]);
29544        assert!(matches!(cli.command, Some(Commands::Locks { .. })));
29545    }
29546
29547    #[test]
29548    fn cli_parses_memory_budget_guard_command() {
29549        let cli = parse_cli([
29550            "tsift",
29551            "memory",
29552            "budget-guard",
29553            "--file",
29554            "tool.log",
29555            "--budget-tokens",
29556            "1000",
29557            "--json",
29558        ]);
29559        match cli.command {
29560            Some(Commands::Memory {
29561                command:
29562                    crate::cli::MemoryCommand::BudgetGuard {
29563                        file,
29564                        budget_tokens,
29565                        json,
29566                        ..
29567                    },
29568            }) => {
29569                assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
29570                assert_eq!(budget_tokens, 1000);
29571                assert!(json);
29572            }
29573            _ => panic!("expected memory budget-guard command"),
29574        }
29575    }
29576
29577    #[test]
29578    fn cli_parses_memory_capture_agent_doc_closeout_command() {
29579        let cli = parse_cli([
29580            "tsift",
29581            "memory",
29582            "capture-agent-doc-closeout",
29583            ".",
29584            "--session-path",
29585            "tasks/software/tsift.md",
29586            "--prompt-target",
29587            "do [#tsiftmemhooks]",
29588            "--response-summary",
29589            "wired closeout capture",
29590            "--commit-hash",
29591            "abc123",
29592            "--session-check-status",
29593            "clean",
29594            "--json",
29595        ]);
29596        match cli.command {
29597            Some(Commands::Memory {
29598                command:
29599                    crate::cli::MemoryCommand::CaptureAgentDocCloseout {
29600                        path,
29601                        session_path,
29602                        prompt_target,
29603                        response_summary,
29604                        commit_hash,
29605                        session_check_status,
29606                        json,
29607                    },
29608            }) => {
29609                assert_eq!(path, std::path::PathBuf::from("."));
29610                assert_eq!(
29611                    session_path,
29612                    std::path::PathBuf::from("tasks/software/tsift.md")
29613                );
29614                assert_eq!(prompt_target, "do [#tsiftmemhooks]");
29615                assert_eq!(response_summary, "wired closeout capture");
29616                assert_eq!(commit_hash.as_deref(), Some("abc123"));
29617                assert_eq!(session_check_status, "clean");
29618                assert!(json);
29619            }
29620            _ => panic!("expected memory capture-agent-doc-closeout command"),
29621        }
29622    }
29623
29624    #[test]
29625    fn cli_parses_memory_project_graph_read_policy() {
29626        let cli = parse_cli([
29627            "tsift",
29628            "memory",
29629            "project-graph",
29630            ".",
29631            "--read-policy",
29632            "query-relevant",
29633            "--query",
29634            "semantic memory",
29635            "--limit",
29636            "7",
29637            "--json",
29638        ]);
29639        match cli.command {
29640            Some(Commands::Memory {
29641                command:
29642                    crate::cli::MemoryCommand::ProjectGraph {
29643                        read_policy,
29644                        query,
29645                        limit,
29646                        json,
29647                        ..
29648                    },
29649            }) => {
29650                assert_eq!(
29651                    read_policy,
29652                    crate::cli::MemoryProjectReadPolicy::QueryRelevant
29653                );
29654                assert_eq!(query.as_deref(), Some("semantic memory"));
29655                assert_eq!(limit, 7);
29656                assert!(json);
29657            }
29658            _ => panic!("expected memory project-graph command"),
29659        }
29660    }
29661
29662    #[test]
29663    fn cli_locks_accepts_scope_flag() {
29664        let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
29665        match cli.command {
29666            Some(Commands::Locks { scope, .. }) => {
29667                assert_eq!(scope.as_deref(), Some("alpha"));
29668            }
29669            _ => panic!("expected Locks command"),
29670        }
29671    }
29672
29673    #[test]
29674    fn cli_search_accepts_autoindex_flag() {
29675        let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
29676        match cli.command {
29677            Some(Commands::Search {
29678                autoindex,
29679                no_autoindex,
29680                ..
29681            }) => {
29682                assert!(autoindex);
29683                assert!(!no_autoindex);
29684            }
29685            _ => panic!("expected Search command"),
29686        }
29687    }
29688
29689    #[test]
29690    fn cli_search_accepts_exact_flag() {
29691        let cli = parse_cli(["tsift", "search", "test", "--exact"]);
29692        match cli.command {
29693            Some(Commands::Search {
29694                exact, strategy, ..
29695            }) => {
29696                assert!(exact);
29697                assert!(strategy.is_none());
29698            }
29699            _ => panic!("expected Search command"),
29700        }
29701    }
29702
29703    #[test]
29704    fn cli_parses_diff_digest_command() {
29705        let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
29706        match cli.command {
29707            Some(Commands::DiffDigest {
29708                json,
29709                path,
29710                cached,
29711                revision,
29712                max_parsed_files,
29713            }) => {
29714                assert!(json);
29715                assert_eq!(path, PathBuf::from("."));
29716                assert!(!cached);
29717                assert!(revision.is_none());
29718                assert_eq!(max_parsed_files, 25);
29719            }
29720            _ => panic!("expected DiffDigest command"),
29721        }
29722    }
29723
29724    #[test]
29725    fn cli_rejects_conflicting_diff_digest_modes() {
29726        match try_parse_cli([
29727            "tsift",
29728            "diff-digest",
29729            "--cached",
29730            "--revision",
29731            "HEAD",
29732            ".",
29733        ]) {
29734            Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
29735            Err(err) => {
29736                assert!(err.to_string().contains("--cached"));
29737                assert!(err.to_string().contains("--revision"));
29738            }
29739        }
29740    }
29741
29742    #[test]
29743    fn cli_parses_test_digest_command() {
29744        let cli = parse_cli([
29745            "tsift",
29746            "test-digest",
29747            "--path",
29748            ".",
29749            "--input",
29750            "target/test.log",
29751            "--runner",
29752            "cargo",
29753            "--json",
29754        ]);
29755        match cli.command {
29756            Some(Commands::TestDigest {
29757                json,
29758                path,
29759                input,
29760                runner,
29761            }) => {
29762                assert!(json);
29763                assert_eq!(path, PathBuf::from("."));
29764                assert_eq!(input, Some(PathBuf::from("target/test.log")));
29765                assert_eq!(runner.as_deref(), Some("cargo"));
29766            }
29767            _ => panic!("expected TestDigest command"),
29768        }
29769    }
29770
29771    #[test]
29772    fn cli_parses_log_digest_command() {
29773        let cli = parse_cli([
29774            "tsift",
29775            "log-digest",
29776            "--path",
29777            ".",
29778            "--input",
29779            "target/build.log",
29780            "--json",
29781        ]);
29782        match cli.command {
29783            Some(Commands::LogDigest {
29784                json,
29785                path,
29786                input,
29787                fixture,
29788                fail_under,
29789            }) => {
29790                assert!(json);
29791                assert_eq!(path, PathBuf::from("."));
29792                assert_eq!(input, Some(PathBuf::from("target/build.log")));
29793                assert!(fixture.is_none());
29794                assert!(!fail_under);
29795            }
29796            _ => panic!("expected LogDigest command"),
29797        }
29798    }
29799
29800    #[test]
29801    fn cli_parses_metric_digest_command() {
29802        let cli = parse_cli([
29803            "tsift",
29804            "metric-digest",
29805            "--input",
29806            "target/runs.json",
29807            "--baseline",
29808            "target/prior.json",
29809            "--metric",
29810            "session_mae",
29811            "--lower-is-better",
29812            "session_mae",
29813            "--history",
29814            "4",
29815            "--top",
29816            "2",
29817            "--json",
29818        ]);
29819        match cli.command {
29820            Some(Commands::MetricDigest {
29821                input,
29822                baseline,
29823                metrics,
29824                lower_is_better,
29825                history,
29826                top,
29827                json,
29828                ..
29829            }) => {
29830                assert!(json);
29831                assert_eq!(input, Some(PathBuf::from("target/runs.json")));
29832                assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
29833                assert_eq!(metrics, vec!["session_mae"]);
29834                assert_eq!(lower_is_better, vec!["session_mae"]);
29835                assert_eq!(history, 4);
29836                assert_eq!(top, 2);
29837            }
29838            _ => panic!("expected MetricDigest command"),
29839        }
29840    }
29841
29842    #[test]
29843    fn cli_parses_dci_benchmark_command() {
29844        let cli = parse_cli([
29845            "tsift",
29846            "dci-benchmark",
29847            "--fixture",
29848            "fixtures/dci-search-benchmark.json",
29849            "--json",
29850        ]);
29851        match cli.command {
29852            Some(Commands::DciBenchmark { fixture, json }) => {
29853                assert!(json);
29854                assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
29855            }
29856            _ => panic!("expected DciBenchmark command"),
29857        }
29858    }
29859
29860    #[test]
29861    fn cli_parses_session_digest_command() {
29862        let cli = parse_cli([
29863            "tsift",
29864            "session-digest",
29865            "--path",
29866            ".",
29867            "--input",
29868            "target/session.md",
29869            "--source",
29870            "markdown",
29871            "--json",
29872        ]);
29873        match cli.command {
29874            Some(Commands::SessionDigest {
29875                json,
29876                path,
29877                input,
29878                source,
29879            }) => {
29880                assert!(json);
29881                assert_eq!(path, PathBuf::from("."));
29882                assert_eq!(input, Some(PathBuf::from("target/session.md")));
29883                assert_eq!(source.as_deref(), Some("markdown"));
29884            }
29885            _ => panic!("expected SessionDigest command"),
29886        }
29887    }
29888
29889    #[test]
29890    fn cli_parses_session_cost_command() {
29891        let cli = parse_cli([
29892            "tsift",
29893            "session-cost",
29894            "--input",
29895            "target/session.jsonl",
29896            "--source",
29897            "codex-jsonl",
29898            "--json",
29899        ]);
29900        match cli.command {
29901            Some(Commands::SessionCost {
29902                json,
29903                input,
29904                fixture,
29905                fail_under,
29906                source,
29907            }) => {
29908                assert!(json);
29909                assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
29910                assert_eq!(fixture, None);
29911                assert!(!fail_under);
29912                assert_eq!(source.as_deref(), Some("codex-jsonl"));
29913            }
29914            _ => panic!("expected SessionCost command"),
29915        }
29916
29917        let cli = parse_cli([
29918            "tsift",
29919            "session-cost",
29920            "--fixture",
29921            "fixtures/real-session-prompt-cache-effectiveness.json",
29922            "--fail-under",
29923            "--json",
29924        ]);
29925        match cli.command {
29926            Some(Commands::SessionCost {
29927                json,
29928                input,
29929                fixture,
29930                fail_under,
29931                source,
29932            }) => {
29933                assert!(json);
29934                assert_eq!(input, None);
29935                assert_eq!(
29936                    fixture,
29937                    Some(PathBuf::from(
29938                        "fixtures/real-session-prompt-cache-effectiveness.json"
29939                    ))
29940                );
29941                assert!(fail_under);
29942                assert_eq!(source, None);
29943            }
29944            _ => panic!("expected SessionCost command"),
29945        }
29946    }
29947
29948    #[test]
29949    fn cli_parses_session_review_command() {
29950        let cli = parse_cli([
29951            "tsift",
29952            "session-review",
29953            "tasks/software/tsift.md",
29954            "--next-context",
29955            "--json",
29956        ]);
29957        match cli.command {
29958            Some(Commands::SessionReview {
29959                json,
29960                next_context,
29961                path,
29962                ..
29963            }) => {
29964                assert!(json);
29965                assert!(next_context);
29966                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29967            }
29968            _ => panic!("expected SessionReview command"),
29969        }
29970    }
29971
29972    #[test]
29973    fn cli_search_accepts_budget_flags() {
29974        let cli = parse_cli([
29975            "tsift",
29976            "search",
29977            "alpha_helper",
29978            "--max-items",
29979            "3",
29980            "--max-bytes",
29981            "96",
29982        ]);
29983        match cli.command {
29984            Some(Commands::Search {
29985                max_items,
29986                max_bytes,
29987                ..
29988            }) => {
29989                assert_eq!(max_items, Some(3));
29990                assert_eq!(max_bytes, Some(96));
29991            }
29992            _ => panic!("expected Search command"),
29993        }
29994    }
29995
29996    #[test]
29997    fn cli_search_accepts_budget_preset() {
29998        let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
29999        match cli.command {
30000            Some(Commands::Search { budget, .. }) => {
30001                assert_eq!(budget, Some(ResponseBudgetPreset::Small));
30002            }
30003            _ => panic!("expected Search command"),
30004        }
30005    }
30006
30007    #[test]
30008    fn cli_search_accepts_ast_facet_filters() {
30009        let cli = parse_cli([
30010            "tsift",
30011            "search",
30012            "setup",
30013            "--lang",
30014            "markdown",
30015            "--kind",
30016            "list_item",
30017            "--node-kind",
30018            "list_item",
30019            "--section",
30020            "Install",
30021            "--parent",
30022            "Run setup.",
30023            "--child",
30024            "Confirm setup.",
30025            "--fence-language",
30026            "rust",
30027            "--list-depth",
30028            "1",
30029            "--heading-level",
30030            "2",
30031        ]);
30032        match cli.command {
30033            Some(Commands::Search {
30034                lang,
30035                kind,
30036                node_kind,
30037                section,
30038                parent,
30039                child,
30040                fence_language,
30041                list_depth,
30042                heading_level,
30043                ..
30044            }) => {
30045                assert_eq!(lang, vec!["markdown"]);
30046                assert_eq!(kind, vec!["list_item"]);
30047                assert_eq!(node_kind, vec!["list_item"]);
30048                assert_eq!(section, vec!["Install"]);
30049                assert_eq!(parent, vec!["Run setup."]);
30050                assert_eq!(child, vec!["Confirm setup."]);
30051                assert_eq!(fence_language, vec!["rust"]);
30052                assert_eq!(list_depth, vec![1]);
30053                assert_eq!(heading_level, vec![2]);
30054            }
30055            _ => panic!("expected Search command"),
30056        }
30057    }
30058
30059    #[test]
30060    fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
30061        let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
30062        assert_eq!(small.preview_items(), 3);
30063        assert_eq!(small.preview_bytes(), 120);
30064        assert_eq!(small.follow_up_items(), 4);
30065
30066        let overridden =
30067            ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
30068        assert_eq!(overridden.preview_items(), 7);
30069        assert_eq!(overridden.preview_bytes(), 120);
30070        assert_eq!(overridden.follow_up_items(), 7);
30071
30072        let envelope_default = ResponseBudget::from_cli(None, None, None, true);
30073        assert!(envelope_default.is_active());
30074    }
30075
30076    #[test]
30077    fn cli_explain_accepts_budget_flags() {
30078        let cli = parse_cli([
30079            "tsift",
30080            "explain",
30081            "alpha_helper",
30082            "--max-items",
30083            "2",
30084            "--max-bytes",
30085            "80",
30086        ]);
30087        match cli.command {
30088            Some(Commands::Explain {
30089                max_items,
30090                max_bytes,
30091                ..
30092            }) => {
30093                assert_eq!(max_items, Some(2));
30094                assert_eq!(max_bytes, Some(80));
30095            }
30096            _ => panic!("expected Explain command"),
30097        }
30098    }
30099
30100    #[test]
30101    fn cli_session_review_accepts_budget_flags() {
30102        let cli = parse_cli([
30103            "tsift",
30104            "session-review",
30105            "tasks/software/tsift.md",
30106            "--max-items",
30107            "4",
30108            "--max-bytes",
30109            "120",
30110        ]);
30111        match cli.command {
30112            Some(Commands::SessionReview {
30113                max_items,
30114                max_bytes,
30115                ..
30116            }) => {
30117                assert_eq!(max_items, Some(4));
30118                assert_eq!(max_bytes, Some(120));
30119            }
30120            _ => panic!("expected SessionReview command"),
30121        }
30122    }
30123
30124    #[test]
30125    fn cli_parses_context_pack_command() {
30126        let cli = parse_cli([
30127            "tsift",
30128            "context-pack",
30129            "tasks/software/tsift.md",
30130            "--test-input",
30131            "target/test.log",
30132            "--runner",
30133            "cargo",
30134            "--log-input",
30135            "target/build.log",
30136            "--max-items",
30137            "3",
30138            "--max-bytes",
30139            "96",
30140            "--json",
30141        ]);
30142        match cli.command {
30143            Some(Commands::ContextPack {
30144                path,
30145                test_input,
30146                runner,
30147                log_input,
30148                json,
30149                max_items,
30150                max_bytes,
30151                budget,
30152                convex_snapshot,
30153            }) => {
30154                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
30155                assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
30156                assert_eq!(runner.as_deref(), Some("cargo"));
30157                assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
30158                assert!(json);
30159                assert_eq!(max_items, Some(3));
30160                assert_eq!(max_bytes, Some(96));
30161                assert!(budget.is_none());
30162                assert!(convex_snapshot.is_none());
30163            }
30164            _ => panic!("expected ContextPack command"),
30165        }
30166    }
30167
30168    #[test]
30169    fn cli_parses_token_savings_command() {
30170        let cli = parse_cli([
30171            "tsift",
30172            "token-savings",
30173            "--fixture",
30174            "fixtures/tsift-token-savings.json",
30175            "--fail-under",
30176            "--json",
30177        ]);
30178        match cli.command {
30179            Some(Commands::TokenSavings {
30180                fixture,
30181                fail_under,
30182                json,
30183            }) => {
30184                assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
30185                assert!(fail_under);
30186                assert!(json);
30187            }
30188            _ => panic!("expected TokenSavings command"),
30189        }
30190    }
30191
30192    #[test]
30193    fn token_savings_report_records_fixture_thresholds() {
30194        let raw_symbols = [
30195            "validate_user",
30196            "validateUser",
30197            "ValidateUser",
30198            "validate-user",
30199            "VALIDATE_USER",
30200            "Validate_User",
30201            "raw_symbol",
30202            "rawSymbol",
30203            "RawSymbol",
30204            "raw-symbol",
30205            "RAW_SYMBOL",
30206            "Raw_Symbol",
30207        ]
30208        .iter()
30209        .enumerate()
30210        .map(|(idx, identifier)| TokenSavingsRawSymbol {
30211            identifier: (*identifier).to_string(),
30212            file: format!("src/example_{idx}.rs"),
30213            line: (idx + 1) as u64,
30214            context: "function".to_string(),
30215        })
30216        .collect();
30217        let fixture = TokenSavingsFixture {
30218            schema_version: 1,
30219            description: "fixture".to_string(),
30220            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30221            cases: vec![TokenSavingsFixtureCase {
30222                name: "search-preview".to_string(),
30223                surface: "search".to_string(),
30224                minimum_savings_percent: 40.0,
30225                raw_symbols,
30226                tagpath_families: vec![
30227                    TokenSavingsFamily {
30228                        canonical: "validate_user".to_string(),
30229                        count: 6,
30230                        aliases: BTreeMap::new(),
30231                    },
30232                    TokenSavingsFamily {
30233                        canonical: "raw_symbol".to_string(),
30234                        count: 6,
30235                        aliases: BTreeMap::new(),
30236                    },
30237                ],
30238                context_pack_inputs: None,
30239                session_review_inputs: None,
30240                source_read_inputs: None,
30241                markdown_projection_inputs: None,
30242            }],
30243        };
30244
30245        let report = build_token_savings_report(&fixture).unwrap();
30246
30247        assert!(report.pass);
30248        assert_eq!(report.cases[0].raw_symbol_count, 12);
30249        assert_eq!(report.cases[0].family_count, 2);
30250        assert_eq!(report.cases[0].status, "pass");
30251        assert!(report.cases[0].byte_delta > 0);
30252        assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
30253        assert!(report.cases[0].savings_percent >= 40.0);
30254    }
30255
30256    #[test]
30257    fn token_savings_source_read_inputs_preserve_required_anchors() {
30258        let fixture = TokenSavingsFixture {
30259            schema_version: 1,
30260            description: "fixture".to_string(),
30261            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30262            cases: vec![TokenSavingsFixtureCase {
30263                name: "source-read".to_string(),
30264                surface: "source-read".to_string(),
30265                minimum_savings_percent: 40.0,
30266                raw_symbols: Vec::new(),
30267                tagpath_families: Vec::new(),
30268                context_pack_inputs: None,
30269                session_review_inputs: None,
30270                source_read_inputs: Some(TokenSavingsSourceReadInputs {
30271                    reads: vec![TokenSavingsSourceReadInput {
30272                        command: "sed -n '40,160p' src/main.rs".to_string(),
30273                        file: "src/main.rs".to_string(),
30274                        raw_start: 40,
30275                        raw_lines: 121,
30276                        raw_excerpt: "line 40\n".repeat(121),
30277                        envelope_start: 40,
30278                        envelope_lines: 121,
30279                        required_line_anchors: vec![40, 120, 160],
30280                    }],
30281                }),
30282                markdown_projection_inputs: None,
30283            }],
30284        };
30285
30286        let report = build_token_savings_report(&fixture).unwrap();
30287
30288        assert!(report.pass);
30289        assert_eq!(report.cases[0].surface, "source-read");
30290        assert!(report.cases[0].savings_percent >= 40.0);
30291    }
30292
30293    #[test]
30294    fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
30295        let fixture = TokenSavingsFixture {
30296            schema_version: 1,
30297            description: "fixture".to_string(),
30298            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30299            cases: vec![TokenSavingsFixtureCase {
30300                name: "source-read".to_string(),
30301                surface: "source-read".to_string(),
30302                minimum_savings_percent: 40.0,
30303                raw_symbols: Vec::new(),
30304                tagpath_families: Vec::new(),
30305                context_pack_inputs: None,
30306                session_review_inputs: None,
30307                source_read_inputs: Some(TokenSavingsSourceReadInputs {
30308                    reads: vec![TokenSavingsSourceReadInput {
30309                        command: "cat src/main.rs".to_string(),
30310                        file: "src/main.rs".to_string(),
30311                        raw_start: 1,
30312                        raw_lines: 200,
30313                        raw_excerpt: "line\n".repeat(200),
30314                        envelope_start: 1,
30315                        envelope_lines: 80,
30316                        required_line_anchors: vec![120],
30317                    }],
30318                }),
30319                markdown_projection_inputs: None,
30320            }],
30321        };
30322
30323        let err = match build_token_savings_report(&fixture) {
30324            Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
30325            Err(err) => err,
30326        };
30327
30328        assert!(err.to_string().contains("hides required line anchor 120"));
30329    }
30330
30331    #[test]
30332    fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
30333        let fixture = TokenSavingsFixture {
30334            schema_version: 1,
30335            description: "fixture".to_string(),
30336            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30337            cases: vec![TokenSavingsFixtureCase {
30338                name: "markdown-projection".to_string(),
30339                surface: "context-pack".to_string(),
30340                minimum_savings_percent: 40.0,
30341                raw_symbols: Vec::new(),
30342                tagpath_families: Vec::new(),
30343                context_pack_inputs: None,
30344                session_review_inputs: None,
30345                source_read_inputs: None,
30346                markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
30347                    documents: vec![TokenSavingsMarkdownProjectionInput {
30348                        command: "context-pack markdown body".to_string(),
30349                        file: "tasks/software/tsift.md".to_string(),
30350                        raw_markdown: "# Heading\n\n".repeat(120),
30351                        outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
30352                        selected_nodes: vec!["mdast-selected".to_string()],
30353                        expand:
30354                            "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
30355                                .to_string(),
30356                    }],
30357                }),
30358            }],
30359        };
30360
30361        let report = build_token_savings_report(&fixture).unwrap();
30362
30363        assert!(report.pass);
30364        assert_eq!(report.cases[0].surface, "context-pack");
30365        assert!(report.cases[0].savings_percent >= 40.0);
30366    }
30367
30368    #[test]
30369    fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
30370        let mut content = String::from("# Cache Root\n\n");
30371        for idx in 0..96 {
30372            content.push_str(&format!(
30373                "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
30374            ));
30375        }
30376
30377        let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30378        assert!(!first.cache_hit);
30379        assert!(first.nodes.len() > 200);
30380
30381        let sections = markdown_section_spans(&content).unwrap();
30382        let list_items = markdown_block_spans(&content, "list_item").unwrap();
30383        let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
30384        let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30385
30386        assert!(second.cache_hit);
30387        assert_eq!(second.nodes.len(), first.nodes.len());
30388        assert_eq!(sections.len(), 97);
30389        assert_eq!(list_items.len(), 96);
30390        assert_eq!(code_blocks.len(), 96);
30391        let first_code = first
30392            .nodes
30393            .iter()
30394            .find(|node| node.kind == "code_block")
30395            .expect("expected a Markdown code block");
30396        let first_code_node = markdown_ast_node(
30397            Path::new("/repo"),
30398            "semantic-edit",
30399            first_code,
30400            content.as_bytes(),
30401            &first.nodes,
30402            8,
30403        );
30404        assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
30405        assert_eq!(
30406            first_code_node.metadata.embedded_symbols[0].name,
30407            "sample_0"
30408        );
30409        assert_eq!(
30410            first_code_node.metadata.embedded_symbols[0].language,
30411            "rust"
30412        );
30413    }
30414
30415    #[test]
30416    fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
30417        let response = empty_search_response(Path::new("/repo"), "lexical");
30418        let symbol_hits = vec![index::SymbolHit {
30419            name: "alpha_helper_with_a_long_name".to_string(),
30420            kind: "function".to_string(),
30421            language: "rust".to_string(),
30422            file: "/repo/src/lib.rs".to_string(),
30423            line: 12,
30424            end_line: None,
30425            node_kind: None,
30426            start_byte: None,
30427            end_byte: None,
30428            body_start_byte: None,
30429            body_end_byte: None,
30430            tags: None,
30431            score: 0.98,
30432            match_type: "exact_name".to_string(),
30433            tagpath_handle: None,
30434        }];
30435
30436        let report = build_relative_search_budget_report(
30437            "alpha_helper_with_a_long_name",
30438            "lexical",
30439            Path::new("/repo"),
30440            &response,
30441            &symbol_hits,
30442            ResponseBudget::new(Some(1), Some(12)),
30443            &SearchFacetFilters::default(),
30444        );
30445
30446        assert_eq!(report.symbols.len(), 1);
30447        assert!(report.symbols[0].handle.starts_with("sfam-"));
30448        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
30449        assert_eq!(report.symbols[0].name, "alpha_hel...");
30450        assert_eq!(report.symbols[0].file, "src/lib.rs");
30451        assert!(report.symbols[0].expand.contains("tsift search"));
30452    }
30453
30454    #[test]
30455    fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
30456        let dir = tempfile::tempdir().unwrap();
30457        let src_dir = dir.path().join("src");
30458        fs::create_dir_all(&src_dir).unwrap();
30459        let source = "fn alpha_helper() {\n    beta();\n}\n";
30460        let file = src_dir.join("lib.rs");
30461        fs::write(&file, source).unwrap();
30462        let body_start = source.find("{\n").unwrap() + 1;
30463        let body_end = source.rfind("\n}").unwrap() + 1;
30464
30465        let response = empty_search_response(dir.path(), "lexical");
30466        let symbol_hits = vec![index::SymbolHit {
30467            name: "alpha_helper".to_string(),
30468            kind: "function".to_string(),
30469            language: "rust".to_string(),
30470            file: file.to_string_lossy().to_string(),
30471            line: 0,
30472            end_line: Some(2),
30473            node_kind: Some("function_item".to_string()),
30474            start_byte: Some(0),
30475            end_byte: Some(i64::try_from(source.len()).unwrap()),
30476            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30477            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30478            tags: Some("alpha,helper".to_string()),
30479            score: 0.98,
30480            match_type: "exact_name".to_string(),
30481            tagpath_handle: None,
30482        }];
30483
30484        let report = build_relative_search_budget_report(
30485            "alpha helper",
30486            "lexical",
30487            dir.path(),
30488            &response,
30489            &symbol_hits,
30490            ResponseBudget::new(Some(5), Some(96)),
30491            &SearchFacetFilters::default(),
30492        );
30493
30494        let symbol = &report.symbols[0];
30495        assert_eq!(symbol.language, "rust");
30496        assert_eq!(symbol.end_line, Some(2));
30497        let ast = symbol
30498            .ast
30499            .as_ref()
30500            .expect("search symbol preview should expose an AST span artifact");
30501        assert_eq!(ast.artifact_kind, "ast_span");
30502        assert!(ast.span.handle.starts_with("span-"));
30503        assert_eq!(ast.span.node_kind, "function_item");
30504        assert_eq!(ast.span.start_byte, 0);
30505        assert_eq!(ast.span.end_byte, source.len());
30506        assert_eq!(ast.span.body_start_byte, Some(body_start));
30507        assert_eq!(ast.span.body_end_byte, Some(body_end));
30508        assert!(ast.expand.source_window.contains("source-read"));
30509        assert!(
30510            ast.expand
30511                .source_body
30512                .as_ref()
30513                .unwrap()
30514                .contains("source-read")
30515        );
30516        assert!(ast.expand.symbol_read.contains("symbol-read"));
30517        assert!(ast.expand.markdown_ast.is_none());
30518    }
30519
30520    #[test]
30521    fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
30522        let dir = tempfile::tempdir().unwrap();
30523        let source = "# Guide\n\n## Install\n\n- Run setup.\n";
30524        let file = dir.path().join("README.md");
30525        fs::write(&file, source).unwrap();
30526        let heading_start = source.find("## Install").unwrap();
30527        let heading_end = source.len();
30528
30529        let response = empty_search_response(dir.path(), "lexical");
30530        let symbol_hits = vec![index::SymbolHit {
30531            name: "Install".to_string(),
30532            kind: "heading".to_string(),
30533            language: "markdown".to_string(),
30534            file: file.to_string_lossy().to_string(),
30535            line: 2,
30536            end_line: Some(4),
30537            node_kind: Some("atx_heading".to_string()),
30538            start_byte: Some(i64::try_from(heading_start).unwrap()),
30539            end_byte: Some(i64::try_from(heading_end).unwrap()),
30540            body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
30541            body_end_byte: Some(i64::try_from(heading_end).unwrap()),
30542            tags: Some("install".to_string()),
30543            score: 1.0,
30544            match_type: "exact_name".to_string(),
30545            tagpath_handle: None,
30546        }];
30547
30548        let report = build_relative_search_budget_report(
30549            "Install",
30550            "lexical",
30551            dir.path(),
30552            &response,
30553            &symbol_hits,
30554            ResponseBudget::new(Some(5), Some(96)),
30555            &SearchFacetFilters::default(),
30556        );
30557
30558        let ast = report.symbols[0]
30559            .ast
30560            .as_ref()
30561            .expect("Markdown search symbol should expose an AST span artifact");
30562        assert_eq!(ast.span.node_kind, "atx_heading");
30563        assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
30564        let markdown_ast = ast
30565            .expand
30566            .markdown_ast
30567            .as_ref()
30568            .expect("Markdown symbols should include markdown-ast expansion");
30569        assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
30570        assert!(markdown_ast.contains("--node"), "{markdown_ast}");
30571        assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
30572        assert!(ast.expand.source_window.contains("source-read"));
30573        assert!(ast.expand.symbol_read.contains("symbol-read"));
30574    }
30575
30576    #[test]
30577    fn search_budget_report_exposes_markdown_embedded_code_symbols() {
30578        let dir = tempfile::tempdir().unwrap();
30579        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30580        let file = dir.path().join("README.md");
30581        fs::write(&file, source).unwrap();
30582        let fence_start = source.find("```rust").unwrap();
30583        let body_start = source.find("fn sample").unwrap();
30584        let body_end = body_start + "fn sample() {}\n".len();
30585
30586        let response = empty_search_response(dir.path(), "lexical");
30587        let symbol_hits = vec![index::SymbolHit {
30588            name: "rust".to_string(),
30589            kind: "code_block".to_string(),
30590            language: "markdown".to_string(),
30591            file: file.to_string_lossy().to_string(),
30592            line: 2,
30593            end_line: Some(4),
30594            node_kind: Some("fenced_code_block".to_string()),
30595            start_byte: Some(i64::try_from(fence_start).unwrap()),
30596            end_byte: Some(i64::try_from(source.len()).unwrap()),
30597            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30598            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30599            tags: Some("rust".to_string()),
30600            score: 1.0,
30601            match_type: "exact_name".to_string(),
30602            tagpath_handle: None,
30603        }];
30604
30605        let report = build_relative_search_budget_report(
30606            "rust",
30607            "lexical",
30608            dir.path(),
30609            &response,
30610            &symbol_hits,
30611            ResponseBudget::new(Some(5), Some(96)),
30612            &SearchFacetFilters::default(),
30613        );
30614
30615        let embedded = &report.symbols[0]
30616            .ast
30617            .as_ref()
30618            .unwrap()
30619            .span
30620            .markdown
30621            .as_ref()
30622            .unwrap()
30623            .embedded_symbols;
30624        assert_eq!(embedded.len(), 1);
30625        assert_eq!(embedded[0].name, "sample");
30626        assert_eq!(embedded[0].kind, "function");
30627        assert_eq!(embedded[0].language, "rust");
30628        assert_eq!(embedded[0].node_kind, "function_item");
30629        assert!(embedded[0].handle.starts_with("span-"));
30630        assert_eq!(embedded[0].start_byte, body_start);
30631        assert_eq!(embedded[0].start_line, 4);
30632    }
30633
30634    fn test_lexical_search_hit(
30635        path: &Path,
30636        rank: usize,
30637        score: f64,
30638        snippet: &str,
30639    ) -> sift::SearchHit {
30640        sift::SearchHit {
30641            artifact_id: format!("hit-{rank}"),
30642            artifact_kind: sift::ContextArtifactKind::File,
30643            budget: sift::ArtifactBudget::from_text(snippet, 1),
30644            confidence: sift::ScoreConfidence::High,
30645            freshness: sift::ArtifactFreshness {
30646                modified_unix_secs: None,
30647                observed_unix_secs: 0,
30648            },
30649            location: Some("line 1".to_string()),
30650            path: path.to_string_lossy().to_string(),
30651            provenance: sift::ArtifactProvenance {
30652                adapter: sift::AcquisitionAdapterKind::FileSystem,
30653                source: "test lexical hit".to_string(),
30654                synthetic: false,
30655            },
30656            rank,
30657            score,
30658            snippet: snippet.to_string(),
30659        }
30660    }
30661
30662    fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
30663        summarize::Summary {
30664            id: 0,
30665            symbol_name: symbol_name.to_string(),
30666            file_path: file_path.to_string(),
30667            content_hash: "hash".to_string(),
30668            summary: summary.to_string(),
30669            entities: None,
30670            relationships: None,
30671            concept_labels: None,
30672            extracted_at: "2026-06-02T00:00:00Z".to_string(),
30673            model: "test".to_string(),
30674            tokens_input: None,
30675            tokens_output: None,
30676        }
30677    }
30678
30679    #[test]
30680    fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
30681        let dir = tempfile::tempdir().unwrap();
30682        let src_dir = dir.path().join("src");
30683        fs::create_dir_all(&src_dir).unwrap();
30684        let source = "fn alpha_helper() {}\n";
30685        let file = src_dir.join("lib.rs");
30686        let broad_file = dir.path().join("README.md");
30687        fs::write(&file, source).unwrap();
30688        fs::write(
30689            &broad_file,
30690            "alpha helper alpha helper alpha helper in prose\n",
30691        )
30692        .unwrap();
30693
30694        let mut response = empty_search_response(dir.path(), "lexical");
30695        response.hits.push(test_lexical_search_hit(
30696            &broad_file,
30697            1,
30698            240.0,
30699            "alpha helper alpha helper alpha helper in prose",
30700        ));
30701        let symbol_hits = vec![index::SymbolHit {
30702            name: "alpha_helper".to_string(),
30703            kind: "function".to_string(),
30704            language: "rust".to_string(),
30705            file: file.to_string_lossy().to_string(),
30706            line: 0,
30707            end_line: Some(0),
30708            node_kind: Some("function_item".to_string()),
30709            start_byte: Some(0),
30710            end_byte: Some(i64::try_from(source.len()).unwrap()),
30711            body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30712            body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30713            tags: Some("alpha,helper".to_string()),
30714            score: 0.8,
30715            match_type: "all_tags".to_string(),
30716            tagpath_handle: None,
30717        }];
30718
30719        let report = build_relative_search_budget_report(
30720            "alpha helper",
30721            "lexical",
30722            dir.path(),
30723            &response,
30724            &symbol_hits,
30725            ResponseBudget::new(Some(5), Some(128)),
30726            &SearchFacetFilters::default(),
30727        );
30728
30729        assert_eq!(report.ranked[0].source, "symbol_span");
30730        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30731        assert!(report.ranked[0].score > report.ranked[1].score);
30732        assert_eq!(report.ranked[1].source, "lexical_file");
30733    }
30734
30735    #[test]
30736    fn search_budget_exact_hit_expands_to_source_handle_and_containing_symbol() {
30737        let dir = tempfile::tempdir().unwrap();
30738        let src_dir = dir.path().join("src");
30739        fs::create_dir_all(&src_dir).unwrap();
30740        let source = "fn alpha_helper() {\n    let needle = \"needle\";\n}\n\nfn other() {}\n";
30741        let file = src_dir.join("lib.rs");
30742        fs::write(&file, source).unwrap();
30743
30744        let mut response = empty_search_response(dir.path(), "exact");
30745        let mut hit = test_lexical_search_hit(&file, 1, 10.0, "let needle = \"needle\";");
30746        hit.location = Some("line 2".to_string());
30747        response.hits.push(hit);
30748
30749        let symbol_hits = vec![index::SymbolHit {
30750            name: "alpha_helper".to_string(),
30751            kind: "function".to_string(),
30752            language: "rust".to_string(),
30753            file: file.to_string_lossy().to_string(),
30754            line: 0,
30755            end_line: Some(2),
30756            node_kind: Some("function_item".to_string()),
30757            start_byte: Some(0),
30758            end_byte: Some(i64::try_from(source.find("\n\n").unwrap()).unwrap()),
30759            body_start_byte: Some(i64::try_from(source.find('{').unwrap() + 1).unwrap()),
30760            body_end_byte: Some(i64::try_from(source.find("\n}").unwrap()).unwrap()),
30761            tags: Some("alpha,helper".to_string()),
30762            score: 0.9,
30763            match_type: "all_tags".to_string(),
30764            tagpath_handle: None,
30765        }];
30766
30767        let report = build_relative_search_budget_report(
30768            "needle",
30769            "exact",
30770            dir.path(),
30771            &response,
30772            &symbol_hits,
30773            ResponseBudget::new(Some(5), Some(128)),
30774            &SearchFacetFilters::default(),
30775        );
30776
30777        let hit = &report.hits[0];
30778        assert_eq!(hit.line, Some(2));
30779        let source_handle = hit
30780            .source_handle
30781            .as_ref()
30782            .expect("exact hit should expose a bounded source_handle window");
30783        assert!(source_handle.handle.starts_with("xwin-"));
30784        assert_eq!(source_handle.kind, "source_handle");
30785        assert_eq!(source_handle.file, "src/lib.rs");
30786        assert_eq!(source_handle.start_line, 1);
30787        assert_eq!(source_handle.end_line, 3);
30788        assert!(source_handle.expand.contains("source-read"));
30789
30790        let containing_symbol = hit
30791            .containing_symbol
30792            .as_ref()
30793            .expect("exact hit should expose its containing symbol when indexed");
30794        assert_eq!(containing_symbol.name, "alpha_helper");
30795        assert_eq!(containing_symbol.kind, "function");
30796        assert_eq!(containing_symbol.line, 1);
30797        assert_eq!(containing_symbol.end_line, Some(3));
30798        assert!(containing_symbol.expand.contains("symbol-read"));
30799
30800        let lexical_rank = report
30801            .ranked
30802            .iter()
30803            .find(|item| item.source == "lexical_file")
30804            .expect("ranked preview should retain the lexical retrieval handle");
30805        assert!(
30806            lexical_rank
30807                .reasons
30808                .iter()
30809                .any(|reason| reason == "source_handle")
30810        );
30811        assert!(
30812            lexical_rank
30813                .reasons
30814                .iter()
30815                .any(|reason| reason == "containing_symbol")
30816        );
30817    }
30818
30819    #[test]
30820    fn search_budget_ranked_preview_prioritizes_source_definitions_before_tests() {
30821        let dir = tempfile::tempdir().unwrap();
30822        let src_dir = dir.path().join("src");
30823        let tests_dir = dir.path().join("tests");
30824        fs::create_dir_all(&src_dir).unwrap();
30825        fs::create_dir_all(&tests_dir).unwrap();
30826        let source_file = src_dir.join("lib.rs");
30827        let test_file = tests_dir.join("alpha_test.rs");
30828        fs::write(&source_file, "fn alpha_helper() {}\n").unwrap();
30829        fs::write(&test_file, "#[test]\nfn alpha_helper_test() {}\n").unwrap();
30830
30831        let response = empty_search_response(dir.path(), "lexical");
30832        let symbol_hits = vec![
30833            index::SymbolHit {
30834                name: "alpha_helper_test".to_string(),
30835                kind: "function".to_string(),
30836                language: "rust".to_string(),
30837                file: test_file.to_string_lossy().to_string(),
30838                line: 1,
30839                end_line: Some(1),
30840                node_kind: Some("function_item".to_string()),
30841                start_byte: Some(8),
30842                end_byte: Some(33),
30843                body_start_byte: Some(31),
30844                body_end_byte: Some(31),
30845                tags: Some("alpha,helper,test".to_string()),
30846                score: 1.0,
30847                match_type: "exact_name".to_string(),
30848                tagpath_handle: None,
30849            },
30850            index::SymbolHit {
30851                name: "alpha_helper".to_string(),
30852                kind: "function".to_string(),
30853                language: "rust".to_string(),
30854                file: source_file.to_string_lossy().to_string(),
30855                line: 0,
30856                end_line: Some(0),
30857                node_kind: Some("function_item".to_string()),
30858                start_byte: Some(0),
30859                end_byte: Some(20),
30860                body_start_byte: Some(18),
30861                body_end_byte: Some(18),
30862                tags: Some("alpha,helper".to_string()),
30863                score: 0.78,
30864                match_type: "all_tags".to_string(),
30865                tagpath_handle: None,
30866            },
30867        ];
30868
30869        let report = build_relative_search_budget_report(
30870            "alpha helper",
30871            "lexical",
30872            dir.path(),
30873            &response,
30874            &symbol_hits,
30875            ResponseBudget::new(Some(5), Some(128)),
30876            &SearchFacetFilters::default(),
30877        );
30878
30879        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30880        assert_eq!(report.ranked[0].path, "src/lib.rs");
30881        assert!(
30882            report.ranked[0]
30883                .reasons
30884                .iter()
30885                .any(|reason| reason == "definition_kind")
30886        );
30887        assert!(
30888            report.ranked[0]
30889                .reasons
30890                .iter()
30891                .any(|reason| reason == "source_path")
30892        );
30893        let test_rank = report
30894            .ranked
30895            .iter()
30896            .find(|item| item.name.as_deref() == Some("alpha_helper_test"))
30897            .expect("test symbol should still be present in the ranked preview");
30898        assert!(test_rank.reasons.iter().any(|reason| reason == "test_path"));
30899    }
30900
30901    #[test]
30902    fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
30903        let dir = tempfile::tempdir().unwrap();
30904        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30905        let file = dir.path().join("README.md");
30906        fs::write(&file, source).unwrap();
30907        let summary_db =
30908            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
30909        summary_db
30910            .insert(&test_summary(
30911                "rust",
30912                "README.md",
30913                "Rust fence contains a sample function.",
30914            ))
30915            .unwrap();
30916
30917        let fence_start = source.find("```rust").unwrap();
30918        let body_start = source.find("fn sample").unwrap();
30919        let body_end = body_start + "fn sample() {}\n".len();
30920        let response = empty_search_response(dir.path(), "lexical");
30921        let symbol_hits = vec![index::SymbolHit {
30922            name: "rust".to_string(),
30923            kind: "code_block".to_string(),
30924            language: "markdown".to_string(),
30925            file: file.to_string_lossy().to_string(),
30926            line: 2,
30927            end_line: Some(4),
30928            node_kind: Some("fenced_code_block".to_string()),
30929            start_byte: Some(i64::try_from(fence_start).unwrap()),
30930            end_byte: Some(i64::try_from(source.len()).unwrap()),
30931            body_start_byte: Some(i64::try_from(body_start).unwrap()),
30932            body_end_byte: Some(i64::try_from(body_end).unwrap()),
30933            tags: Some("rust".to_string()),
30934            score: 1.0,
30935            match_type: "exact_name".to_string(),
30936            tagpath_handle: None,
30937        }];
30938
30939        let report = build_relative_search_budget_report(
30940            "rust",
30941            "lexical",
30942            dir.path(),
30943            &response,
30944            &symbol_hits,
30945            ResponseBudget::new(Some(5), Some(128)),
30946            &SearchFacetFilters::default(),
30947        );
30948
30949        let symbol = &report.symbols[0];
30950        assert_eq!(symbol.summary_refs, 1);
30951        assert_eq!(symbol.graph_neighbors, 1);
30952        assert!(
30953            report.ranked[0]
30954                .reasons
30955                .iter()
30956                .any(|reason| reason == "summary_refs:1")
30957        );
30958        assert!(
30959            report.ranked[0]
30960                .reasons
30961                .iter()
30962                .any(|reason| reason == "graph_neighbors:1")
30963        );
30964    }
30965
30966    fn markdown_search_facet_fixture() -> tempfile::TempDir {
30967        let dir = tempfile::tempdir().unwrap();
30968        let source = r#"# Guide
30969
30970## Install
30971
30972- Run setup.
30973  - Confirm setup.
30974
30975```rust
30976fn sample() {}
30977```
30978"#;
30979        fs::write(dir.path().join("README.md"), source).unwrap();
30980        let index_dir = dir.path().join(".tsift");
30981        fs::create_dir_all(&index_dir).unwrap();
30982        run_index_update(
30983            &index_dir.join("index.db"),
30984            dir.path(),
30985            "indexing markdown search facet fixture".to_string(),
30986            dir.path(),
30987            None,
30988            false,
30989            false,
30990        )
30991        .unwrap();
30992        dir
30993    }
30994
30995    fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
30996        let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
30997        db.symbol_search(query, 20).unwrap()
30998    }
30999
31000    #[test]
31001    fn search_facet_filters_match_scalar_symbol_fields() {
31002        let dir = tempfile::tempdir().unwrap();
31003        let hits = vec![
31004            index::SymbolHit {
31005                name: "alpha_helper".to_string(),
31006                kind: "function".to_string(),
31007                language: "rust".to_string(),
31008                file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
31009                line: 0,
31010                end_line: None,
31011                node_kind: Some("function_item".to_string()),
31012                start_byte: None,
31013                end_byte: None,
31014                body_start_byte: None,
31015                body_end_byte: None,
31016                tags: None,
31017                score: 1.0,
31018                match_type: "exact_name".to_string(),
31019                tagpath_handle: None,
31020            },
31021            index::SymbolHit {
31022                name: "Install".to_string(),
31023                kind: "heading".to_string(),
31024                language: "markdown".to_string(),
31025                file: dir.path().join("README.md").to_string_lossy().to_string(),
31026                line: 0,
31027                end_line: None,
31028                node_kind: Some("atx_heading".to_string()),
31029                start_byte: None,
31030                end_byte: None,
31031                body_start_byte: None,
31032                body_end_byte: None,
31033                tags: None,
31034                score: 0.9,
31035                match_type: "exact_name".to_string(),
31036                tagpath_handle: None,
31037            },
31038        ];
31039
31040        let filtered = apply_search_facet_filters(
31041            dir.path(),
31042            hits,
31043            &SearchFacetFilters {
31044                languages: vec!["rust".to_string()],
31045                kinds: vec!["function".to_string()],
31046                node_kinds: vec!["function_item".to_string()],
31047                ..SearchFacetFilters::default()
31048            },
31049        );
31050
31051        assert_eq!(filtered.len(), 1);
31052        assert_eq!(filtered[0].name, "alpha_helper");
31053    }
31054
31055    #[test]
31056    fn search_facet_filters_match_markdown_sections_and_block_metadata() {
31057        let dir = markdown_search_facet_fixture();
31058
31059        let nested_list = apply_search_facet_filters(
31060            dir.path(),
31061            markdown_search_facet_hits(dir.path(), "setup"),
31062            &SearchFacetFilters {
31063                sections: vec!["Install".to_string()],
31064                parents: vec!["Run setup.".to_string()],
31065                list_depths: vec![1],
31066                ..SearchFacetFilters::default()
31067            },
31068        );
31069        assert_eq!(nested_list.len(), 1);
31070        assert_eq!(nested_list[0].name, "Confirm setup.");
31071
31072        let parent_list = apply_search_facet_filters(
31073            dir.path(),
31074            markdown_search_facet_hits(dir.path(), "setup"),
31075            &SearchFacetFilters {
31076                children: vec!["Confirm setup.".to_string()],
31077                ..SearchFacetFilters::default()
31078            },
31079        );
31080        assert_eq!(parent_list.len(), 1);
31081        assert_eq!(parent_list[0].name, "Run setup.");
31082
31083        let heading = apply_search_facet_filters(
31084            dir.path(),
31085            markdown_search_facet_hits(dir.path(), "Install"),
31086            &SearchFacetFilters {
31087                heading_levels: vec![2],
31088                node_kinds: vec!["atx_heading".to_string()],
31089                ..SearchFacetFilters::default()
31090            },
31091        );
31092        assert_eq!(heading.len(), 1);
31093        assert_eq!(heading[0].name, "Install");
31094
31095        let fence = apply_search_facet_filters(
31096            dir.path(),
31097            markdown_search_facet_hits(dir.path(), "rust"),
31098            &SearchFacetFilters {
31099                fence_languages: vec!["rust".to_string()],
31100                kinds: vec!["code_block".to_string()],
31101                ..SearchFacetFilters::default()
31102            },
31103        );
31104        assert_eq!(fence.len(), 1);
31105        assert_eq!(fence[0].kind, "code_block");
31106
31107        let embedded_child = apply_search_facet_filters(
31108            dir.path(),
31109            markdown_search_facet_hits(dir.path(), "rust"),
31110            &SearchFacetFilters {
31111                children: vec!["sample".to_string()],
31112                kinds: vec!["code_block".to_string()],
31113                ..SearchFacetFilters::default()
31114            },
31115        );
31116        assert_eq!(embedded_child.len(), 1);
31117        assert_eq!(embedded_child[0].name, "rust");
31118    }
31119
31120    #[test]
31121    fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
31122        let response = empty_search_response(Path::new("/repo"), "lexical");
31123        let symbol_hits = vec![
31124            index::SymbolHit {
31125                name: "alpha_helper".to_string(),
31126                kind: "function".to_string(),
31127                language: "rust".to_string(),
31128                file: "/repo/src/lib.rs".to_string(),
31129                line: 12,
31130                end_line: None,
31131                node_kind: None,
31132                start_byte: None,
31133                end_byte: None,
31134                body_start_byte: None,
31135                body_end_byte: None,
31136                tags: Some("alpha,helper".to_string()),
31137                score: 0.98,
31138                match_type: "exact_name".to_string(),
31139                tagpath_handle: None,
31140            },
31141            index::SymbolHit {
31142                name: "alphaHelper".to_string(),
31143                kind: "method".to_string(),
31144                language: "rust".to_string(),
31145                file: "/repo/src/main.rs".to_string(),
31146                line: 34,
31147                end_line: None,
31148                node_kind: None,
31149                start_byte: None,
31150                end_byte: None,
31151                body_start_byte: None,
31152                body_end_byte: None,
31153                tags: Some("alpha,helper".to_string()),
31154                score: 0.93,
31155                match_type: "tag_overlap".to_string(),
31156                tagpath_handle: None,
31157            },
31158            index::SymbolHit {
31159                name: "alpha_helper".to_string(),
31160                kind: "function".to_string(),
31161                language: "rust".to_string(),
31162                file: "/repo/src/worker.rs".to_string(),
31163                line: 56,
31164                end_line: None,
31165                node_kind: None,
31166                start_byte: None,
31167                end_byte: None,
31168                body_start_byte: None,
31169                body_end_byte: None,
31170                tags: Some("alpha,helper".to_string()),
31171                score: 0.91,
31172                match_type: "tag_overlap".to_string(),
31173                tagpath_handle: None,
31174            },
31175        ];
31176
31177        let report = build_relative_search_budget_report(
31178            "alpha helper",
31179            "lexical",
31180            Path::new("/repo"),
31181            &response,
31182            &symbol_hits,
31183            ResponseBudget::new(Some(5), Some(48)),
31184            &SearchFacetFilters::default(),
31185        );
31186
31187        assert_eq!(report.symbol_total, 1);
31188        assert_eq!(report.raw_symbol_total, 3);
31189        assert_eq!(report.symbols.len(), 1);
31190        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
31191        assert_eq!(report.symbols[0].match_count, 3);
31192        assert_eq!(report.symbols[0].surface_count, 2);
31193        assert_eq!(report.symbols[0].file_count, 3);
31194        assert_eq!(
31195            report.symbols[0].surface_examples,
31196            vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
31197        );
31198        assert!(report.symbols[0].name.contains("(+1 variant)"));
31199        assert!(report.symbols[0].file.contains("(+2 files)"));
31200        assert!(report.symbols[0].expand.contains("tsift search"));
31201        assert!(report.symbols[0].expand.contains("alpha helper"));
31202    }
31203
31204    #[test]
31205    fn search_budget_report_carries_active_filters() {
31206        let response = empty_search_response(Path::new("/repo"), "lexical");
31207        let symbol_hits = vec![index::SymbolHit {
31208            name: "alpha_helper".to_string(),
31209            kind: "function".to_string(),
31210            language: "rust".to_string(),
31211            file: "/repo/src/lib.rs".to_string(),
31212            line: 12,
31213            end_line: None,
31214            node_kind: Some("function_item".to_string()),
31215            start_byte: None,
31216            end_byte: None,
31217            body_start_byte: None,
31218            body_end_byte: None,
31219            tags: Some("alpha,helper".to_string()),
31220            score: 0.98,
31221            match_type: "exact_name".to_string(),
31222            tagpath_handle: None,
31223        }];
31224        let filters = SearchFacetFilters {
31225            languages: vec!["rust".to_string()],
31226            kinds: vec!["function".to_string()],
31227            node_kinds: vec!["function_item".to_string()],
31228            ..SearchFacetFilters::default()
31229        };
31230
31231        let report = build_relative_search_budget_report(
31232            "alpha helper",
31233            "lexical",
31234            Path::new("/repo"),
31235            &response,
31236            &symbol_hits,
31237            ResponseBudget::new(Some(5), Some(48)),
31238            &filters,
31239        );
31240
31241        assert_eq!(report.filters, filters);
31242        assert_eq!(
31243            search_facet_filters_summary(&report.filters),
31244            "lang=rust kind=function node-kind=function_item"
31245        );
31246    }
31247
31248    #[test]
31249    fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
31250        let mut response = empty_search_response(Path::new("/repo"), "lexical");
31251        response.indexed_artifacts = 450;
31252        let symbol_hits = vec![
31253            index::SymbolHit {
31254                name: "alpha_helper".to_string(),
31255                kind: "function".to_string(),
31256                language: "rust".to_string(),
31257                file: "/repo/src/lib.rs".to_string(),
31258                line: 12,
31259                end_line: None,
31260                node_kind: None,
31261                start_byte: None,
31262                end_byte: None,
31263                body_start_byte: None,
31264                body_end_byte: None,
31265                tags: Some("alpha,helper".to_string()),
31266                score: 0.98,
31267                match_type: "exact_name".to_string(),
31268                tagpath_handle: None,
31269            },
31270            index::SymbolHit {
31271                name: "beta_helper".to_string(),
31272                kind: "function".to_string(),
31273                language: "rust".to_string(),
31274                file: "/repo/src/beta.rs".to_string(),
31275                line: 21,
31276                end_line: None,
31277                node_kind: None,
31278                start_byte: None,
31279                end_byte: None,
31280                body_start_byte: None,
31281                body_end_byte: None,
31282                tags: Some("beta,helper".to_string()),
31283                score: 0.92,
31284                match_type: "tag_overlap".to_string(),
31285                tagpath_handle: None,
31286            },
31287        ];
31288
31289        let report = build_relative_search_budget_report(
31290            "helper",
31291            "lexical",
31292            Path::new("/repo"),
31293            &response,
31294            &symbol_hits,
31295            ResponseBudget::new(Some(1), Some(64)),
31296            &SearchFacetFilters::default(),
31297        );
31298
31299        let guard = report
31300            .scale_guard
31301            .as_ref()
31302            .expect("broad previews should emit a scale guard");
31303        assert_eq!(guard.level, "high-hit");
31304        assert_eq!(guard.signals.indexed_artifacts, 450);
31305        assert_eq!(guard.signals.raw_symbol_matches, 2);
31306        assert!(
31307            guard
31308                .narrow_commands
31309                .iter()
31310                .any(|command| command.contains("--exact"))
31311        );
31312        assert!(
31313            guard
31314                .narrow_commands
31315                .iter()
31316                .any(|command| command.contains("alpha helper"))
31317        );
31318        assert!(
31319            guard
31320                .narrow_commands
31321                .last()
31322                .unwrap()
31323                .contains("workflow search")
31324        );
31325    }
31326
31327    #[test]
31328    fn explain_budget_report_limits_edges_and_members() {
31329        let symbols = vec![index::StoredSymbol {
31330            name: "alpha_helper".to_string(),
31331            kind: "function".to_string(),
31332            language: "rust".to_string(),
31333            signature: None,
31334            file: "src/lib.rs".to_string(),
31335            line: 10,
31336            end_line: None,
31337            node_kind: None,
31338            start_byte: None,
31339            end_byte: None,
31340            body_start_byte: None,
31341            body_end_byte: None,
31342            parent_module: None,
31343            visibility: None,
31344            tags: None,
31345            tagpath_handle: None,
31346        }];
31347        let callers = vec![
31348            index::StoredEdge {
31349                caller_file: "src/main.rs".to_string(),
31350                caller_name: "main".to_string(),
31351                caller_line: 1,
31352                callee_name: "alpha_helper".to_string(),
31353                call_site_line: 3,
31354                tagpath_handle: None,
31355            },
31356            index::StoredEdge {
31357                caller_file: "src/worker.rs".to_string(),
31358                caller_name: "worker".to_string(),
31359                caller_line: 5,
31360                callee_name: "alpha_helper".to_string(),
31361                call_site_line: 8,
31362                tagpath_handle: None,
31363            },
31364        ];
31365        let community = graph::Community {
31366            id: 1,
31367            members: vec![
31368                graph::CommunityMember::new("alpha_helper"),
31369                graph::CommunityMember::new("main"),
31370                graph::CommunityMember::new("worker"),
31371            ],
31372            modularity_contribution: 0.5,
31373        };
31374
31375        let report = build_explain_budget_report(
31376            "alpha_helper",
31377            Path::new("/repo"),
31378            &symbols,
31379            &callers,
31380            2,
31381            false,
31382            &[],
31383            0,
31384            false,
31385            Some(&community),
31386            ResponseBudget::new(Some(1), Some(24)),
31387        );
31388
31389        assert_eq!(report.definitions.len(), 1);
31390        assert_eq!(report.callers.len(), 1);
31391        assert!(report.truncated);
31392        assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
31393        assert_eq!(
31394            report.definitions[0].tag_alias.as_deref(),
31395            Some("alpha/helper")
31396        );
31397        assert!(report.callers[0].handle.starts_with("ecall-"));
31398        assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
31399    }
31400
31401    #[test]
31402    fn session_review_next_context_budget_limits_lists() {
31403        let report = session_review::SessionReviewReport {
31404            root: "/repo".to_string(),
31405            target: "tasks/software/tsift.md".to_string(),
31406            target_kind: "file".to_string(),
31407            sessions_considered: 1,
31408            sessions_matched: 1,
31409            claude_sessions: 1,
31410            codex_sessions: 0,
31411            agent_doc_logs: 0,
31412            prompt_target_count: 2,
31413            command_groups: 0,
31414            file_groups: 2,
31415            symbol_groups: 1,
31416            failure_groups: 1,
31417            runtime_event_groups: 0,
31418            restart_churn_groups: 0,
31419            closeout_groups: 0,
31420            usage_samples: 1,
31421            prompt_tokens: 120,
31422            cached_input_tokens: 80,
31423            cache_creation_input_tokens: 0,
31424            output_tokens: 40,
31425            reasoning_output_tokens: 0,
31426            total_tokens: 240,
31427            cached_input_ratio: Some(40.0),
31428            largest_turn_total_tokens: 240,
31429            aggregate_cost: session_review::SessionReviewCostSummary {
31430                scope: "bounded_matched_sessions".to_string(),
31431                sessions: 1,
31432                usage_samples: 1,
31433                prompt_tokens: 120,
31434                cached_input_tokens: 80,
31435                cache_creation_input_tokens: 0,
31436                output_tokens: 40,
31437                reasoning_output_tokens: 0,
31438                total_tokens: 240,
31439                cached_input_ratio: Some(40.0),
31440                largest_turn_total_tokens: 240,
31441            },
31442            latest_session_cost: Some(session_review::SessionReviewCostSummary {
31443                scope: "latest_matched_session".to_string(),
31444                sessions: 1,
31445                usage_samples: 1,
31446                prompt_tokens: 120,
31447                cached_input_tokens: 80,
31448                cache_creation_input_tokens: 0,
31449                output_tokens: 40,
31450                reasoning_output_tokens: 0,
31451                total_tokens: 240,
31452                cached_input_ratio: Some(66.67),
31453                largest_turn_total_tokens: 240,
31454            }),
31455            prompt_cache_cross_run: None,
31456            prompt_cache_roi_scorecard: vec![],
31457            guardrails: vec![
31458                session_cost::SessionCostGuardrail {
31459                    kind: "cache_resend".to_string(),
31460                    severity: "warn".to_string(),
31461                    message: "cached input ratio was high".to_string(),
31462                    guidance: "compact or restart the session".to_string(),
31463                },
31464                session_cost::SessionCostGuardrail {
31465                    kind: "prompt_budget".to_string(),
31466                    severity: "warn".to_string(),
31467                    message: "largest prompt turn reached 999999 tokens".to_string(),
31468                    guidance: "compact the session before another large turn".to_string(),
31469                },
31470                session_cost::SessionCostGuardrail {
31471                    kind: "restart_loop".to_string(),
31472                    severity: "warn".to_string(),
31473                    message: "restart churn detected".to_string(),
31474                    guidance: "restart cleanly".to_string(),
31475                },
31476                session_cost::SessionCostGuardrail {
31477                    kind: "noop_closeout".to_string(),
31478                    severity: "warn".to_string(),
31479                    message: "commit_already_current appeared 8 times".to_string(),
31480                    guidance: "avoid reopening without new edits".to_string(),
31481                },
31482            ],
31483            loop_clusters: vec![session_cost::SessionCostLoopCluster {
31484                kind: "command_bundle".to_string(),
31485                label: "cargo test -> cargo build --release".to_string(),
31486                occurrences: 2,
31487                max_consecutive: 2,
31488            }],
31489            file_read_diagnostics: vec![session_cost::SessionCostFileReadDiagnostic {
31490                path: "src/lib.rs".to_string(),
31491                range: "12-40".to_string(),
31492                occurrences: 3,
31493                estimated_tokens: 1200,
31494                duplicate_estimated_tokens: 800,
31495                follow_up_commands: vec![
31496                    "tsift source-read src/lib.rs --start 12 --lines 29 --budget normal"
31497                        .to_string(),
31498                ],
31499            }],
31500            prompt_targets: vec![
31501                session_review::SessionReviewPromptTarget {
31502                    text: "do one".to_string(),
31503                    occurrences: 1,
31504                },
31505                session_review::SessionReviewPromptTarget {
31506                    text: "do two".to_string(),
31507                    occurrences: 1,
31508                },
31509            ],
31510            commands: vec![],
31511            touched_files: vec![],
31512            touched_symbols: vec![],
31513            failures: vec![],
31514            runtime_events: vec![],
31515            restart_churn: vec![],
31516            closeout: vec![],
31517            largest_turns: vec![],
31518            sessions: vec![session_review::SessionReviewSession {
31519                source: "claude_jsonl".to_string(),
31520                path: "/tmp/session.jsonl".to_string(),
31521                matched_by: vec!["path".to_string()],
31522                modified_unix_secs: None,
31523                prompt_target_count: 2,
31524                command_groups: 0,
31525                file_groups: 2,
31526                symbol_groups: 1,
31527                failure_groups: 1,
31528                runtime_event_groups: 0,
31529                restart_churn_groups: 0,
31530                closeout_groups: 0,
31531                usage_samples: 1,
31532                prompt_tokens: 120,
31533                cached_input_tokens: 80,
31534                cache_creation_input_tokens: 0,
31535                output_tokens: 40,
31536                reasoning_output_tokens: 0,
31537                total_tokens: 240,
31538                largest_turn_total_tokens: 240,
31539            }],
31540            next_context: session_review::SessionReviewNextContext {
31541                target: "tasks/software/tsift.md".to_string(),
31542                active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
31543                last_verification: session_review::SessionReviewVerificationState {
31544                    status: "green".to_string(),
31545                    detail: "cargo test".to_string(),
31546                },
31547                touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
31548                touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
31549                unresolved_failures: vec![session_review::SessionReviewFailure {
31550                    kind: "timeout".to_string(),
31551                    message: "search timed out".to_string(),
31552                    occurrences: 1,
31553                    command: None,
31554                    session_path: None,
31555                }],
31556                agent_doc_queue: Some(session_review::SessionReviewAgentDocQueueProfile {
31557                    active_queue_prompt: Some(
31558                        "[#one] do one with enough detail to truncate".to_string(),
31559                    ),
31560                    live_exchange_tail: vec!["do one".to_string(), "do two".to_string()],
31561                    backlog_rows: vec!["[#one] do one".to_string(), "[#two] do two".to_string()],
31562                    review_rows: vec![
31563                        "[#review] review one".to_string(),
31564                        "[#review2] review two".to_string(),
31565                    ],
31566                    prompt_presets: vec![
31567                        "#spec-test-build-install-commit-push: update spec + tests"
31568                            .to_string(),
31569                        "#next-steps: collect follow-ups".to_string(),
31570                    ],
31571                    expansion_handles: vec![
31572                        session_review::SessionReviewAgentDocExpansionHandle {
31573                            handle: "adq-next-context".to_string(),
31574                            label: "refresh next-context".to_string(),
31575                            expand: "tsift --envelope session-review tasks/software/tsift.md --next-context --budget normal".to_string(),
31576                        },
31577                        session_review::SessionReviewAgentDocExpansionHandle {
31578                            handle: "adq-context-pack".to_string(),
31579                            label: "refresh context-pack".to_string(),
31580                            expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal".to_string(),
31581                        },
31582                    ],
31583                }),
31584                prompt_cache_health: None,
31585                next_digest_commands: vec![
31586                    "tsift session-review --next-context tasks/software/tsift.md".to_string(),
31587                    "tsift diff-digest .".to_string(),
31588                    "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
31589                    "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
31590                ],
31591            },
31592            warnings: vec![],
31593        };
31594
31595        let budget_report = build_session_review_next_context_budget_report(
31596            &report,
31597            ResponseBudget::new(Some(1), Some(12)),
31598            None,
31599        );
31600
31601        assert!(budget_report.truncated);
31602        assert_eq!(budget_report.prompt_targets, vec!["do one"]);
31603        assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
31604        assert!(
31605            budget_report.touched_symbol_refs[0]
31606                .handle
31607                .starts_with("ncsym-")
31608        );
31609        assert_eq!(
31610            budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
31611            Some("alpha/helper")
31612        );
31613        assert!(
31614            budget_report.unresolved_failures[0]
31615                .handle
31616                .starts_with("snf-")
31617        );
31618        assert_eq!(budget_report.next_digest_commands.len(), 4);
31619        assert_eq!(
31620            budget_report.next_digest_commands[2],
31621            "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
31622        );
31623        let queue = budget_report
31624            .agent_doc_queue
31625            .as_ref()
31626            .expect("agent-doc queue budget profile should be present");
31627        assert_eq!(queue.active_queue_prompt.as_deref(), Some("[#one] do..."));
31628        assert_eq!(queue.backlog_rows, vec!["[#one] do..."]);
31629        assert_eq!(queue.review_row_total, 2);
31630        assert_eq!(queue.prompt_presets.len(), 1);
31631        assert_eq!(queue.expansion_handles.len(), 2);
31632        assert!(queue.truncated);
31633        assert_eq!(budget_report.next_token_actions.len(), 1);
31634        assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
31635
31636        let full_action_report = build_session_review_next_context_budget_report(
31637            &report,
31638            ResponseBudget::new(Some(6), Some(120)),
31639            None,
31640        );
31641        assert_eq!(
31642            full_action_report
31643                .next_token_actions
31644                .iter()
31645                .map(|action| action.kind.as_str())
31646                .collect::<Vec<_>>(),
31647            vec![
31648                "prompt_budget",
31649                "cache_resend",
31650                "repeated_raw_read",
31651                "repeated_command_bundle",
31652                "restart_loop",
31653                "noop_closeout"
31654            ]
31655        );
31656        assert_eq!(
31657            full_action_report.next_token_actions[0]
31658                .compact_command
31659                .as_deref(),
31660            Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
31661        );
31662        assert_eq!(
31663            full_action_report.next_token_actions[0]
31664                .restart_command
31665                .as_deref(),
31666            Some("agent-doc start \"tasks/software/tsift.md\"")
31667        );
31668        assert!(
31669            full_action_report.next_token_actions[0]
31670                .digest_commands
31671                .iter()
31672                .any(|command| command
31673                    == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
31674        );
31675        let raw_read_action = full_action_report
31676            .next_token_actions
31677            .iter()
31678            .find(|action| action.kind == "repeated_raw_read")
31679            .expect("raw read action");
31680        assert!(
31681            raw_read_action.rewrite_commands.iter().any(
31682                |command| command == "tsift rewrite --run \"sed -n 12,40p \\\"src/lib.rs\\\"\""
31683            ),
31684            "raw read rewrite commands: {:?}",
31685            raw_read_action.rewrite_commands
31686        );
31687        assert!(raw_read_action.rewrite_commands.iter().any(|command| command
31688        == "tsift --envelope source-read src/lib.rs --start 12 --lines 29 --budget normal"));
31689        let command_bundle_action = full_action_report
31690            .next_token_actions
31691            .iter()
31692            .find(|action| action.kind == "repeated_command_bundle")
31693            .expect("command bundle action");
31694        assert!(
31695            command_bundle_action
31696                .rewrite_commands
31697                .iter()
31698                .any(|command| command == "tsift rewrite --run \"cargo test\"")
31699        );
31700        assert!(
31701            command_bundle_action
31702                .rewrite_commands
31703                .iter()
31704                .any(|command| command == "tsift rewrite --run \"cargo build --release\"")
31705        );
31706    }
31707
31708    #[test]
31709    fn context_pack_diff_preview_limits_files_and_symbols() {
31710        let report = diff_digest::DiffDigestReport {
31711            root: "/repo".to_string(),
31712            mode: diff_digest::DiffDigestMode::WorkingTree,
31713            revision: None,
31714            files_changed: 2,
31715            files_with_current_summaries: 1,
31716            symbols_touched: 3,
31717            call_edges_added: 1,
31718            call_edges_removed: 0,
31719            files: vec![
31720                diff_digest::DiffDigestFile {
31721                    path: "src/lib.rs".to_string(),
31722                    status: diff_digest::DiffDigestFileStatus::Modified,
31723                    touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
31724                    summary_state: diff_digest::DiffDigestSummaryState::Current,
31725                    current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
31726                        symbol: "alpha_helper".to_string(),
31727                        summary: "alpha helper handles the main alpha workflow".to_string(),
31728                    }],
31729                    added_call_edges: vec!["alpha->beta".to_string()],
31730                    removed_call_edges: vec![],
31731                    warnings: vec!["stale parse".to_string()],
31732                },
31733                diff_digest::DiffDigestFile {
31734                    path: "src/main.rs".to_string(),
31735                    status: diff_digest::DiffDigestFileStatus::Added,
31736                    touched_symbols: vec!["main".to_string()],
31737                    summary_state: diff_digest::DiffDigestSummaryState::Missing,
31738                    current_summaries: vec![],
31739                    added_call_edges: vec![],
31740                    removed_call_edges: vec![],
31741                    warnings: vec![],
31742                },
31743            ],
31744        };
31745
31746        let preview =
31747            build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
31748
31749        assert!(preview.truncated);
31750        assert_eq!(preview.files.len(), 1);
31751        assert_eq!(preview.files[0].path, "src/lib.rs");
31752        assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
31753        assert!(
31754            preview.files[0].touched_symbol_refs[0]
31755                .handle
31756                .starts_with("cdsym-")
31757        );
31758        assert_eq!(
31759            preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
31760            Some("alpha/he...")
31761        );
31762        assert!(
31763            preview.files[0].summary_refs[0]
31764                .handle
31765                .starts_with("cdsum-")
31766        );
31767        assert_eq!(
31768            preview.files[0].summary_refs[0].tag_alias.as_deref(),
31769            Some("alpha/he...")
31770        );
31771        assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
31772        assert_eq!(
31773            preview.files[0].summary_refs[0].expand,
31774            "tsift summarize --file \"src/lib.rs\""
31775        );
31776        assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
31777    }
31778
31779    #[test]
31780    fn context_pack_status_reminders_include_stale_index_state() {
31781        let dir = setup_graph_index();
31782        std::thread::sleep(std::time::Duration::from_millis(50));
31783        std::fs::write(
31784            dir.path().join("main.rs"),
31785            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
31786        )
31787        .unwrap();
31788
31789        let reminders = context_pack_status_reminders(dir.path());
31790
31791        assert_eq!(reminders.len(), 1);
31792        assert!(reminders[0].contains("index stale"));
31793        assert!(reminders[0].contains("tsift index ."));
31794    }
31795
31796    // #gdbgatecold regression-lock: the trusted context-pack pipeline must
31797    // share its index-inspection across `prepare_agent_doc_index_gate` and
31798    // `context_pack_status_reminders` (both call `IndexDb::inspect_read_only`
31799    // on the same `(root, .tsift/index.db)` key). With the scope guard
31800    // active in `build_context_pack_report_with_profile`, the second call
31801    // hits the cache, so we should record one miss and at least one hit.
31802    #[test]
31803    fn build_context_pack_reuses_inspect_within_scope() {
31804        let dir = setup_graph_index();
31805        init_git_repo(dir.path());
31806        let _guard = index::InspectScopeGuard::new();
31807        let _ = build_context_pack_report(
31808            dir.path(),
31809            None,
31810            None,
31811            None,
31812            ResponseBudget::new(Some(2), Some(96)),
31813        )
31814        .unwrap();
31815        let (hits, misses) = index::inspect_scope_stats();
31816        assert!(
31817            hits >= 1,
31818            "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
31819        );
31820        assert!(
31821            misses >= 1,
31822            "expected at least one initial inspect miss (hits={hits}, misses={misses})"
31823        );
31824    }
31825
31826    // #gdbgatecold scope-isolation: outside of any scope, every call to
31827    // `IndexDb::inspect_read_only` must hit the disk fresh. This locks in
31828    // the contract that the search/status fast-paths never reuse a cached
31829    // inspection across consecutive top-level calls.
31830    #[test]
31831    fn inspect_read_only_outside_scope_does_not_cache() {
31832        let dir = setup_graph_index();
31833        let db_path = dir.path().join(".tsift/index.db");
31834        let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31835        let (hits, misses) = index::inspect_scope_stats();
31836        assert_eq!(
31837            (hits, misses),
31838            (0, 0),
31839            "no scope guard => no hits/misses recorded"
31840        );
31841        let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31842        let (hits, _) = index::inspect_scope_stats();
31843        assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
31844    }
31845
31846    #[test]
31847    fn context_pack_refreshes_stale_index_before_handoff() {
31848        let dir = setup_graph_index();
31849        init_git_repo(dir.path());
31850        std::thread::sleep(std::time::Duration::from_millis(50));
31851        std::fs::write(
31852            dir.path().join("main.rs"),
31853            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
31854        )
31855        .unwrap();
31856
31857        let report = build_context_pack_report(
31858            dir.path(),
31859            None,
31860            None,
31861            None,
31862            ResponseBudget::new(Some(2), Some(96)),
31863        )
31864        .unwrap();
31865
31866        assert!(
31867            report
31868                .status_reminders
31869                .iter()
31870                .any(|reminder| reminder.contains("index refreshed")
31871                    && reminder.contains("context-pack handoff")),
31872            "expected context-pack refresh diagnostic, got {:?}",
31873            report.status_reminders
31874        );
31875        assert!(
31876            !report
31877                .status_reminders
31878                .iter()
31879                .any(|reminder| reminder.contains("index stale")),
31880            "stale reminder should be gone after refresh: {:?}",
31881            report.status_reminders
31882        );
31883
31884        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
31885        let summary = db.compute_changes(dir.path()).unwrap();
31886        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
31887    }
31888
31889    #[test]
31890    fn context_pack_materializes_source_handles_into_graph_store() {
31891        let dir = tempfile::tempdir().unwrap();
31892        let packet = ExplorationPacket {
31893            budget: exploration_budget_for_counts(2, 1),
31894            relationship_map: vec![ExplorationRelation {
31895                from: "file:main.rs".to_string(),
31896                relation: "touches_symbol".to_string(),
31897                to: "symbol:helper".to_string(),
31898                label: Some("modified diff".to_string()),
31899            }],
31900            source_windows: vec![ExplorationSourceWindow {
31901                handle: "xwin-test".to_string(),
31902                file: "main.rs".to_string(),
31903                start: 1,
31904                end: 32,
31905                reason: "changed file".to_string(),
31906                expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
31907            }],
31908            worker_context: vec![ExplorationWorkerContext {
31909                handle: "xwrk-test".to_string(),
31910                target: "tasks/software/tsift.md".to_string(),
31911                summary: "do #kgnv".to_string(),
31912                expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
31913                    .to_string(),
31914            }],
31915            no_reread_guidance: "use windows".to_string(),
31916        };
31917
31918        let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
31919        assert_eq!(packet.source_windows[0].handle, "xwin-test");
31920
31921        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
31922        let source_handles = store.nodes_by_kind("source_handle").unwrap();
31923        assert_eq!(source_handles.len(), 1);
31924        assert_eq!(
31925            source_handles[0].properties.get("file"),
31926            Some(&"main.rs".to_string())
31927        );
31928        assert_eq!(
31929            store
31930                .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
31931                .unwrap()
31932                .len(),
31933            1
31934        );
31935        let worker_context = store.nodes_by_kind("worker_context").unwrap();
31936        assert_eq!(worker_context.len(), 1);
31937        assert_eq!(
31938            store
31939                .outgoing_edges("xwrk-test", Some("scopes_source"))
31940                .unwrap()
31941                .len(),
31942            1
31943        );
31944    }
31945
31946    #[test]
31947    fn context_pack_records_graph_orchestration_observability() {
31948        let dir = setup_traversal_project();
31949        init_git_repo(dir.path());
31950        let session = dir.path().join("tasks/software/tsift.md");
31951        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
31952
31953        let report = build_context_pack_report(
31954            &session,
31955            None,
31956            None,
31957            None,
31958            ResponseBudget::new(Some(4), Some(160)),
31959        )
31960        .unwrap();
31961
31962        assert_eq!(
31963            report.graph_orchestration.contract_version,
31964            CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
31965        );
31966        assert_eq!(
31967            report
31968                .graph_orchestration
31969                .projection_freshness
31970                .status
31971                .as_str(),
31972            "current"
31973        );
31974        assert!(!report.graph_orchestration.projection_hashes.is_empty());
31975        assert_eq!(report.graph_orchestration.readiness.status, "blocked");
31976        assert_eq!(
31977            report.graph_orchestration.readiness.reason,
31978            "summary_cache_empty"
31979        );
31980        assert!(report.graph_orchestration.readiness.fail_closed);
31981        assert!(
31982            report
31983                .graph_orchestration
31984                .readiness
31985                .next_commands
31986                .iter()
31987                .any(|command| command == "tsift summarize --extract ."),
31988            "{:?}",
31989            report.graph_orchestration.readiness.next_commands
31990        );
31991        assert!(
31992            report
31993                .graph_orchestration
31994                .evidence_packet_ids
31995                .iter()
31996                .all(|id| !id.starts_with("gevd-")),
31997            "evidence packet ids should be empty when readiness is blocked: {:?}",
31998            report.graph_orchestration.evidence_packet_ids
31999        );
32000        assert!(
32001            report
32002                .graph_orchestration
32003                .conflict_matrix_decisions
32004                .iter()
32005                .any(|decision| decision.contains("readiness blocked")),
32006            "conflict-matrix decisions should reference readiness block: {:?}",
32007            report.graph_orchestration.conflict_matrix_decisions
32008        );
32009        assert!(
32010            !report
32011                .graph_orchestration
32012                .follow_up_commands
32013                .iter()
32014                .any(|command| command.contains("conflict-matrix")),
32015            "conflict-matrix command should not appear when readiness is blocked: {:?}",
32016            report.graph_orchestration.follow_up_commands
32017        );
32018        assert!(
32019            report
32020                .graph_orchestration
32021                .follow_up_commands
32022                .iter()
32023                .any(|command| command == "tsift summarize --extract ."),
32024            "{:?}",
32025            report.graph_orchestration.follow_up_commands
32026        );
32027        assert!(
32028            !report
32029                .graph_orchestration
32030                .worker_ownership_blocks
32031                .is_empty()
32032        );
32033    }
32034
32035    #[test]
32036    fn convex_sync_report_chunks_upserts_and_tombstones() {
32037        let dir = setup_traversal_project();
32038        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
32039        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
32040        let mut snapshot = projection.to_convex_rows();
32041        snapshot.nodes.push(ConvexNodeRow {
32042            external_id: "stale-node".to_string(),
32043            kind: "backlog".to_string(),
32044            label: "stale".to_string(),
32045            properties: BTreeMap::new(),
32046            provenance: Vec::new(),
32047            freshness: None,
32048        });
32049        snapshot.edges.clear();
32050        snapshot.edges.push(ConvexEdgeRow {
32051            edge_key: "stale-edge".to_string(),
32052            from_external_id: "stale-node".to_string(),
32053            to_external_id: "stale-node".to_string(),
32054            kind: "mentions".to_string(),
32055            properties: BTreeMap::new(),
32056            provenance: Vec::new(),
32057            freshness: None,
32058        });
32059        let snapshot_path = dir.path().join("convex-snapshot.json");
32060        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
32061
32062        let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
32063
32064        assert_eq!(report.freshness.status, "stale");
32065        assert!(report.freshness.fail_closed);
32066        assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
32067        assert!(
32068            report.edge_upserts.len() > 1,
32069            "snapshot without edges should upsert local edges"
32070        );
32071        assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
32072        assert_eq!(
32073            report.chunks.first().map(|chunk| chunk.operation.as_str()),
32074            Some("delete_edges"),
32075            "edge tombstones should be planned before node tombstones"
32076        );
32077        assert!(
32078            report
32079                .chunks
32080                .iter()
32081                .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
32082            "expected chunked edge upserts, got {:?}",
32083            report.chunks
32084        );
32085    }
32086
32087    #[test]
32088    fn convex_snapshot_validation_fails_closed_when_stale() {
32089        let dir = setup_traversal_project();
32090        build_traversal_graph(dir.path(), dir.path(), None).unwrap();
32091        let snapshot = ConvexProjectionRows::default();
32092        let snapshot_path = dir.path().join("empty-convex-snapshot.json");
32093        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
32094
32095        let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
32096        assert!(
32097            err.to_string()
32098                .contains("Convex graph projection is not current"),
32099            "{err}"
32100        );
32101    }
32102
32103    #[test]
32104    fn convex_sync_report_marks_live_apply_mode_without_network() {
32105        let dir = setup_traversal_project();
32106        let report =
32107            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
32108
32109        assert!(!report.dry_run);
32110        assert!(
32111            !report
32112                .diagnostics
32113                .iter()
32114                .any(|diagnostic| diagnostic.contains("dry-run only")),
32115            "apply-mode report should not claim dry-run diagnostics"
32116        );
32117        assert!(
32118            report
32119                .chunks
32120                .iter()
32121                .any(|chunk| chunk.operation == "upsert_nodes"),
32122            "live apply mode should still expose chunked idempotent operations"
32123        );
32124    }
32125
32126    #[test]
32127    fn convex_sync_apply_round_trips_with_http_backend() {
32128        use std::net::TcpListener;
32129        use std::sync::{Arc, Mutex};
32130
32131        let dir = setup_traversal_project();
32132        let report =
32133            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
32134        let expected_chunks = report.chunks.len();
32135        assert!(expected_chunks > 0);
32136
32137        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
32138        let endpoint = format!("http://{}", listener.local_addr().unwrap());
32139        let operations = Arc::new(Mutex::new(Vec::<String>::new()));
32140        let server_operations = Arc::clone(&operations);
32141        let server = std::thread::spawn(move || {
32142            for _ in 0..expected_chunks {
32143                let (mut stream, _) = listener.accept().unwrap();
32144                let mut reader = BufReader::new(stream.try_clone().unwrap());
32145                let mut request_line = String::new();
32146                reader.read_line(&mut request_line).unwrap();
32147                assert!(request_line.starts_with("POST "));
32148
32149                let mut content_length = 0usize;
32150                loop {
32151                    let mut line = String::new();
32152                    reader.read_line(&mut line).unwrap();
32153                    if line == "\r\n" {
32154                        break;
32155                    }
32156                    if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
32157                        content_length = value.trim().parse().unwrap();
32158                    }
32159                }
32160
32161                let mut body = vec![0u8; content_length];
32162                reader.read_exact(&mut body).unwrap();
32163                let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
32164                server_operations
32165                    .lock()
32166                    .unwrap()
32167                    .push(request["operation"].as_str().unwrap().to_string());
32168
32169                let response = br#"{"status":"ok","message":"accepted"}"#;
32170                write!(
32171                    stream,
32172                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
32173                    response.len()
32174                )
32175                .unwrap();
32176                stream.write_all(response).unwrap();
32177            }
32178        });
32179
32180        cmd_convex_sync(
32181            ConvexSyncOptions {
32182                path: dir.path(),
32183                scope: None,
32184                snapshot: None,
32185                chunk_size: 100,
32186                remote_snapshot: false,
32187                apply: true,
32188                endpoint: Some(&endpoint),
32189                auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
32190            },
32191            OutputFormat {
32192                json_output: false,
32193                compact: true,
32194                pretty: false,
32195                terse: false,
32196                ultra_terse: false,
32197                schema: false,
32198                envelope: false,
32199            },
32200        )
32201        .unwrap();
32202        server.join().unwrap();
32203
32204        let operations = operations.lock().unwrap().clone();
32205        assert!(operations.contains(&"upsert_nodes".to_string()));
32206        assert!(operations.contains(&"upsert_edges".to_string()));
32207    }
32208
32209    #[test]
32210    fn context_pack_diff_preview_attaches_tag_ontology_refs() {
32211        let root = tempfile::tempdir().unwrap();
32212        fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
32213        fs::write(
32214            root.path().join(".naming/tags/alpha.md"),
32215            "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
32216        )
32217        .unwrap();
32218        let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
32219        let report = diff_digest::DiffDigestReport {
32220            root: root.path().display().to_string(),
32221            mode: diff_digest::DiffDigestMode::WorkingTree,
32222            revision: None,
32223            files_changed: 1,
32224            files_with_current_summaries: 1,
32225            symbols_touched: 1,
32226            call_edges_added: 0,
32227            call_edges_removed: 0,
32228            files: vec![diff_digest::DiffDigestFile {
32229                path: "src/lib.rs".to_string(),
32230                status: diff_digest::DiffDigestFileStatus::Modified,
32231                touched_symbols: vec!["alpha_helper".to_string()],
32232                summary_state: diff_digest::DiffDigestSummaryState::Current,
32233                current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
32234                    symbol: "alpha_helper".to_string(),
32235                    summary: "alpha helper summary".to_string(),
32236                }],
32237                added_call_edges: vec![],
32238                removed_call_edges: vec![],
32239                warnings: vec![],
32240            }],
32241        };
32242
32243        let preview = build_context_pack_diff_preview(
32244            &report,
32245            ResponseBudget::new(Some(1), Some(80)),
32246            Some(&ontology),
32247        );
32248
32249        let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
32250        assert!(symbol_ref.handle.starts_with("tont-"));
32251        assert_eq!(symbol_ref.tag, "alpha");
32252        assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
32253        assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
32254        assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
32255        assert_eq!(
32256            preview.files[0].summary_refs[0].ontology_refs[0].path,
32257            ".naming/tags/alpha.md"
32258        );
32259    }
32260
32261    #[test]
32262    fn context_pack_test_preview_limits_failure_groups() {
32263        let report = test_digest::TestDigestReport {
32264            root: "/repo".to_string(),
32265            runner: "cargo".to_string(),
32266            failures: 2,
32267            grouped_failures: 2,
32268            counts: test_digest::TestDigestCounts {
32269                passed: Some(8),
32270                failed: Some(2),
32271                skipped: Some(1),
32272            },
32273            failure_groups: vec![
32274                test_digest::TestDigestFailure {
32275                    tests: vec!["suite::alpha_failure".to_string()],
32276                    message: "assertion failed".to_string(),
32277                    path: Some("src/lib.rs".to_string()),
32278                    line: Some(42),
32279                    column: None,
32280                    occurrences: 1,
32281                    summary_state: test_digest::TestDigestSummaryState::Current,
32282                    current_summaries: vec![test_digest::TestDigestSummarySnippet {
32283                        symbol: "alpha_failure".to_string(),
32284                        summary: "failure summary for alpha test".to_string(),
32285                    }],
32286                },
32287                test_digest::TestDigestFailure {
32288                    tests: vec!["suite::beta_failure".to_string()],
32289                    message: "panic".to_string(),
32290                    path: Some("src/main.rs".to_string()),
32291                    line: Some(7),
32292                    column: None,
32293                    occurrences: 1,
32294                    summary_state: test_digest::TestDigestSummaryState::Missing,
32295                    current_summaries: vec![],
32296                },
32297            ],
32298            warnings: vec!["warning text".to_string()],
32299        };
32300
32301        let preview =
32302            build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32303
32304        assert!(preview.truncated);
32305        assert_eq!(preview.failure_groups.len(), 1);
32306        assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
32307        assert_eq!(preview.failure_groups[0].message, "assertion f...");
32308        assert!(
32309            preview.failure_groups[0].summary_refs[0]
32310                .handle
32311                .starts_with("ctsum-")
32312        );
32313        assert_eq!(
32314            preview.failure_groups[0].summary_refs[0].expand,
32315            "tsift summarize --file \"src/lib.rs\""
32316        );
32317        assert_eq!(preview.warnings, vec!["warning text"]);
32318    }
32319
32320    #[test]
32321    fn maybe_attach_log_digest_raw_artifact_persists_bulky_logs() {
32322        let dir = tempfile::tempdir().unwrap();
32323        let root = dir.path();
32324
32325        // Small log: no artifact attached, nothing written.
32326        let small_input = "Compiling serde v1.0.130\n";
32327        let mut small = log_digest::compute(root, small_input).unwrap();
32328        maybe_attach_log_digest_raw_artifact(root, &mut small, small_input).unwrap();
32329        assert!(small.raw_log_artifact.is_none());
32330        assert!(!root.join(".tsift/artifacts").exists());
32331
32332        // Bulky log: artifact persisted with a stable handle and expand command.
32333        let bulky_input = "x".repeat(log_digest::LOG_DIGEST_RAW_ARTIFACT_MIN_BYTES) + "\n";
32334        let mut bulky = log_digest::compute(root, &bulky_input).unwrap();
32335        maybe_attach_log_digest_raw_artifact(root, &mut bulky, &bulky_input).unwrap();
32336        let artifact = bulky
32337            .raw_log_artifact
32338            .expect("artifact attached for bulky log");
32339        assert!(artifact.handle.starts_with("logdg-"));
32340        assert_eq!(artifact.bytes, bulky_input.len());
32341        assert!(artifact.expand.contains("tsift log-digest"));
32342        assert!(artifact.expand.contains("--input"));
32343        let persisted = root.join(&artifact.path);
32344        assert!(persisted.exists(), "artifact file written to {persisted:?}");
32345        assert_eq!(std::fs::read_to_string(&persisted).unwrap(), bulky_input);
32346    }
32347
32348    #[test]
32349    fn context_pack_log_preview_limits_signals_and_refs() {
32350        let report = log_digest::LogDigestReport {
32351            root: "/repo".to_string(),
32352            total_lines: 12,
32353            non_empty_lines: 10,
32354            signal_groups: 2,
32355            error_signal_groups: 1,
32356            repeated_line_groups: 2,
32357            repeated_line_occurrences: 3,
32358            line_family_groups: 0,
32359            file_ref_groups: 2,
32360            symbol_ref_groups: 2,
32361            stack_groups: 1,
32362            signals: vec![
32363                log_digest::LogDigestSignal {
32364                    severity: "error".to_string(),
32365                    message: "src/lib.rs:42 boom".to_string(),
32366                    path: Some("src/lib.rs".to_string()),
32367                    line: Some(42),
32368                    column: None,
32369                    occurrences: 2,
32370                    summary_state: log_digest::LogDigestSummaryState::Current,
32371                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32372                        symbol: "alpha_helper".to_string(),
32373                        summary: "alpha helper cached log summary".to_string(),
32374                    }],
32375                },
32376                log_digest::LogDigestSignal {
32377                    severity: "warn".to_string(),
32378                    message: "slow path".to_string(),
32379                    path: None,
32380                    line: None,
32381                    column: None,
32382                    occurrences: 1,
32383                    summary_state: log_digest::LogDigestSummaryState::Unavailable,
32384                    current_summaries: vec![],
32385                },
32386            ],
32387            repeated_lines: vec![
32388                log_digest::LogDigestRepeatedLine {
32389                    line: "retrying work item alpha".to_string(),
32390                    occurrences: 3,
32391                },
32392                log_digest::LogDigestRepeatedLine {
32393                    line: "retrying work item beta".to_string(),
32394                    occurrences: 2,
32395                },
32396            ],
32397            line_families: vec![],
32398            file_refs: vec![
32399                log_digest::LogDigestFileRef {
32400                    path: "src/lib.rs".to_string(),
32401                    line: Some(42),
32402                    column: None,
32403                    occurrences: 2,
32404                    summary_state: log_digest::LogDigestSummaryState::Current,
32405                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32406                        symbol: "alpha_helper".to_string(),
32407                        summary: "alpha helper cached file summary".to_string(),
32408                    }],
32409                },
32410                log_digest::LogDigestFileRef {
32411                    path: "src/main.rs".to_string(),
32412                    line: Some(7),
32413                    column: None,
32414                    occurrences: 1,
32415                    summary_state: log_digest::LogDigestSummaryState::Missing,
32416                    current_summaries: vec![],
32417                },
32418            ],
32419            symbol_refs: vec![
32420                log_digest::LogDigestSymbolRef {
32421                    symbol: "alpha_helper".to_string(),
32422                    occurrences: 2,
32423                    summary_state: log_digest::LogDigestSummaryState::Current,
32424                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
32425                        symbol: "alpha_helper".to_string(),
32426                        summary: "alpha helper cached symbol summary".to_string(),
32427                    }],
32428                },
32429                log_digest::LogDigestSymbolRef {
32430                    symbol: "beta_helper".to_string(),
32431                    occurrences: 1,
32432                    summary_state: log_digest::LogDigestSummaryState::Missing,
32433                    current_summaries: vec![],
32434                },
32435            ],
32436            stack_traces: vec![log_digest::LogDigestStackGroup {
32437                frames: vec!["frame one".to_string()],
32438                occurrences: 1,
32439            }],
32440            raw_log_artifact: None,
32441            warnings: vec!["warning text".to_string()],
32442        };
32443
32444        let preview =
32445            build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32446
32447        assert!(preview.truncated);
32448        assert_eq!(preview.signals.len(), 1);
32449        assert_eq!(preview.signals[0].message, "src/lib.rs:...");
32450        assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
32451        assert_eq!(preview.file_refs.len(), 1);
32452        assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
32453        assert!(
32454            preview.signals[0].summary_refs[0]
32455                .handle
32456                .starts_with("clsum-")
32457        );
32458        assert!(
32459            preview.file_refs[0].summary_refs[0]
32460                .handle
32461                .starts_with("clfsum-")
32462        );
32463        assert!(
32464            preview.symbol_refs[0].summary_refs[0]
32465                .handle
32466                .starts_with("clssum-")
32467        );
32468        assert_eq!(
32469            preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
32470            Some("alpha/helper")
32471        );
32472        assert_eq!(
32473            preview.symbol_refs[0].summary_refs[0].expand,
32474            "tsift summarize \"alpha_helper\""
32475        );
32476        assert_eq!(preview.warnings, vec!["warning text"]);
32477    }
32478
32479    #[test]
32480    fn cli_search_rejects_exact_with_strategy_flag() {
32481        let cli = try_parse_cli([
32482            "tsift",
32483            "search",
32484            "test",
32485            "--exact",
32486            "--strategy",
32487            "lexical",
32488        ]);
32489        assert!(cli.is_err());
32490    }
32491
32492    #[test]
32493    fn cli_search_autoindexes_by_default() {
32494        let cli = parse_cli(["tsift", "search", "test"]);
32495        match cli.command {
32496            Some(Commands::Search {
32497                autoindex,
32498                no_autoindex,
32499                ..
32500            }) => {
32501                assert!(!autoindex);
32502                assert!(!no_autoindex);
32503                assert!(autoindex || !no_autoindex);
32504            }
32505            _ => panic!("expected Search command"),
32506        }
32507    }
32508
32509    #[test]
32510    fn cli_local_model_status_accepts_json_and_no_probe() {
32511        let cli = parse_cli(["tsift", "local-model", "status", "--json", "--no-probe"]);
32512        match cli.command {
32513            Some(Commands::LocalModel {
32514                command: LocalModelCommand::Status { json, no_probe },
32515            }) => {
32516                assert!(json);
32517                assert!(no_probe);
32518            }
32519            _ => panic!("expected LocalModel status command"),
32520        }
32521    }
32522
32523    #[test]
32524    fn cli_local_model_unload_accepts_probe_and_strict_flags() {
32525        let cli = parse_cli([
32526            "tsift",
32527            "local-model",
32528            "unload",
32529            "--profile",
32530            "qwen3-32b-q4",
32531            "--pre-used-mib",
32532            "200",
32533            "--post-used-mib",
32534            "800",
32535            "--provider-pid",
32536            "42",
32537            "--strict",
32538            "--json",
32539        ]);
32540        match cli.command {
32541            Some(Commands::LocalModel {
32542                command:
32543                    LocalModelCommand::Unload {
32544                        profile,
32545                        provider_pid,
32546                        pre_used_mib,
32547                        post_used_mib,
32548                        strict,
32549                        json,
32550                        ..
32551                    },
32552            }) => {
32553                assert_eq!(profile, "qwen3-32b-q4");
32554                assert_eq!(provider_pid, Some(42));
32555                assert_eq!(pre_used_mib, Some(200));
32556                assert_eq!(post_used_mib, Some(800));
32557                assert!(strict);
32558                assert!(json);
32559            }
32560            _ => panic!("expected LocalModel unload command"),
32561        }
32562    }
32563
32564    #[test]
32565    fn cli_local_model_swap_parses_flags() {
32566        let cli = parse_cli([
32567            "tsift",
32568            "local-model",
32569            "swap",
32570            "--from",
32571            "qwen3-32b-q4",
32572            "--to",
32573            "qwen3-embedding-0.6b",
32574            "--provider-pid",
32575            "42",
32576            "--pre-used-mib",
32577            "200",
32578            "--post-used-mib",
32579            "180",
32580            "--strict",
32581            "--json",
32582        ]);
32583        match cli.command {
32584            Some(Commands::LocalModel {
32585                command:
32586                    LocalModelCommand::Swap {
32587                        from,
32588                        to,
32589                        provider_pid,
32590                        pre_used_mib,
32591                        post_used_mib,
32592                        strict,
32593                        json,
32594                        ..
32595                    },
32596            }) => {
32597                assert_eq!(from, "qwen3-32b-q4");
32598                assert_eq!(to, "qwen3-embedding-0.6b");
32599                assert_eq!(provider_pid, Some(42));
32600                assert_eq!(pre_used_mib, Some(200));
32601                assert_eq!(post_used_mib, Some(180));
32602                assert!(strict);
32603                assert!(json);
32604            }
32605            _ => panic!("expected LocalModel swap command"),
32606        }
32607    }
32608
32609    #[test]
32610    fn cli_local_model_resolve_parses_flags() {
32611        use cli::ResolveRole;
32612        let cli = parse_cli([
32613            "tsift",
32614            "local-model",
32615            "resolve",
32616            "--profile",
32617            "hash",
32618            "--role",
32619            "embed",
32620            "--no-probe",
32621            "--json",
32622        ]);
32623        match cli.command {
32624            Some(Commands::LocalModel {
32625                command:
32626                    LocalModelCommand::Resolve {
32627                        profile,
32628                        role,
32629                        no_probe,
32630                        json,
32631                    },
32632            }) => {
32633                assert_eq!(profile.as_deref(), Some("hash"));
32634                assert_eq!(role, ResolveRole::Embed);
32635                assert!(no_probe);
32636                assert!(json);
32637            }
32638            _ => panic!("expected LocalModel resolve command"),
32639        }
32640    }
32641
32642    #[test]
32643    fn cli_semantic_command_accepts_profile_flag() {
32644        let cli = parse_cli([
32645            "tsift",
32646            "semantic",
32647            "auth",
32648            "--profile",
32649            "qwen3-embedding-0.6b",
32650            "--json",
32651        ]);
32652        match cli.command {
32653            Some(Commands::Semantic { profile, query, .. }) => {
32654                assert_eq!(query, "auth");
32655                assert_eq!(profile.as_deref(), Some("qwen3-embedding-0.6b"));
32656            }
32657            _ => panic!("expected Semantic command"),
32658        }
32659    }
32660
32661    #[test]
32662    fn cli_summarize_command_accepts_profile_flag() {
32663        let cli = parse_cli([
32664            "tsift",
32665            "summarize",
32666            "--extract",
32667            "src",
32668            "--profile",
32669            "hash",
32670            "--json",
32671        ]);
32672        match cli.command {
32673            Some(Commands::Summarize {
32674                extract,
32675                profile,
32676                json,
32677                ..
32678            }) => {
32679                assert_eq!(extract.as_deref(), Some(std::path::Path::new("src")));
32680                assert_eq!(profile.as_deref(), Some("hash"));
32681                assert!(json);
32682            }
32683            _ => panic!("expected Summarize command"),
32684        }
32685    }
32686
32687    #[test]
32688    fn cli_local_model_lease_acquire_parses_flags() {
32689        let cli = parse_cli([
32690            "tsift",
32691            "local-model",
32692            "lease",
32693            "acquire",
32694            "--profile",
32695            "qwen3-32b-q4",
32696            "--holder-pid",
32697            "4242",
32698            "--holder-command",
32699            "corky",
32700            "--idle-ttl-seconds",
32701            "120",
32702            "--vram-baseline-mib",
32703            "200",
32704            "--lease-file",
32705            "/tmp/tsift-lease.json",
32706            "--strict",
32707            "--json",
32708        ]);
32709        match cli.command {
32710            Some(Commands::LocalModel {
32711                command:
32712                    LocalModelCommand::Lease {
32713                        command:
32714                            LeaseCommand::Acquire {
32715                                profile,
32716                                holder_pid,
32717                                holder_command,
32718                                idle_ttl_seconds,
32719                                vram_baseline_mib,
32720                                lease_file,
32721                                strict,
32722                                json,
32723                                ..
32724                            },
32725                    },
32726            }) => {
32727                assert_eq!(profile, "qwen3-32b-q4");
32728                assert_eq!(holder_pid, Some(4242));
32729                assert_eq!(holder_command, "corky");
32730                assert_eq!(idle_ttl_seconds, 120);
32731                assert_eq!(vram_baseline_mib, Some(200));
32732                assert_eq!(lease_file, Some(PathBuf::from("/tmp/tsift-lease.json")));
32733                assert!(strict);
32734                assert!(json);
32735            }
32736            _ => panic!("expected LocalModel lease acquire command"),
32737        }
32738    }
32739
32740    #[test]
32741    fn cli_local_model_lease_release_parses_flags() {
32742        let cli = parse_cli([
32743            "tsift",
32744            "local-model",
32745            "lease",
32746            "release",
32747            "--profile",
32748            "qwen3-embedding-0.6b",
32749            "--holder-pid",
32750            "999",
32751            "--json",
32752        ]);
32753        match cli.command {
32754            Some(Commands::LocalModel {
32755                command:
32756                    LocalModelCommand::Lease {
32757                        command:
32758                            LeaseCommand::Release {
32759                                profile,
32760                                holder_pid,
32761                                json,
32762                                ..
32763                            },
32764                    },
32765            }) => {
32766                assert_eq!(profile, "qwen3-embedding-0.6b");
32767                assert_eq!(holder_pid, Some(999));
32768                assert!(json);
32769            }
32770            _ => panic!("expected LocalModel lease release command"),
32771        }
32772    }
32773
32774    #[test]
32775    fn cli_local_model_lease_show_parses_flags() {
32776        let cli = parse_cli([
32777            "tsift",
32778            "local-model",
32779            "lease",
32780            "show",
32781            "--include-stale",
32782            "--json",
32783        ]);
32784        match cli.command {
32785            Some(Commands::LocalModel {
32786                command:
32787                    LocalModelCommand::Lease {
32788                        command:
32789                            LeaseCommand::Show {
32790                                include_stale,
32791                                json,
32792                                ..
32793                            },
32794                    },
32795            }) => {
32796                assert!(include_stale);
32797                assert!(json);
32798            }
32799            _ => panic!("expected LocalModel lease show command"),
32800        }
32801    }
32802
32803    #[test]
32804    fn cli_search_accepts_no_autoindex_flag() {
32805        let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
32806        match cli.command {
32807            Some(Commands::Search {
32808                autoindex,
32809                no_autoindex,
32810                ..
32811            }) => {
32812                assert!(!autoindex);
32813                assert!(no_autoindex);
32814            }
32815            _ => panic!("expected Search command"),
32816        }
32817    }
32818
32819    #[test]
32820    fn cli_search_rejects_conflicting_autoindex_flags() {
32821        let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
32822        assert!(cli.is_err());
32823    }
32824
32825    // --- relativize paths ---
32826
32827    #[test]
32828    fn cli_accepts_global_absolute_flag() {
32829        let cli = parse_cli(["tsift", "--absolute", "status"]);
32830        assert!(cli.absolute);
32831        assert!(matches!(cli.command, Some(Commands::Status { .. })));
32832    }
32833
32834    #[test]
32835    fn cli_accepts_global_tabular_flag() {
32836        let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
32837        assert!(cli.tabular);
32838        assert!(matches!(cli.command, Some(Commands::Search { .. })));
32839    }
32840
32841    #[test]
32842    fn cli_tabular_with_graph() {
32843        let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
32844        assert!(cli.tabular);
32845        assert!(matches!(cli.command, Some(Commands::Graph { .. })));
32846    }
32847
32848    #[test]
32849    fn cli_tabular_with_communities() {
32850        let cli = parse_cli(["tsift", "--tabular", "communities"]);
32851        assert!(cli.tabular);
32852        assert!(matches!(cli.command, Some(Commands::Communities { .. })));
32853    }
32854
32855    #[test]
32856    fn cli_tabular_with_explain() {
32857        let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
32858        assert!(cli.tabular);
32859        assert!(matches!(cli.command, Some(Commands::Explain { .. })));
32860    }
32861
32862    #[test]
32863    fn cli_traverse_accepts_path_target_and_html_format() {
32864        let cli = parse_cli([
32865            "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
32866        ]);
32867        match cli.command {
32868            Some(Commands::Traverse {
32869                node,
32870                to,
32871                path,
32872                format,
32873                ..
32874            }) => {
32875                assert_eq!(node.as_deref(), Some("#kgnv"));
32876                assert_eq!(to.as_deref(), Some("main"));
32877                assert_eq!(path, PathBuf::from("."));
32878                assert_eq!(format, TraverseFormat::Html);
32879            }
32880            _ => panic!("expected Traverse command"),
32881        }
32882    }
32883
32884    #[test]
32885    fn cli_parses_semantic_related_command() {
32886        let cli = parse_cli([
32887            "tsift",
32888            "semantic",
32889            "graph navigation",
32890            "--path",
32891            ".",
32892            "--kind",
32893            "all",
32894            "--limit",
32895            "3",
32896            "--json",
32897        ]);
32898        match cli.command {
32899            Some(Commands::Semantic {
32900                query,
32901                path,
32902                kind,
32903                limit,
32904                json,
32905                ..
32906            }) => {
32907                assert_eq!(query, "graph navigation");
32908                assert_eq!(path, PathBuf::from("."));
32909                assert_eq!(kind, SemanticRelatedKind::All);
32910                assert_eq!(limit, 3);
32911                assert!(json);
32912            }
32913            _ => panic!("expected Semantic command"),
32914        }
32915    }
32916
32917    #[test]
32918    fn cli_parses_convex_sync_command() {
32919        let cli = parse_cli([
32920            "tsift",
32921            "convex-sync",
32922            ".",
32923            "--snapshot",
32924            "rows.json",
32925            "--chunk-size",
32926            "25",
32927            "--json",
32928        ]);
32929        match cli.command {
32930            Some(Commands::ConvexSync {
32931                path,
32932                snapshot,
32933                chunk_size,
32934                json,
32935                ..
32936            }) => {
32937                assert_eq!(path, PathBuf::from("."));
32938                assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
32939                assert_eq!(chunk_size, 25);
32940                assert!(json);
32941            }
32942            _ => panic!("expected ConvexSync command"),
32943        }
32944    }
32945
32946    #[test]
32947    fn cli_parses_convex_sync_live_flags() {
32948        let cli = parse_cli([
32949            "tsift",
32950            "convex-sync",
32951            ".",
32952            "--remote-snapshot",
32953            "--apply",
32954            "--endpoint",
32955            "https://example.test/convex-graph",
32956            "--auth-token-env",
32957            "TSIFT_TEST_TOKEN",
32958        ]);
32959        match cli.command {
32960            Some(Commands::ConvexSync {
32961                remote_snapshot,
32962                apply,
32963                endpoint,
32964                auth_token_env,
32965                ..
32966            }) => {
32967                assert!(remote_snapshot);
32968                assert!(apply);
32969                assert_eq!(
32970                    endpoint.as_deref(),
32971                    Some("https://example.test/convex-graph")
32972                );
32973                assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
32974            }
32975            _ => panic!("expected ConvexSync command"),
32976        }
32977    }
32978
32979    #[test]
32980    fn cli_parses_graph_db_query() {
32981        let cli = parse_cli([
32982            "tsift",
32983            "graph-db",
32984            "--backend",
32985            "convex-snapshot",
32986            "--convex-snapshot",
32987            "rows.json",
32988            "--json",
32989            "neighborhood",
32990            "gbak-kgnv",
32991            "--depth",
32992            "2",
32993            "--edge-kind",
32994            "mentions",
32995            "--property",
32996            "path=tasks/software/tsift.md",
32997            "--cursor",
32998            "gbak-old",
32999            "--limit",
33000            "10",
33001        ]);
33002        match cli.command {
33003            Some(Commands::GraphDb {
33004                backend,
33005                convex_snapshot,
33006                json,
33007                query,
33008                ..
33009            }) => {
33010                assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
33011                assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
33012                assert!(json);
33013                match query {
33014                    GraphDbQuery::Neighborhood {
33015                        id,
33016                        depth,
33017                        edge_kind,
33018                        cursor,
33019                        limit,
33020                        property_filters,
33021                    } => {
33022                        assert_eq!(id, "gbak-kgnv");
33023                        assert_eq!(depth, 2);
33024                        assert_eq!(edge_kind.as_deref(), Some("mentions"));
33025                        assert_eq!(cursor.as_deref(), Some("gbak-old"));
33026                        assert_eq!(limit, Some(10));
33027                        assert_eq!(
33028                            property_filters,
33029                            vec!["path=tasks/software/tsift.md".to_string()]
33030                        );
33031                    }
33032                    _ => panic!("expected graph-db neighborhood query"),
33033                }
33034            }
33035            _ => panic!("expected GraphDb command"),
33036        }
33037    }
33038
33039    #[test]
33040    fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
33041        let cli = parse_cli([
33042            "tsift",
33043            "graph-db",
33044            "--json",
33045            "backend-eval",
33046            "--candidate",
33047            "surrealdb",
33048            "--target",
33049            "gval",
33050            "--full-projection",
33051        ]);
33052        match cli.command {
33053            Some(Commands::GraphDb { json, query, .. }) => {
33054                assert!(json);
33055                match query {
33056                    GraphDbQuery::BackendEval {
33057                        candidates,
33058                        targets,
33059                        full_projection,
33060                    } => {
33061                        assert_eq!(candidates, vec!["surrealdb".to_string()]);
33062                        assert_eq!(targets, vec!["gval".to_string()]);
33063                        assert!(full_projection);
33064                    }
33065                    _ => panic!("expected graph-db backend-eval query"),
33066                }
33067            }
33068            _ => panic!("expected GraphDb command"),
33069        }
33070    }
33071
33072    #[test]
33073    fn cli_parses_graph_db_tokensave_backend() {
33074        let cli = parse_cli([
33075            "tsift",
33076            "graph-db",
33077            "--backend",
33078            "tokensave",
33079            "--json",
33080            "node",
33081            "fn:main",
33082        ]);
33083        match cli.command {
33084            Some(Commands::GraphDb {
33085                backend,
33086                json,
33087                query,
33088                ..
33089            }) => {
33090                assert_eq!(backend, GraphDbBackend::Tokensave);
33091                assert!(json);
33092                match query {
33093                    GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
33094                    _ => panic!("expected graph-db node query"),
33095                }
33096            }
33097            _ => panic!("expected GraphDb command"),
33098        }
33099    }
33100
33101    #[test]
33102    fn cli_parses_analyze_command() {
33103        let cli = parse_cli([
33104            "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
33105            "--limit", "7", "--json",
33106        ]);
33107        match cli.command {
33108            Some(Commands::Analyze {
33109                path,
33110                scope,
33111                entry_points,
33112                limit,
33113                json,
33114            }) => {
33115                assert_eq!(path, PathBuf::from("."));
33116                assert_eq!(scope.as_deref(), Some("core"));
33117                assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
33118                assert_eq!(limit, 7);
33119                assert!(json);
33120            }
33121            _ => panic!("expected Analyze command"),
33122        }
33123    }
33124
33125    #[test]
33126    fn cli_parses_graph_db_related_query() {
33127        let cli = parse_cli([
33128            "tsift",
33129            "graph-db",
33130            "--json",
33131            "related",
33132            "voice avatar memory retrieval",
33133            "--kind",
33134            "all",
33135            "--depth",
33136            "3",
33137            "--seed-limit",
33138            "4",
33139            "--limit",
33140            "12",
33141        ]);
33142        match cli.command {
33143            Some(Commands::GraphDb { json, query, .. }) => {
33144                assert!(json);
33145                match query {
33146                    GraphDbQuery::Related {
33147                        query,
33148                        kind,
33149                        depth,
33150                        seed_limit,
33151                        limit,
33152                    } => {
33153                        assert_eq!(query, "voice avatar memory retrieval");
33154                        assert_eq!(kind, SemanticRelatedKind::All);
33155                        assert_eq!(depth, 3);
33156                        assert_eq!(seed_limit, 4);
33157                        assert_eq!(limit, 12);
33158                    }
33159                    _ => panic!("expected graph-db related query"),
33160                }
33161            }
33162            _ => panic!("expected GraphDb command"),
33163        }
33164    }
33165
33166    #[test]
33167    fn cli_parses_graph_db_compact_query() {
33168        let cli = parse_cli([
33169            "tsift",
33170            "graph-db",
33171            "--path",
33172            ".",
33173            "compact",
33174            "--apply",
33175            "--prune-tombstones",
33176            "--confirmed-convex-reconciled",
33177        ]);
33178        match cli.command {
33179            Some(Commands::GraphDb { query, .. }) => match query {
33180                GraphDbQuery::Compact {
33181                    apply,
33182                    prune_tombstones,
33183                    confirmed_convex_reconciled,
33184                } => {
33185                    assert!(apply);
33186                    assert!(prune_tombstones);
33187                    assert!(confirmed_convex_reconciled);
33188                }
33189                _ => panic!("expected graph-db compact query"),
33190            },
33191            _ => panic!("expected GraphDb command"),
33192        }
33193    }
33194
33195    #[test]
33196    fn cli_parses_graph_db_snapshot_queries() {
33197        let export_cli = parse_cli([
33198            "tsift",
33199            "graph-db",
33200            "--json",
33201            "snapshot-export",
33202            "graph.db.gz",
33203            "--force",
33204        ]);
33205        match export_cli.command {
33206            Some(Commands::GraphDb { json, query, .. }) => {
33207                assert!(json);
33208                match query {
33209                    GraphDbQuery::SnapshotExport { output, force } => {
33210                        assert_eq!(output, PathBuf::from("graph.db.gz"));
33211                        assert!(force);
33212                    }
33213                    _ => panic!("expected graph-db snapshot-export query"),
33214                }
33215            }
33216            _ => panic!("expected GraphDb command"),
33217        }
33218
33219        let import_cli = parse_cli([
33220            "tsift",
33221            "graph-db",
33222            "snapshot-import",
33223            "graph.db.gz",
33224            "--replace",
33225        ]);
33226        match import_cli.command {
33227            Some(Commands::GraphDb { query, .. }) => match query {
33228                GraphDbQuery::SnapshotImport { artifact, replace } => {
33229                    assert_eq!(artifact, PathBuf::from("graph.db.gz"));
33230                    assert!(replace);
33231                }
33232                _ => panic!("expected graph-db snapshot-import query"),
33233            },
33234            _ => panic!("expected GraphDb command"),
33235        }
33236    }
33237
33238    #[test]
33239    fn cli_parses_impact_command() {
33240        let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
33241        match cli.command {
33242            Some(Commands::Impact {
33243                path,
33244                cached,
33245                limit,
33246                ..
33247            }) => {
33248                assert_eq!(path, PathBuf::from("."));
33249                assert!(cached);
33250                assert_eq!(limit, 5);
33251            }
33252            _ => panic!("expected Impact command"),
33253        }
33254    }
33255
33256    #[test]
33257    fn cli_parses_conflict_matrix_command() {
33258        let cli = parse_cli([
33259            "tsift",
33260            "conflict-matrix",
33261            "--path",
33262            "tasks/software/tsift.md",
33263            "--depth",
33264            "4",
33265            "--limit",
33266            "12",
33267            "--impact-limit",
33268            "6",
33269            "--json",
33270            "pwcm",
33271            "#g6kf",
33272        ]);
33273        match cli.command {
33274            Some(Commands::ConflictMatrix {
33275                targets,
33276                path,
33277                depth,
33278                limit,
33279                impact_limit,
33280                json,
33281                ..
33282            }) => {
33283                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33284                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33285                assert_eq!(depth, 4);
33286                assert_eq!(limit, 12);
33287                assert_eq!(impact_limit, 6);
33288                assert!(json);
33289            }
33290            _ => panic!("expected ConflictMatrix command"),
33291        }
33292    }
33293
33294    #[test]
33295    fn cli_parses_dispatch_trace_command() {
33296        let cli = parse_cli([
33297            "tsift",
33298            "dispatch-trace",
33299            "--path",
33300            "tasks/software/tsift.md",
33301            "--format",
33302            "html",
33303            "--depth",
33304            "4",
33305            "pwcm",
33306            "#g6kf",
33307        ]);
33308        match cli.command {
33309            Some(Commands::DispatchTrace {
33310                targets,
33311                path,
33312                format,
33313                depth,
33314                ..
33315            }) => {
33316                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33317                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33318                assert_eq!(format, DispatchTraceFormat::Html);
33319                assert_eq!(depth, 4);
33320            }
33321            _ => panic!("expected DispatchTrace command"),
33322        }
33323    }
33324
33325    #[test]
33326    fn cli_parses_dependency_dag_command() {
33327        let cli = parse_cli([
33328            "tsift",
33329            "dependency-dag",
33330            "--path",
33331            "tasks/software/tsift.md",
33332            "--depth",
33333            "5",
33334            "--limit",
33335            "20",
33336            "--json",
33337            "alpha",
33338            "#beta",
33339        ]);
33340        match cli.command {
33341            Some(Commands::DependencyDag {
33342                targets,
33343                path,
33344                depth,
33345                limit,
33346                json,
33347                ..
33348            }) => {
33349                assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
33350                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33351                assert_eq!(depth, 5);
33352                assert_eq!(limit, 20);
33353                assert!(json);
33354            }
33355            _ => panic!("expected DependencyDag command"),
33356        }
33357    }
33358
33359    #[test]
33360    fn relativize_strips_root_prefix() {
33361        let root = std::path::Path::new("/home/user/project");
33362        assert_eq!(
33363            relativize("/home/user/project/src/main.rs", root),
33364            "src/main.rs"
33365        );
33366    }
33367
33368    #[test]
33369    fn relativize_leaves_non_matching_path() {
33370        let root = std::path::Path::new("/home/user/project");
33371        assert_eq!(
33372            relativize("/other/path/file.rs", root),
33373            "/other/path/file.rs"
33374        );
33375    }
33376
33377    #[test]
33378    fn relativize_leaves_already_relative() {
33379        let root = std::path::Path::new("/home/user/project");
33380        assert_eq!(relativize("src/main.rs", root), "src/main.rs");
33381    }
33382
33383    #[test]
33384    fn relativize_pathbuf_strips_prefix() {
33385        let root = std::path::Path::new("/home/user/project");
33386        let path = std::path::Path::new("/home/user/project/src/lib.rs");
33387        assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
33388    }
33389
33390    #[test]
33391    fn relativize_edges_strips_caller_file() {
33392        let root = std::path::Path::new("/tmp/proj");
33393        let mut edges = vec![index::StoredEdge {
33394            caller_file: "/tmp/proj/src/main.rs".to_string(),
33395            caller_name: "main".to_string(),
33396            caller_line: 1,
33397            callee_name: "helper".to_string(),
33398            call_site_line: 5,
33399            tagpath_handle: None,
33400        }];
33401        relativize_edges(&mut edges, root);
33402        assert_eq!(edges[0].caller_file, "src/main.rs");
33403    }
33404
33405    #[test]
33406    fn relativize_json_paths_strips_known_keys() {
33407        let root = std::path::Path::new("/tmp/proj");
33408        let mut val = serde_json::json!({
33409            "file": "/tmp/proj/src/main.rs",
33410            "path": "/tmp/proj/test.rs",
33411            "name": "/tmp/proj/not-a-path",
33412            "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
33413        });
33414        relativize_json_paths(&mut val, root);
33415        assert_eq!(val["file"], "src/main.rs");
33416        assert_eq!(val["path"], "test.rs");
33417        assert_eq!(val["name"], "/tmp/proj/not-a-path");
33418        assert_eq!(val["hits"][0]["path"], "nested.rs");
33419    }
33420
33421    // --- limit caps ---
33422
33423    #[test]
33424    fn cli_graph_accepts_limit_flag() {
33425        let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
33426        match cli.command {
33427            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
33428            _ => panic!("expected Graph command"),
33429        }
33430    }
33431
33432    #[test]
33433    fn cli_graph_default_limit_is_20() {
33434        let cli = parse_cli(["tsift", "graph", "main"]);
33435        match cli.command {
33436            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
33437            _ => panic!("expected Graph command"),
33438        }
33439    }
33440
33441    #[test]
33442    fn cli_communities_accepts_limit_flag() {
33443        let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
33444        match cli.command {
33445            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
33446            _ => panic!("expected Communities command"),
33447        }
33448    }
33449
33450    #[test]
33451    fn cli_communities_default_limit_is_10() {
33452        let cli = parse_cli(["tsift", "communities"]);
33453        match cli.command {
33454            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
33455            _ => panic!("expected Communities command"),
33456        }
33457    }
33458
33459    #[test]
33460    fn cli_explain_accepts_limit_flag() {
33461        let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
33462        match cli.command {
33463            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
33464            _ => panic!("expected Explain command"),
33465        }
33466    }
33467
33468    #[test]
33469    fn cli_explain_default_limit_is_15() {
33470        let cli = parse_cli(["tsift", "explain", "main"]);
33471        match cli.command {
33472            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
33473            _ => panic!("expected Explain command"),
33474        }
33475    }
33476
33477    #[test]
33478    fn cli_limit_zero_means_unlimited() {
33479        let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
33480        match cli.command {
33481            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
33482            _ => panic!("expected Graph command"),
33483        }
33484    }
33485
33486    #[test]
33487    fn graph_cmd_limit_runs_ok() {
33488        let dir = setup_graph_index();
33489        let result = cmd_graph(
33490            "main",
33491            dir.path(),
33492            false,
33493            false,
33494            None,
33495            1,
33496            false,
33497            false,
33498            false,
33499            false,
33500            false,
33501            false,
33502            false,
33503            TagpathSearchOpts::default(),
33504        );
33505        assert!(result.is_ok());
33506    }
33507
33508    #[test]
33509    fn graph_cmd_unlimited_runs_ok() {
33510        let dir = setup_graph_index();
33511        let result = cmd_graph(
33512            "main",
33513            dir.path(),
33514            false,
33515            false,
33516            None,
33517            0,
33518            false,
33519            false,
33520            false,
33521            false,
33522            false,
33523            false,
33524            false,
33525            TagpathSearchOpts::default(),
33526        );
33527        assert!(result.is_ok());
33528    }
33529
33530    #[test]
33531    fn graph_cmd_tabular_runs_ok() {
33532        let dir = setup_graph_index();
33533        let result = cmd_graph(
33534            "main",
33535            dir.path(),
33536            false,
33537            false,
33538            None,
33539            20,
33540            false,
33541            false,
33542            false,
33543            false,
33544            false,
33545            true,
33546            false,
33547            TagpathSearchOpts::default(),
33548        );
33549        assert!(result.is_ok());
33550    }
33551
33552    #[test]
33553    fn communities_cmd_tabular_runs_ok() {
33554        let dir = setup_graph_index();
33555        let result = cmd_communities(
33556            dir.path(),
33557            None,
33558            1,
33559            10,
33560            false,
33561            false,
33562            false,
33563            false,
33564            true,
33565            false,
33566            TagpathSearchOpts::default(),
33567        );
33568        assert!(result.is_ok());
33569    }
33570
33571    #[test]
33572    fn explain_cmd_tabular_runs_ok() {
33573        let dir = setup_graph_index();
33574        let result = cmd_explain(
33575            "main",
33576            dir.path(),
33577            None,
33578            15,
33579            false,
33580            false,
33581            false,
33582            false,
33583            false,
33584            true,
33585            false,
33586            false,
33587        );
33588        assert!(result.is_ok());
33589    }
33590
33591    #[test]
33592    fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
33593        // #gdbcacheprove: .agent-doc runtime markdown (snapshots, baselines, archives,
33594        // session docs, runtime logs) must not contribute to the source watermark, or
33595        // every agent-doc cycle would invalidate the graph-db backend-eval cache and
33596        // force a full rebuild on the next run.
33597        let cases = [
33598            ".agent-doc",
33599            ".agent-doc/snapshots/abc.md",
33600            ".agent-doc/baselines/abc.md",
33601            ".agent-doc/archives/2026.md",
33602            ".agent-doc/runtime/run.jsonl",
33603            "src/foo/.agent-doc",
33604            "src/foo/.agent-doc/snapshots/x.md",
33605            "./.agent-doc/snapshots/x.md",
33606        ];
33607        for path in cases {
33608            assert!(
33609                traversal_relative_path_is_generated_artifact(path),
33610                "expected `{path}` to be excluded from source watermark"
33611            );
33612        }
33613        // Real source paths must NOT be excluded.
33614        for path in [
33615            "src/main.rs",
33616            "tests/perf_gate.rs",
33617            "fixtures/x.json",
33618            "agent-doc/src/lib.rs", // sibling dir without the leading dot
33619            "src/.agent-doc-helper.rs",
33620        ] {
33621            assert!(
33622                !traversal_relative_path_is_generated_artifact(path),
33623                "expected `{path}` to be included in source watermark"
33624            );
33625        }
33626    }
33627
33628    #[test]
33629    fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
33630        // #cachelookupshift: the conflict-matrix preparation cache key hashes
33631        // file_state snapshot rows + every markdown file under the root. Any
33632        // .tsift/, target/, or .agent-doc/ path slipping past the filter would
33633        // shift the watermark every run because those directories mutate as a
33634        // side effect of running tsift itself. This test locks the artifact
33635        // filter against regressions for each prefix variant
33636        // (bare, root-anchored, nested, and './' leading).
33637        let cases = [
33638            ".tsift",
33639            ".tsift/index.db",
33640            ".tsift/indexes/foo/index.db",
33641            ".tsift/conflict-matrix-cache/inputs/abc.json",
33642            ".tsift/summaries.db",
33643            "src/foo/.tsift",
33644            "src/foo/.tsift/graph.db",
33645            "./.tsift/index.db",
33646            "target",
33647            "target/debug/build/x",
33648            "target/release/tsift",
33649            "src/foo/target/debug/x",
33650            "./target/release/x",
33651        ];
33652        for path in cases {
33653            assert!(
33654                traversal_relative_path_is_generated_artifact(path),
33655                "expected `{path}` to be excluded from source watermark"
33656            );
33657        }
33658        // Look-alike paths must NOT be excluded — only true artifact dirs.
33659        for path in [
33660            "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
33661            "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
33662            "src/tsift-extras/lib.rs",
33663            "tsift/README.md",
33664            "src/targeting.rs",
33665            "src/.tsiftrc",
33666            "src/agent-doc-helper.rs",
33667        ] {
33668            assert!(
33669                !traversal_relative_path_is_generated_artifact(path),
33670                "expected `{path}` to be included in source watermark"
33671            );
33672        }
33673    }
33674
33675    #[test]
33676    fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
33677        // #cachelookupshift: the conflict-matrix preparation cache only hits
33678        // when traversal_source_watermark returns the same hash for two
33679        // consecutive calls on identical source state. Lock that invariant so
33680        // a future change that folds wall-clock time, a directory mtime, or
33681        // any other non-content input into the hash trips this test before
33682        // regressing the preparation_cache_lookup hit rate. We exercise the
33683        // session_only=true path with a hinted markdown file so the test does
33684        // not need a full index DB to drive the index-snapshot branch.
33685        let dir = tempfile::tempdir().unwrap();
33686        let root = dir.path();
33687        std::fs::create_dir_all(root.join("src")).unwrap();
33688        std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
33689        let hint = root.join("README.md");
33690        std::fs::write(&hint, "# stable\n").unwrap();
33691        // Add a generated-artifact directory that must NOT affect the watermark.
33692        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33693        std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
33694        std::fs::create_dir_all(root.join("target/debug")).unwrap();
33695        std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
33696
33697        let first = traversal_source_watermark(root, &hint, None, true)
33698            .expect("first watermark call must succeed")
33699            .expect("first watermark must produce a hash for hinted markdown");
33700        let second = traversal_source_watermark(root, &hint, None, true)
33701            .expect("second watermark call must succeed")
33702            .expect("second watermark must produce a hash for hinted markdown");
33703        assert_eq!(
33704            first, second,
33705            "watermark must be identical across back-to-back invocations on a quiescent root"
33706        );
33707
33708        // Mutating a generated-artifact file must NOT shift the hash.
33709        std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
33710        std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
33711        let third = traversal_source_watermark(root, &hint, None, true)
33712            .expect("third watermark call must succeed")
33713            .expect("third watermark must produce a hash for hinted markdown");
33714        assert_eq!(
33715            first, third,
33716            "watermark must ignore mutations under .tsift/ and target/"
33717        );
33718
33719        // Mutating the hinted markdown file MUST shift the hash so the
33720        // preparation cache invalidates correctly when user state changes.
33721        // Sleep briefly to push the file mtime past the original even on
33722        // coarse-resolution filesystems.
33723        std::thread::sleep(std::time::Duration::from_millis(20));
33724        std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
33725        let fourth = traversal_source_watermark(root, &hint, None, true)
33726            .expect("fourth watermark call must succeed")
33727            .expect("fourth watermark must produce a hash for hinted markdown");
33728        assert_ne!(
33729            first, fourth,
33730            "watermark must invalidate when the hinted markdown file changes"
33731        );
33732    }
33733
33734    #[test]
33735    fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
33736        // #gcachemiss: full-projection cache keys must not miss just because the
33737        // SQLite summary cache file header or mtime churned. Only the semantic rows
33738        // that feed traversal projection should participate in the source watermark.
33739        let dir = tempfile::tempdir().unwrap();
33740        let root = dir.path();
33741        std::fs::write(root.join("README.md"), "# stable\n").unwrap();
33742        let summaries_db_path = root.join(".tsift/summaries.db");
33743        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33744        let mut summary = summarize::Summary {
33745            id: 0,
33746            symbol_name: "main".to_string(),
33747            file_path: "src/main.rs".to_string(),
33748            content_hash: "hash-main".to_string(),
33749            summary: "main wires the CLI".to_string(),
33750            entities: Some(vec![summarize::Entity {
33751                name: "Cli".to_string(),
33752                kind: "type".to_string(),
33753                description: "Command-line interface".to_string(),
33754            }]),
33755            relationships: None,
33756            concept_labels: Some(vec!["cli".to_string()]),
33757            extracted_at: "1700000000".to_string(),
33758            model: "test-model".to_string(),
33759            tokens_input: Some(10),
33760            tokens_output: Some(5),
33761        };
33762        summary_db.insert(&summary).unwrap();
33763        drop(summary_db);
33764
33765        let hint = root.join("README.md");
33766        let first = traversal_source_watermark(root, &hint, None, true)
33767            .expect("first watermark call must succeed")
33768            .expect("first watermark must produce a hash");
33769
33770        std::thread::sleep(std::time::Duration::from_millis(20));
33771        let conn = Connection::open(&summaries_db_path).unwrap();
33772        conn.pragma_update(None, "user_version", 1).unwrap();
33773        conn.pragma_update(None, "user_version", 0).unwrap();
33774        drop(conn);
33775
33776        let second = traversal_source_watermark(root, &hint, None, true)
33777            .expect("second watermark call must succeed")
33778            .expect("second watermark must produce a hash");
33779        assert_eq!(
33780            first, second,
33781            "metadata-only summaries.db churn must not invalidate the source watermark"
33782        );
33783
33784        summary.entities = Some(vec![summarize::Entity {
33785            name: "GraphCache".to_string(),
33786            kind: "type".to_string(),
33787            description: "Stable full-projection cache input".to_string(),
33788        }]);
33789        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33790        summary_db.delete_by_file("src/main.rs").unwrap();
33791        summary_db.insert(&summary).unwrap();
33792        drop(summary_db);
33793
33794        let third = traversal_source_watermark(root, &hint, None, true)
33795            .expect("third watermark call must succeed")
33796            .expect("third watermark must produce a hash");
33797        assert_ne!(
33798            first, third,
33799            "semantic summary row changes must invalidate the source watermark"
33800        );
33801    }
33802
33803    #[test]
33804    fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
33805        // #gfullhot: backend-eval full-projection cache keys should be based on
33806        // the indexed graph inputs, not file_state mtimes. Touching a source file
33807        // without changing extracted symbols/call edges must still hit the cache.
33808        let dir = tempfile::tempdir().unwrap();
33809        let root = dir.path();
33810        std::fs::create_dir_all(root.join("src")).unwrap();
33811        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33812        let source = root.join("src/lib.rs");
33813        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33814        std::fs::write(&source, source_body).unwrap();
33815        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33816        db.rebuild(root).unwrap();
33817        drop(db);
33818
33819        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33820            .unwrap()
33821            .value;
33822        std::thread::sleep(std::time::Duration::from_millis(20));
33823        std::fs::write(&source, source_body).unwrap();
33824        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33825        db.apply_changes(root).unwrap();
33826        drop(db);
33827
33828        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33829            .unwrap()
33830            .value;
33831        assert_eq!(
33832            first, second,
33833            "mtime-only source index churn must not invalidate the full-projection cache"
33834        );
33835    }
33836
33837    #[test]
33838    fn full_projection_source_watermark_ignores_session_markdown_churn() {
33839        // #gfullhot: the full-projection performance cache isolates code graph
33840        // and semantic-summary inputs. Current session evidence is measured by
33841        // the bounded real dataset, so unrelated task-doc edits must not force a
33842        // million-row full-projection rebuild.
33843        let dir = tempfile::tempdir().unwrap();
33844        let root = dir.path();
33845        std::fs::create_dir_all(root.join("src")).unwrap();
33846        std::fs::create_dir_all(root.join("tasks/software")).unwrap();
33847        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33848        std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
33849        let task_doc = root.join("tasks/software/tsift.md");
33850        std::fs::write(
33851            &task_doc,
33852            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
33853        )
33854        .unwrap();
33855        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33856        db.rebuild(root).unwrap();
33857        drop(db);
33858
33859        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33860            .unwrap()
33861            .value;
33862        std::fs::write(
33863            &task_doc,
33864            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
33865        )
33866        .unwrap();
33867        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33868            .unwrap()
33869            .value;
33870        assert_eq!(
33871            first, second,
33872            "session markdown churn must not invalidate the full-projection code/summary cache"
33873        );
33874    }
33875
33876    #[test]
33877    fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
33878        // #gfullhot: once a full-project projection is cached, repeated samples
33879        // with unchanged graph inputs must report zero source_graph_build and
33880        // projection_rows work even if indexed file mtimes changed.
33881        let dir = tempfile::tempdir().unwrap();
33882        let root = dir.path();
33883        std::fs::create_dir_all(root.join("src")).unwrap();
33884        std::fs::create_dir_all(root.join(".tsift")).unwrap();
33885        let source = root.join("src/lib.rs");
33886        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33887        std::fs::write(&source, source_body).unwrap();
33888        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33889        db.rebuild(root).unwrap();
33890        drop(db);
33891
33892        let (_projection, _warnings, _phases, first_stats) =
33893            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33894        assert!(
33895            !first_stats.hit,
33896            "the first full-projection run should populate the cache"
33897        );
33898
33899        std::thread::sleep(std::time::Duration::from_millis(20));
33900        std::fs::write(&source, source_body).unwrap();
33901        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33902        db.apply_changes(root).unwrap();
33903        drop(db);
33904
33905        let (_projection, _warnings, phases, second_stats) =
33906            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33907        assert!(second_stats.hit, "mtime-only churn should still cache-hit");
33908        let source_graph_build = phases
33909            .iter()
33910            .find(|phase| phase.name == "full_projection.source_graph_build")
33911            .expect("cache hit must report source_graph_build");
33912        let projection_rows = phases
33913            .iter()
33914            .find(|phase| phase.name == "full_projection.projection_rows")
33915            .expect("cache hit must report projection_rows");
33916        assert_eq!(source_graph_build.duration_micros, 0);
33917        assert_eq!(projection_rows.duration_micros, 0);
33918    }
33919
33920    #[test]
33921    fn build_token_capped_preview_within_cap() {
33922        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 2", "}"];
33923        let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
33924        assert!(!capped.was_capped);
33925        assert_eq!(capped.preview.len(), 3);
33926        assert_eq!(capped.capped_end, 3);
33927    }
33928
33929    #[test]
33930    fn build_token_capped_preview_truncates_long_body() {
33931        let owned: Vec<String> = (0..200)
33932            .map(|i| format!("    let line_{i} = {i};"))
33933            .collect();
33934        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33935        let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
33936        assert!(capped.was_capped);
33937        assert!(capped.preview.len() < 200);
33938        assert!(capped.capped_end < 200);
33939        assert!(!capped.preview.is_empty());
33940    }
33941
33942    #[test]
33943    fn build_token_capped_preview_respects_start_offset() {
33944        let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
33945        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33946        let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
33947        assert!(capped.was_capped);
33948        assert!(capped.capped_end >= 50);
33949        assert!(capped.capped_end < 100);
33950        assert_eq!(capped.preview[0].line, 50);
33951    }
33952
33953    #[test]
33954    fn response_budget_body_token_cap_defaults() {
33955        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
33956        assert_eq!(budget.body_token_cap(), 1500);
33957
33958        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
33959        assert_eq!(budget.body_token_cap(), 500);
33960
33961        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
33962        assert_eq!(budget.body_token_cap(), 3000);
33963    }
33964
33965    #[test]
33966    fn build_token_capped_preview_empty_input() {
33967        let lines: Vec<&str> = vec![];
33968        let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
33969        assert!(!capped.was_capped);
33970        assert!(capped.preview.is_empty());
33971    }
33972
33973    #[test]
33974    fn build_token_capped_preview_single_long_line_fits() {
33975        let lines: Vec<&str> = vec!["short"];
33976        let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
33977        assert!(!capped.was_capped);
33978        assert_eq!(capped.preview.len(), 1);
33979        assert_eq!(capped.capped_end, 1);
33980    }
33981
33982    #[test]
33983    fn edge_index_replaces_from_id_to_id_with_positions() {
33984        let input = serde_json::json!({
33985            "nodes": [
33986                {"id": "symbol:src/lib.rs:foo"},
33987                {"id": "symbol:src/lib.rs:bar"},
33988                {"id": "symbol:src/lib.rs:baz"}
33989            ],
33990            "edges": [
33991                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
33992                {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
33993            ]
33994        });
33995        let result = edge_index_transform(input);
33996        let edges = result.get("edges").unwrap().as_array().unwrap();
33997        assert_eq!(edges.len(), 2);
33998        assert_eq!(edges[0]["from"], 0);
33999        assert_eq!(edges[0]["to"], 1);
34000        assert_eq!(edges[1]["from"], 1);
34001        assert_eq!(edges[1]["to"], 2);
34002        assert!(edges[0].get("from_id").is_none());
34003        assert!(edges[0].get("to_id").is_none());
34004    }
34005
34006    #[test]
34007    fn edge_index_preserves_unresolved_ids_as_strings() {
34008        let input = serde_json::json!({
34009            "nodes": [{"id": "symbol:src/lib.rs:foo"}],
34010            "edges": [
34011                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
34012            ]
34013        });
34014        let result = edge_index_transform(input);
34015        let edge = &result["edges"][0];
34016        assert_eq!(edge["from"], 0);
34017        assert_eq!(edge["to_id"], "symbol:other.rs:missing");
34018    }
34019
34020    #[test]
34021    fn edge_index_noop_without_nodes_and_edges() {
34022        let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
34023        let result = edge_index_transform(input);
34024        assert_eq!(result["report"]["entries"][0]["from_id"], "a");
34025    }
34026}
34027
34028// --- SQL introspection ---
34029
34030#[derive(Serialize)]
34031struct TableInfo {
34032    name: String,
34033    columns: Vec<ColumnInfo>,
34034    row_count: i64,
34035}
34036
34037#[derive(Serialize)]
34038struct ColumnInfo {
34039    name: String,
34040    #[serde(rename = "type")]
34041    col_type: String,
34042    notnull: bool,
34043    pk: bool,
34044    #[serde(skip_serializing_if = "Option::is_none")]
34045    default_value: Option<String>,
34046}
34047
34048/// Open a SQLite connection (read-only).
34049pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
34050    let conn = Connection::open_with_flags(
34051        path,
34052        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
34053    )
34054    .with_context(|| format!("opening database: {}", path.display()))?;
34055    Ok(conn)
34056}
34057
34058/// List all user tables with column metadata and row counts.
34059pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
34060    let mut stmt = conn.prepare(
34061        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
34062    )?;
34063    let table_names: Vec<String> = stmt
34064        .query_map([], |row| row.get(0))?
34065        .collect::<std::result::Result<Vec<_>, _>>()?;
34066
34067    let mut tables = Vec::new();
34068    for tbl in table_names {
34069        let columns = table_columns(conn, &tbl)?;
34070        let row_count: i64 =
34071            conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
34072                row.get(0)
34073            })?;
34074        tables.push(TableInfo {
34075            name: tbl,
34076            columns,
34077            row_count,
34078        });
34079    }
34080    Ok(tables)
34081}
34082
34083/// Get column metadata for a single table.
34084pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
34085    let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
34086    let cols = stmt
34087        .query_map([], |row| {
34088            Ok(ColumnInfo {
34089                name: row.get(1)?,
34090                col_type: row.get::<_, String>(2).unwrap_or_default(),
34091                notnull: row.get::<_, bool>(3).unwrap_or(false),
34092                pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
34093                default_value: row.get(4)?,
34094            })
34095        })?
34096        .collect::<std::result::Result<Vec<_>, _>>()?;
34097    Ok(cols)
34098}
34099
34100/// Execute an arbitrary SQL query and return rows as JSON values.
34101pub(crate) fn execute_query(
34102    conn: &Connection,
34103    sql: &str,
34104) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
34105    let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
34106    let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
34107    let col_count = col_names.len();
34108
34109    let mut rows = Vec::new();
34110    let mut query_rows = stmt.query([])?;
34111    while let Some(row) = query_rows.next()? {
34112        let mut vals = Vec::with_capacity(col_count);
34113        for i in 0..col_count {
34114            let val = match row.get_ref(i)? {
34115                rusqlite::types::ValueRef::Null => serde_json::Value::Null,
34116                rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
34117                rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
34118                rusqlite::types::ValueRef::Text(s) => {
34119                    serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
34120                }
34121                rusqlite::types::ValueRef::Blob(b) => {
34122                    serde_json::Value::String(format!("<blob {} bytes>", b.len()))
34123                }
34124            };
34125            vals.push(val);
34126        }
34127        rows.push(vals);
34128    }
34129    Ok((col_names, rows))
34130}
34131
34132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34133enum DigestRunnerKind {
34134    Test,
34135    Log,
34136}
34137
34138impl DigestRunnerKind {
34139    fn parse(raw: &str) -> Result<Self> {
34140        match raw.trim().to_ascii_lowercase().as_str() {
34141            "test" => Ok(Self::Test),
34142            "log" => Ok(Self::Log),
34143            other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
34144        }
34145    }
34146
34147    fn as_str(self) -> &'static str {
34148        match self {
34149            Self::Test => "test",
34150            Self::Log => "log",
34151        }
34152    }
34153}
34154
34155/// Simple shell word splitting (handles single and double quotes).
34156pub(crate) fn shell_split(s: &str) -> Vec<&str> {
34157    let mut parts = Vec::new();
34158    let mut i = 0;
34159    let bytes = s.as_bytes();
34160    while i < bytes.len() {
34161        // Skip whitespace
34162        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
34163            i += 1;
34164        }
34165        if i >= bytes.len() {
34166            break;
34167        }
34168        let start = i;
34169        if bytes[i] == b'"' || bytes[i] == b'\'' {
34170            let quote = bytes[i];
34171            i += 1;
34172            while i < bytes.len() && bytes[i] != quote {
34173                i += 1;
34174            }
34175            if i < bytes.len() {
34176                i += 1; // closing quote
34177            }
34178        } else {
34179            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
34180                i += 1;
34181            }
34182        }
34183        parts.push(&s[start..i]);
34184    }
34185    parts
34186}
34187
34188/// Quote a string for shell if it contains special characters.
34189pub(crate) fn shell_quote(s: &str) -> String {
34190    // Strip existing quotes
34191    let unquoted =
34192        if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
34193            &s[1..s.len() - 1]
34194        } else {
34195            s
34196        };
34197
34198    if unquoted
34199        .chars()
34200        .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
34201    {
34202        format!("\"{}\"", unquoted)
34203    } else {
34204        format!(
34205            "\"{}\"",
34206            unquoted.replace('\\', "\\\\").replace('"', "\\\"")
34207        )
34208    }
34209}
34210
34211fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
34212    sift::SearchCoverageSnapshot {
34213        mode: sift::SearchCoverageMode::Sealed,
34214        total_sector_count: 0,
34215        mounted_sector_count: 0,
34216        reused_sector_count: 0,
34217        dirty_sector_count: 0,
34218        completed_dirty_sector_count: 0,
34219        rebuilding_sector_count: 0,
34220        resumed_sector_count: 0,
34221        active_rebuild: None,
34222    }
34223}
34224
34225fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
34226    let total_sector_count = responses
34227        .iter()
34228        .map(|response| response.coverage.total_sector_count)
34229        .sum();
34230    let mounted_sector_count = responses
34231        .iter()
34232        .map(|response| response.coverage.mounted_sector_count)
34233        .sum();
34234    let reused_sector_count = responses
34235        .iter()
34236        .map(|response| response.coverage.reused_sector_count)
34237        .sum();
34238    let dirty_sector_count = responses
34239        .iter()
34240        .map(|response| response.coverage.dirty_sector_count)
34241        .sum();
34242    let completed_dirty_sector_count = responses
34243        .iter()
34244        .map(|response| response.coverage.completed_dirty_sector_count)
34245        .sum();
34246    let rebuilding_sector_count = responses
34247        .iter()
34248        .map(|response| response.coverage.rebuilding_sector_count)
34249        .sum();
34250    let resumed_sector_count = responses
34251        .iter()
34252        .map(|response| response.coverage.resumed_sector_count)
34253        .sum();
34254
34255    let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
34256        sift::SearchCoverageMode::Sealed
34257    } else if completed_dirty_sector_count > 0
34258        || rebuilding_sector_count > 0
34259        || resumed_sector_count > 0
34260    {
34261        sift::SearchCoverageMode::Converging
34262    } else {
34263        sift::SearchCoverageMode::Frontier
34264    };
34265
34266    sift::SearchCoverageSnapshot {
34267        mode,
34268        total_sector_count,
34269        mounted_sector_count,
34270        reused_sector_count,
34271        dirty_sector_count,
34272        completed_dirty_sector_count,
34273        rebuilding_sector_count,
34274        resumed_sector_count,
34275        active_rebuild: responses
34276            .iter()
34277            .find_map(|response| response.coverage.active_rebuild.clone()),
34278    }
34279}
34280
34281fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
34282    sift::SearchResponse {
34283        strategy: strategy.to_string(),
34284        root: root.display().to_string(),
34285        indexed_artifacts: 0,
34286        skipped_artifacts: 0,
34287        coverage: empty_search_coverage(),
34288        hits: Vec::new(),
34289    }
34290}
34291
34292fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
34293    for hit in &mut response.hits {
34294        let path = Path::new(&hit.path);
34295        if path.is_relative() {
34296            hit.path = search_root.join(path).display().to_string();
34297        }
34298    }
34299}
34300
34301fn merge_search_responses(
34302    root: &Path,
34303    strategy: &str,
34304    limit: usize,
34305    responses: Vec<sift::SearchResponse>,
34306) -> sift::SearchResponse {
34307    let indexed_artifacts = responses
34308        .iter()
34309        .map(|response| response.indexed_artifacts)
34310        .sum();
34311    let skipped_artifacts = responses
34312        .iter()
34313        .map(|response| response.skipped_artifacts)
34314        .sum();
34315    let coverage = if responses.is_empty() {
34316        empty_search_coverage()
34317    } else {
34318        aggregate_search_coverage(&responses)
34319    };
34320    let mut hits: Vec<sift::SearchHit> = responses
34321        .into_iter()
34322        .flat_map(|response| response.hits)
34323        .collect();
34324    hits.sort_by(|left, right| {
34325        right
34326            .score
34327            .partial_cmp(&left.score)
34328            .unwrap_or(Ordering::Equal)
34329            .then_with(|| left.path.cmp(&right.path))
34330            .then_with(|| left.location.cmp(&right.location))
34331    });
34332    hits.truncate(limit);
34333    for (rank, hit) in hits.iter_mut().enumerate() {
34334        hit.rank = rank + 1;
34335    }
34336
34337    sift::SearchResponse {
34338        strategy: strategy.to_string(),
34339        root: root.display().to_string(),
34340        indexed_artifacts,
34341        skipped_artifacts,
34342        coverage,
34343        hits,
34344    }
34345}
34346
34347pub(crate) fn federated_sift_search(
34348    root: &Path,
34349    cache_dir: &Path,
34350    query: &str,
34351    limit: usize,
34352    timeout_secs: u64,
34353    strategy: &str,
34354    fts_index_fresh: Option<bool>,
34355) -> Result<sift::SearchResponse> {
34356    let targets = resolve_search_index_targets(root, root, None, true)?;
34357    if targets.is_empty() {
34358        if config::Config::submodule_dirs(root)?.is_empty() {
34359            return run_search_with_timeout(
34360                root,
34361                cache_dir,
34362                query,
34363                limit,
34364                timeout_secs,
34365                strategy,
34366                &[],
34367                fts_index_fresh,
34368            );
34369        }
34370        return Ok(empty_search_response(root, strategy));
34371    }
34372
34373    let mut responses = Vec::with_capacity(targets.len());
34374    for target in &targets {
34375        let mut response = run_search_with_timeout(
34376            &target.source_root,
34377            cache_dir,
34378            query,
34379            limit,
34380            timeout_secs,
34381            strategy,
34382            std::slice::from_ref(target),
34383            fts_index_fresh,
34384        )?;
34385        absolutize_search_hit_paths(&mut response, &target.source_root);
34386        response.root = root.display().to_string();
34387        responses.push(response);
34388    }
34389
34390    Ok(merge_search_responses(root, strategy, limit, responses))
34391}
34392
34393/// Federated symbol search across every scoped `.tsift/indexes/<scope>/index.db`
34394/// in the workspace. Per-scope tagpath annotation runs inside the per-scope
34395/// loop so each scope's adapter resolves against its own `.naming.toml` /
34396/// `.naming/index.json` (the workspace root usually has no tagpath of its
34397/// own). The merged `TagpathAnnotationDiagnostic` reports `loaded=true` when
34398/// at least one scope loaded, and `stale=true` with the first stale reason
34399/// when any scope was stale.
34400pub(crate) fn federated_symbol_search(
34401    root: &std::path::Path,
34402    query: &str,
34403    limit: usize,
34404    tagpath_opts: &TagpathSearchOpts,
34405) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
34406    let cfg = config::Config::load(root)?;
34407    let submodules = config::Config::submodule_dirs(root)?;
34408    let mut all_hits: Vec<index::SymbolHit> = Vec::new();
34409    let mut combined = TagpathAnnotationDiagnostic::default();
34410    for scope in &submodules {
34411        if !cfg.federation_for_scope(scope) {
34412            continue;
34413        }
34414        let db_path = cfg.db_path_for(root, &scope.id);
34415        if !db_path.exists() {
34416            continue;
34417        }
34418        let db = index::IndexDb::open_read_only(&db_path)?;
34419        let mut hits = db.symbol_search(query, limit)?;
34420        let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
34421        combined.loaded |= diag.loaded;
34422        if diag.stale && !combined.stale {
34423            combined.stale = true;
34424            combined.reason = diag.reason;
34425        }
34426        all_hits.append(&mut hits);
34427    }
34428    all_hits.sort_by(|a, b| {
34429        b.score
34430            .partial_cmp(&a.score)
34431            .unwrap_or(std::cmp::Ordering::Equal)
34432    });
34433    all_hits.truncate(limit);
34434    Ok((all_hits, combined))
34435}
34436
34437#[derive(Debug, Deserialize)]
34438#[serde(tag = "type", rename_all = "lowercase")]
34439enum RipgrepJsonEvent {
34440    Match {
34441        data: RipgrepMatchData,
34442    },
34443    #[serde(other)]
34444    Other,
34445}
34446
34447#[derive(Debug, Deserialize)]
34448struct RipgrepMatchData {
34449    path: RipgrepTextField,
34450    lines: RipgrepTextField,
34451    line_number: Option<usize>,
34452}
34453
34454#[derive(Debug, Deserialize)]
34455struct RipgrepTextField {
34456    text: Option<String>,
34457}
34458
34459pub(crate) fn federated_exact_search(
34460    root: &Path,
34461    query: &str,
34462    limit: usize,
34463    timeout_secs: u64,
34464) -> Result<sift::SearchResponse> {
34465    let cfg = config::Config::load(root)?;
34466    let mut responses = Vec::new();
34467    for scope in config::Config::submodule_dirs(root)? {
34468        if !cfg.federation_for_scope(&scope) {
34469            continue;
34470        }
34471        let mut response =
34472            run_exact_search_with_timeout(std::slice::from_ref(&scope.source_root), query, limit, timeout_secs)?;
34473        absolutize_search_hit_paths(&mut response, &scope.source_root);
34474        response.root = root.display().to_string();
34475        responses.push(response);
34476    }
34477
34478    Ok(merge_search_responses(root, "exact", limit, responses))
34479}
34480
34481pub(crate) fn run_sift_search(
34482    search_path: &Path,
34483    cache_dir: &Path,
34484    query: &str,
34485    limit: usize,
34486    strategy: &str,
34487    // #015t Phase 4b — the caller's already-known FTS index freshness:
34488    //   `Some(true)`  — caller (cmd_search after precheck+autoindex) proved fresh;
34489    //                   use FTS without re-walking the tree.
34490    //   `Some(false)` — caller proved stale/degraded (e.g. read-only writer lock);
34491    //                   skip FTS, serve live results via the TokenIndex fallback.
34492    //   `None`        — unknown (direct programmatic callers); inspect here.
34493    // This drops the redundant `inspect_read_only` walk on the normal CLI path,
34494    // where `precheck_search_indexes` already established freshness.
34495    fts_index_fresh: Option<bool>,
34496) -> Result<sift::SearchResponse> {
34497    // #015t Phase 4 cutover: the `index.db` FTS5 path is now the DEFAULT for
34498    // lexical search. The normal search flow runs `precheck_search_indexes` with
34499    // autoindex first, so a fresh root `index.db` is guaranteed present before we
34500    // get here; the FTS5 BM25 path supersedes the parallel JSON `TokenIndex`
34501    // (which never rebuilt on content change — staleness was keyed on file
34502    // existence only). Ranking shifts from substring-position to BM25 by design;
34503    // the Phase 3 soundness gate proved candidate coverage (FTS ⊇ TokenIndex).
34504    //
34505    // The JSON `TokenIndex` is demoted to a FALLBACK for the only remaining cases
34506    // that reach here without a fresh root index.db: an un-indexed root reached
34507    // with `--no-autoindex` (the normal precheck degrades a missing index to exact
34508    // search, not lexical), a stale/degraded index (live results via TokenIndex),
34509    // and direct programmatic callers. `TSIFT_FTS_SEARCH=0` (`0`/`false`/`no`/`off`)
34510    // forces that legacy path as a transition escape hatch. Both the in-process
34511    // (timeout=0) and `__search-worker` subprocess routes pass through here; the
34512    // worker inherits the env var and is handed the freshness verdict explicitly.
34513    if !fts_search_forced_off() {
34514        let db_path = search_path.join(".tsift/index.db");
34515        let use_fts = match fts_index_fresh {
34516            Some(fresh) => fresh && db_path.exists(),
34517            None => db_path.exists() && index_db_is_fresh_for_fts(&db_path, search_path),
34518        };
34519        if use_fts {
34520            return sift::fts_search(&db_path, search_path, query, limit)
34521                .context("index.db FTS5 search failed");
34522        }
34523    }
34524
34525    let engine = Sift::builder().with_cache_dir(cache_dir).build();
34526    let options = SearchOptions::default()
34527        .with_limit(limit)
34528        .with_strategy(strategy.to_string());
34529    let input = SearchInput::new(search_path, query).with_options(options);
34530    engine.search(input).context("sift search failed")
34531}
34532
34533/// #015t Phase 4 — the FTS5 `index.db` path is trustworthy only when the index is
34534/// **openable AND fresh** for the search root. A missing/corrupt index.db (e.g. an
34535/// empty placeholder) or a **stale** one (e.g. held open by a concurrent writer so
34536/// autoindex degraded to read-only) falls back to the live `TokenIndex` path — so a
34537/// search never returns content the index has not caught up to. The normal flow's
34538/// `precheck_search_indexes` + autoindex makes this true in the common case;
34539/// re-inspecting here is a redundant tree-walk that #015t Phase 4b can replace by
34540/// threading the precheck's freshness result through to this call.
34541fn index_db_is_fresh_for_fts(db_path: &Path, search_path: &Path) -> bool {
34542    match index::IndexDb::inspect_read_only(db_path, search_path, false) {
34543        Ok(inspection) => {
34544            inspection.summary.new + inspection.summary.modified + inspection.summary.deleted == 0
34545        }
34546        Err(_) => false,
34547    }
34548}
34549
34550/// #015t Phase 4 — whether the operator has forced lexical search back onto the
34551/// legacy JSON `TokenIndex` path via `TSIFT_FTS_SEARCH` set to a falsy value
34552/// (`0`/`false`/`no`/`off`). The FTS5 `index.db` path is the default; this is the
34553/// transition escape hatch. Any other value (or unset) keeps the FTS5 default.
34554fn fts_search_forced_off() -> bool {
34555    std::env::var("TSIFT_FTS_SEARCH")
34556        .map(|value| fts_flag_value_disabled(&value))
34557        .unwrap_or(false)
34558}
34559
34560/// Pure parser for a falsy `TSIFT_FTS_SEARCH` value (factored out so the rules
34561/// can be unit-tested without mutating process env).
34562fn fts_flag_value_disabled(value: &str) -> bool {
34563    matches!(
34564        value.trim().to_ascii_lowercase().as_str(),
34565        "0" | "false" | "no" | "off"
34566    )
34567}
34568
34569fn exact_search_timeout_message(timeout_secs: u64) -> String {
34570    format!(
34571        "tsift search timed out after {}s (strategy: exact). \
34572         Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
34573        timeout_secs
34574    )
34575}
34576
34577fn exact_search_command(search_paths: &[PathBuf], query: &str) -> Command {
34578    let mut command = Command::new("rg");
34579    command
34580        .arg("--json")
34581        .arg("--fixed-strings")
34582        .arg("--line-number")
34583        .arg("--hidden")
34584        .arg("--")
34585        .arg(query);
34586    if search_paths.is_empty() {
34587        command.arg(Path::new("."));
34588    } else {
34589        command.args(search_paths);
34590    }
34591    command
34592}
34593
34594fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
34595    let observed_unix_secs = SystemTime::now()
34596        .duration_since(UNIX_EPOCH)
34597        .unwrap_or_default()
34598        .as_secs() as i64;
34599    let modified_unix_secs = fs::metadata(path)
34600        .ok()
34601        .and_then(|metadata| metadata.modified().ok())
34602        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
34603        .map(|duration| duration.as_secs() as i64);
34604    sift::ArtifactFreshness {
34605        observed_unix_secs,
34606        modified_unix_secs,
34607    }
34608}
34609
34610fn parse_exact_search_output(
34611    search_path: &Path,
34612    limit: usize,
34613    raw: &str,
34614) -> Result<sift::SearchResponse> {
34615    if limit == 0 {
34616        return Ok(sift::SearchResponse {
34617            strategy: "exact".to_string(),
34618            root: search_path.display().to_string(),
34619            indexed_artifacts: 0,
34620            skipped_artifacts: 0,
34621            coverage: empty_search_coverage(),
34622            hits: Vec::new(),
34623        });
34624    }
34625
34626    let mut hits = Vec::new();
34627    for line in raw.lines() {
34628        let event: RipgrepJsonEvent =
34629            serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
34630        let RipgrepJsonEvent::Match { data } = event else {
34631            continue;
34632        };
34633        let Some(path_text) = data.path.text else {
34634            continue;
34635        };
34636        let Some(lines_text) = data.lines.text else {
34637            continue;
34638        };
34639        let path = PathBuf::from(path_text);
34640        let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
34641        let rank = hits.len() + 1;
34642        hits.push(sift::SearchHit {
34643            artifact_id: format!(
34644                "exact:{}:{}:{}",
34645                path.display(),
34646                data.line_number.unwrap_or(0),
34647                rank
34648            ),
34649            artifact_kind: sift::ContextArtifactKind::File,
34650            path: path.display().to_string(),
34651            rank,
34652            score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
34653            confidence: sift::ScoreConfidence::High,
34654            location: data.line_number.map(|line| format!("line {}", line)),
34655            snippet: snippet.clone(),
34656            provenance: sift::ArtifactProvenance {
34657                adapter: sift::AcquisitionAdapterKind::FileSystem,
34658                source: "ripgrep -F".to_string(),
34659                synthetic: false,
34660            },
34661            freshness: exact_search_file_timestamp(&path),
34662            budget: sift::ArtifactBudget::from_text(&snippet, 1),
34663        });
34664        if hits.len() >= limit {
34665            break;
34666        }
34667    }
34668
34669    Ok(sift::SearchResponse {
34670        strategy: "exact".to_string(),
34671        root: search_path.display().to_string(),
34672        indexed_artifacts: hits.len(),
34673        skipped_artifacts: 0,
34674        coverage: empty_search_coverage(),
34675        hits,
34676    })
34677}
34678
34679fn exact_search_response_from_process(
34680    search_path: &Path,
34681    limit: usize,
34682    status: std::process::ExitStatus,
34683    stdout: &[u8],
34684    stderr: &[u8],
34685) -> Result<sift::SearchResponse> {
34686    if !status.success() && status.code() != Some(1) {
34687        let message = String::from_utf8_lossy(stderr);
34688        let trimmed = message.trim();
34689        if trimmed.is_empty() {
34690            bail!("ripgrep exact search exited with status {}", status);
34691        }
34692        bail!("{}", trimmed);
34693    }
34694
34695    let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
34696    parse_exact_search_output(search_path, limit, &raw)
34697}
34698
34699fn run_exact_search(search_paths: &[PathBuf], query: &str, limit: usize) -> Result<sift::SearchResponse> {
34700    let output = exact_search_command(search_paths, query)
34701        .output()
34702        .context("running exact search with ripgrep")?;
34703    let root_display = search_paths
34704        .first()
34705        .map(|p| p.as_path())
34706        .unwrap_or_else(|| Path::new("."));
34707    exact_search_response_from_process(
34708        root_display,
34709        limit,
34710        output.status,
34711        &output.stdout,
34712        &output.stderr,
34713    )
34714}
34715
34716pub(crate) fn run_exact_search_with_timeout(
34717    search_paths: &[PathBuf],
34718    query: &str,
34719    limit: usize,
34720    timeout_secs: u64,
34721) -> Result<sift::SearchResponse> {
34722    if timeout_secs == 0 {
34723        return run_exact_search(search_paths, query, limit);
34724    }
34725
34726    let mut child = exact_search_command(search_paths, query)
34727        .stdin(Stdio::null())
34728        .stdout(Stdio::piped())
34729        .stderr(Stdio::piped())
34730        .spawn()
34731        .context("spawning timed exact search worker")?;
34732
34733    let timeout = Duration::from_secs(timeout_secs);
34734    let status = wait_for_child_exit(&mut child, timeout)
34735        .context("waiting for timed exact search worker")?;
34736    if status.is_none() {
34737        let _ = child.kill();
34738        let _ = child.wait();
34739        bail!("{}", exact_search_timeout_message(timeout_secs));
34740    }
34741
34742    let status = status.unwrap();
34743    let stdout = read_child_stdout(&mut child)?;
34744    let stderr = read_child_stderr(&mut child)?;
34745    let root_display = search_paths
34746        .first()
34747        .map(|p| p.as_path())
34748        .unwrap_or_else(|| Path::new("."));
34749    exact_search_response_from_process(
34750        root_display,
34751        limit,
34752        status,
34753        stdout.as_bytes(),
34754        stderr.as_bytes(),
34755    )
34756}
34757
34758#[allow(clippy::too_many_arguments)]
34759pub(crate) fn run_search_with_timeout(
34760    search_path: &Path,
34761    cache_dir: &Path,
34762    query: &str,
34763    limit: usize,
34764    timeout_secs: u64,
34765    strategy: &str,
34766    search_targets: &[SearchIndexTarget],
34767    // #015t Phase 4b — FTS index freshness verdict forwarded to the worker so it
34768    // skips the redundant `inspect_read_only` walk (see `run_sift_search`).
34769    fts_index_fresh: Option<bool>,
34770) -> Result<sift::SearchResponse> {
34771    if timeout_secs == 0 {
34772        return run_sift_search(search_path, cache_dir, query, limit, strategy, fts_index_fresh);
34773    }
34774
34775    let output_path = next_search_worker_output_path();
34776    let mut command = Command::new(
34777        std::env::current_exe().context("resolving tsift executable for timed search")?,
34778    );
34779    command
34780        .arg("__search-worker")
34781        .arg("--path")
34782        .arg(search_path)
34783        .arg("--cache-dir")
34784        .arg(cache_dir)
34785        .arg("--query")
34786        .arg(query)
34787        .arg("--limit")
34788        .arg(limit.to_string())
34789        .arg("--strategy")
34790        .arg(strategy)
34791        .arg("--output")
34792        .arg(&output_path);
34793    if let Some(fresh) = fts_index_fresh {
34794        command.arg("--fts-index-fresh").arg(fresh.to_string());
34795    }
34796    let mut child = command
34797        .stdin(Stdio::null())
34798        .stdout(Stdio::null())
34799        .stderr(Stdio::piped())
34800        .spawn()
34801        .context("spawning timed sift search worker")?;
34802
34803    let timeout = Duration::from_secs(timeout_secs);
34804    let status =
34805        wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
34806    if status.is_none() {
34807        let _ = child.kill();
34808        let _ = child.wait();
34809        let _ = fs::remove_file(&output_path);
34810        bail!(
34811            "{}",
34812            search_timeout_message(timeout_secs, strategy, search_targets)?
34813        );
34814    }
34815
34816    let status = status.unwrap();
34817    let stderr = read_child_stderr(&mut child)?;
34818    if !status.success() {
34819        let _ = fs::remove_file(&output_path);
34820        let message = stderr.trim();
34821        if message.is_empty() {
34822            bail!("sift search worker exited with status {}", status);
34823        }
34824        bail!("{}", message);
34825    }
34826
34827    let raw = fs::read_to_string(&output_path)
34828        .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
34829    let _ = fs::remove_file(&output_path);
34830    serde_json::from_str(&raw).context("parsing search worker output")
34831}
34832
34833fn next_search_worker_output_path() -> PathBuf {
34834    let stamp = SystemTime::now()
34835        .duration_since(UNIX_EPOCH)
34836        .unwrap_or_default()
34837        .as_nanos();
34838    std::env::temp_dir().join(format!(
34839        "tsift-search-{}-{}.json",
34840        std::process::id(),
34841        stamp
34842    ))
34843}
34844
34845fn wait_for_child_exit(
34846    child: &mut std::process::Child,
34847    timeout: Duration,
34848) -> Result<Option<std::process::ExitStatus>> {
34849    let started = Instant::now();
34850    loop {
34851        if let Some(status) = child.try_wait()? {
34852            return Ok(Some(status));
34853        }
34854        if started.elapsed() >= timeout {
34855            return Ok(None);
34856        }
34857        let remaining = timeout.saturating_sub(started.elapsed());
34858        std::thread::sleep(remaining.min(Duration::from_millis(10)));
34859    }
34860}
34861
34862fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
34863    let mut stderr = String::new();
34864    if let Some(mut pipe) = child.stderr.take() {
34865        pipe.read_to_string(&mut stderr)
34866            .context("reading search worker stderr")?;
34867    }
34868    Ok(stderr)
34869}
34870
34871fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
34872    let mut stdout = String::new();
34873    if let Some(mut pipe) = child.stdout.take() {
34874        pipe.read_to_string(&mut stdout)
34875            .context("reading search worker stdout")?;
34876    }
34877    Ok(stdout)
34878}
34879
34880pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
34881    if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
34882        fs::write(&path, std::process::id().to_string())
34883            .with_context(|| format!("writing search worker pid file: {path}"))?;
34884    }
34885    if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
34886        let delay_ms = ms
34887            .parse::<u64>()
34888            .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
34889        std::thread::sleep(Duration::from_millis(delay_ms));
34890    }
34891    Ok(())
34892}
34893
34894#[cfg(test)]
34895thread_local! {
34896    static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
34897}
34898
34899#[cfg(test)]
34900enum SearchPostPrecheckLockMode {
34901    RollbackJournal,
34902    Wal,
34903}
34904
34905#[cfg(test)]
34906struct SearchPostPrecheckLockHook {
34907    db_path: PathBuf,
34908    mode: SearchPostPrecheckLockMode,
34909}
34910
34911#[cfg(test)]
34912struct SearchPostPrecheckLockGuard;
34913
34914#[cfg(test)]
34915impl Drop for SearchPostPrecheckLockGuard {
34916    fn drop(&mut self) {
34917        SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34918            hook.borrow_mut().take();
34919        });
34920    }
34921}
34922
34923#[cfg(test)]
34924fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34925    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
34926}
34927
34928#[cfg(test)]
34929fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34930    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
34931}
34932
34933#[cfg(test)]
34934fn install_search_post_precheck_lock_hook(
34935    db_path: PathBuf,
34936    mode: SearchPostPrecheckLockMode,
34937) -> SearchPostPrecheckLockGuard {
34938    SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34939        assert!(
34940            hook.borrow().is_none(),
34941            "search post-precheck lock hook already installed"
34942        );
34943        *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
34944    });
34945    SearchPostPrecheckLockGuard
34946}
34947
34948#[cfg(test)]
34949pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34950    let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
34951        return Ok(());
34952    };
34953    let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
34954    std::thread::spawn(move || {
34955        let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
34956        match hook.mode {
34957            SearchPostPrecheckLockMode::RollbackJournal => {
34958                conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
34959                    .expect("acquiring rollback-journal hook lock");
34960                fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
34961                    .expect("writing rollback journal marker");
34962            }
34963            SearchPostPrecheckLockMode::Wal => {
34964                conn.execute_batch(
34965                    "PRAGMA journal_mode=WAL;
34966                     PRAGMA wal_autocheckpoint=0;
34967                     CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
34968                     INSERT INTO search_wal_lock_probe DEFAULT VALUES;
34969                     PRAGMA locking_mode=EXCLUSIVE;
34970                     BEGIN EXCLUSIVE;",
34971                )
34972                .expect("acquiring WAL hook lock");
34973                assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
34974            }
34975        }
34976        ready_tx.send(()).expect("signaling search lock hook");
34977        std::thread::sleep(Duration::from_millis(200));
34978        drop(conn);
34979        let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
34980    });
34981    ready_rx
34982        .recv_timeout(Duration::from_secs(1))
34983        .context("waiting for search post-precheck lock hook")?;
34984    Ok(())
34985}
34986
34987#[cfg(not(test))]
34988pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34989    Ok(())
34990}