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::{Cli, Commands, DispatchTraceFormat, GraphDbQuery, SemanticRelatedKind, SourceReadStyle};
85#[cfg(test)]
86use cli::{GraphDbBackend, TraverseFormat};
87use commands::digests::{
88    cmd_context_pack, cmd_diff_digest, cmd_log_digest, cmd_metric_digest, cmd_session_cost,
89    cmd_session_digest, cmd_session_review_with_budget, cmd_test_digest,
90};
91#[cfg(test)]
92use commands::graph::cmd_explain;
93use commands::graph::{
94    cmd_analyze, cmd_communities, cmd_explain_with_budget, cmd_graph, cmd_path, cmd_traverse,
95};
96#[cfg(test)]
97use commands::index_search::cmd_search;
98use commands::index_search::{cmd_index, cmd_search_with_budget, cmd_search_worker};
99use commands::infra::{
100    StatusCommandOptions, cmd_convex_sync, cmd_edit, cmd_graph_db, cmd_init, cmd_locks,
101    cmd_rewrite, cmd_route, cmd_sql, cmd_status,
102};
103use commands::memory::cmd_memory;
104use commands::quality::{cmd_audit, cmd_audit_tagpath, cmd_lint};
105use commands::summarize::cmd_summarize;
106use flate2::{Compression, read::GzDecoder, write::GzEncoder};
107#[cfg(test)]
108use output::ResponseBudgetPreset;
109use output::tagpath::{
110    TagpathAnnotationDiagnostic, TagpathSearchOpts, annotate_communities_with_tagpath,
111    annotate_hits_with_tagpath, annotate_path_nodes_with_tagpath,
112    annotate_stored_edges_with_tagpath, annotate_stored_symbols_with_tagpath,
113};
114use output::{
115    OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric, ToolEnvelopeSummary,
116    TranscriptArtifactRef,
117};
118use rusqlite::{Connection, OptionalExtension};
119use serde::{Deserialize, Serialize};
120use sift::{SearchInput, SearchOptions, Sift};
121#[cfg(test)]
122use std::cell::RefCell;
123use std::cmp::Ordering;
124use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
125use std::env;
126use std::fs;
127use std::io::{Read as _, Write as _};
128use std::path::{Path, PathBuf};
129use std::process::{Command, Stdio};
130use std::sync::{Mutex, OnceLock};
131use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
132use substrate::{
133    ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
134    GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
135    GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
136    SqliteGraphStore, SqliteProjectionRefresh, TerseGraphEdge as SubstrateTerseGraphEdge,
137    TerseGraphNode as SubstrateTerseGraphNode,
138};
139use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
140#[cfg(test)]
141use tsift_agent_doc::session_cost;
142#[cfg(test)]
143use tsift_agent_doc::session_review;
144use tsift_cache::cycle_packet_cache;
145use tsift_core::{
146    NeighborhoodScoring, RankedNeighborhoodOptions, SemanticSeededNeighborhoodOptions,
147};
148use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
149use tsift_graph as graph;
150use tsift_index::{config, index, init, multiplicity, walk};
151use tsift_memgraphrag::append_tsift_memory_graph_projection_rows;
152#[cfg(test)]
153use tsift_memory::MemoryEvent;
154use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
155use tsift_resolution as resolution;
156use tsift_search::{impact, sift};
157use tsift_sqlite as substrate;
158use tsift_status::status;
159use tsift_summarize::summarize;
160#[cfg(feature = "backend-surrealdb")]
161use tsift_surrealdb::SurrealdbGraphStore;
162use tsift_tokensave::TokensaveDb;
163
164#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
165pub(crate) enum GraphDbExperimentalBackend {
166    DuckdbDuckpgq,
167    Falkordb,
168    Ladybug,
169    Kuzu,
170    Surrealdb,
171}
172
173#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
174pub(crate) struct SearchFacetFilters {
175    #[serde(skip_serializing_if = "Vec::is_empty", default)]
176    pub(crate) languages: Vec<String>,
177    #[serde(skip_serializing_if = "Vec::is_empty", default)]
178    pub(crate) kinds: Vec<String>,
179    #[serde(skip_serializing_if = "Vec::is_empty", default)]
180    pub(crate) node_kinds: Vec<String>,
181    #[serde(skip_serializing_if = "Vec::is_empty", default)]
182    pub(crate) sections: Vec<String>,
183    #[serde(skip_serializing_if = "Vec::is_empty", default)]
184    pub(crate) parents: Vec<String>,
185    #[serde(skip_serializing_if = "Vec::is_empty", default)]
186    pub(crate) children: Vec<String>,
187    #[serde(skip_serializing_if = "Vec::is_empty", default)]
188    pub(crate) fence_languages: Vec<String>,
189    #[serde(skip_serializing_if = "Vec::is_empty", default)]
190    pub(crate) list_depths: Vec<usize>,
191    #[serde(skip_serializing_if = "Vec::is_empty", default)]
192    pub(crate) heading_levels: Vec<usize>,
193}
194
195impl SearchFacetFilters {
196    pub(crate) fn is_empty(&self) -> bool {
197        self.languages.is_empty()
198            && self.kinds.is_empty()
199            && self.node_kinds.is_empty()
200            && self.sections.is_empty()
201            && self.parents.is_empty()
202            && self.children.is_empty()
203            && self.fence_languages.is_empty()
204            && self.list_depths.is_empty()
205            && self.heading_levels.is_empty()
206    }
207
208    fn needs_ast_context(&self) -> bool {
209        !self.sections.is_empty()
210            || !self.parents.is_empty()
211            || !self.children.is_empty()
212            || !self.fence_languages.is_empty()
213            || !self.list_depths.is_empty()
214            || !self.heading_levels.is_empty()
215    }
216}
217
218#[derive(Serialize)]
219struct GraphDbBackendPromotionGate {
220    status: String,
221    native_adapter_required: bool,
222    required_checks: Vec<String>,
223}
224
225impl GraphDbExperimentalBackend {
226    fn name(self) -> &'static str {
227        match self {
228            Self::DuckdbDuckpgq => "duckdb-duckpgq",
229            Self::Falkordb => "falkordb",
230            Self::Ladybug => "ladybug",
231            Self::Kuzu => "kuzu",
232            Self::Surrealdb => "surrealdb",
233        }
234    }
235
236    fn adapter_label(self) -> &'static str {
237        match self {
238            Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
239            Self::Falkordb => "FalkorDB read-only prototype",
240            Self::Ladybug => "Ladybug read-only prototype",
241            Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
242            Self::Surrealdb => "SurrealDB read-only prototype",
243        }
244    }
245
246    fn projection_load(self) -> &'static str {
247        match self {
248            Self::Falkordb => {
249                "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"
250            }
251            Self::Kuzu => {
252                "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"
253            }
254            Self::Surrealdb => {
255                "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"
256            }
257            _ => {
258                "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
259            }
260        }
261    }
262
263    fn lock_behavior(self) -> &'static str {
264        match self {
265            Self::Falkordb => {
266                "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
267            }
268            Self::Kuzu => {
269                "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"
270            }
271            Self::Surrealdb => {
272                "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
273            }
274            _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
275        }
276    }
277
278    fn install_portability(self) -> &'static str {
279        match self {
280            Self::Falkordb => {
281                "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
282            }
283            Self::Kuzu => {
284                "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"
285            }
286            Self::Surrealdb => {
287                "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"
288            }
289            _ => {
290                "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
291            }
292        }
293    }
294
295    fn prototype_hold_reason(self) -> Option<&'static str> {
296        match self {
297            Self::DuckdbDuckpgq => Some(
298                "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
299            ),
300            Self::Falkordb => Some(
301                "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",
302            ),
303            Self::Ladybug => Some(
304                "Ladybug 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::Kuzu => Some(
307                "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
308            ),
309            Self::Surrealdb => Some(
310                "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",
311            ),
312        }
313    }
314
315    fn promotion_gate(self) -> GraphDbBackendPromotionGate {
316        match self {
317            Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
318                status: "hold_native_adapter_required".to_string(),
319                native_adapter_required: true,
320                required_checks: vec![
321                    "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
322                        .to_string(),
323                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
324                        .to_string(),
325                    "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
326                    "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
327                        .to_string(),
328                ],
329            },
330            Self::Falkordb => GraphDbBackendPromotionGate {
331                status: "hold_native_adapter_required".to_string(),
332                native_adapter_required: true,
333                required_checks: vec![
334                    "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
335                        .to_string(),
336                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
337                        .to_string(),
338                    "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
339                        .to_string(),
340                    "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
341                        .to_string(),
342                ],
343            },
344            Self::Ladybug => GraphDbBackendPromotionGate {
345                status: "hold_native_adapter_required".to_string(),
346                native_adapter_required: true,
347                required_checks: vec![
348                    "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
349                        .to_string(),
350                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
351                        .to_string(),
352                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
353                        .to_string(),
354                    "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
355                        .to_string(),
356                ],
357            },
358            Self::Kuzu => GraphDbBackendPromotionGate {
359                status: "hold_native_adapter_required".to_string(),
360                native_adapter_required: true,
361                required_checks: vec![
362                    "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
363                        .to_string(),
364                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
365                        .to_string(),
366                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
367                        .to_string(),
368                    "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
369                        .to_string(),
370                ],
371            },
372            Self::Surrealdb => GraphDbBackendPromotionGate {
373                status: "hold_native_adapter_required".to_string(),
374                native_adapter_required: true,
375                required_checks: vec![
376                    "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
377                        .to_string(),
378                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
379                        .to_string(),
380                    "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
381                        .to_string(),
382                    "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
383                        .to_string(),
384                ],
385            },
386        }
387    }
388
389    fn parse(raw: &str) -> Result<Self> {
390        match raw {
391            "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
392            "falkordb" | "falkor" => Ok(Self::Falkordb),
393            "ladybug" => Ok(Self::Ladybug),
394            "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
395            "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
396            _ => {
397                bail!(
398                    "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
399                )
400            }
401        }
402    }
403}
404
405pub fn run() -> Result<()> {
406    let cli = Cli::parse();
407    let compact = cli.compact;
408    let pretty = cli.pretty;
409    let terse = cli.terse || cli.ultra_terse;
410    let ultra_terse = cli.ultra_terse;
411    let absolute = cli.absolute;
412    let tabular = cli.tabular;
413    let schema = cli.schema;
414    let envelope = cli.envelope;
415    match cli.command {
416        Some(Commands::Search {
417            query,
418            path,
419            limit,
420            strategy,
421            exact,
422            scope,
423            federated,
424            lang,
425            kind,
426            node_kind,
427            section,
428            parent,
429            child,
430            fence_language,
431            list_depth,
432            heading_level,
433            json,
434            autoindex,
435            no_autoindex,
436            timeout,
437            max_items,
438            max_bytes,
439            budget,
440            no_tagpath,
441            tagpath_strict,
442        }) => cmd_search_with_budget(
443            query,
444            path,
445            limit,
446            if exact {
447                Some("exact".to_string())
448            } else {
449                strategy
450            },
451            scope,
452            federated,
453            json || terse || schema || envelope,
454            autoindex || !no_autoindex,
455            timeout,
456            compact,
457            pretty,
458            terse,
459            ultra_terse,
460            absolute,
461            tabular,
462            schema,
463            envelope,
464            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
465            TagpathSearchOpts {
466                no_tagpath,
467                strict: tagpath_strict,
468            },
469            SearchFacetFilters {
470                languages: lang,
471                kinds: kind,
472                node_kinds: node_kind,
473                sections: section,
474                parents: parent,
475                children: child,
476                fence_languages: fence_language,
477                list_depths: list_depth,
478                heading_levels: heading_level,
479            },
480        ),
481        Some(Commands::SearchWorker {
482            path,
483            cache_dir,
484            query,
485            limit,
486            strategy,
487            output,
488        }) => cmd_search_worker(&path, &cache_dir, &query, limit, &strategy, &output),
489        Some(Commands::DigestRunner {
490            kind,
491            path,
492            runner,
493            shell_command,
494            json,
495        }) => cmd_digest_runner(
496            &kind,
497            &path,
498            runner.as_deref(),
499            &shell_command,
500            OutputFormat {
501                json_output: json || terse || schema || envelope,
502                compact,
503                pretty,
504                terse,
505                ultra_terse,
506                schema,
507                envelope,
508            },
509        ),
510        Some(Commands::Edit { dry_run, file }) => {
511            cmd_edit(dry_run, file, compact, pretty, terse, schema)
512        }
513        Some(Commands::EditIntents {
514            path,
515            scope,
516            file,
517            json,
518            apply,
519            verify,
520            verify_command,
521            max_items,
522            max_bytes,
523            budget,
524        }) => cmd_edit_intents(
525            &path,
526            scope.as_deref(),
527            file,
528            apply,
529            SemanticEditVerifyOptions {
530                enabled: verify,
531                command: verify_command.as_deref(),
532            },
533            OutputFormat {
534                json_output: json || terse || schema || envelope,
535                compact,
536                pretty,
537                terse,
538                ultra_terse,
539                schema,
540                envelope,
541            },
542            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
543        ),
544        Some(Commands::Index {
545            path,
546            rebuild,
547            check,
548            exit_code,
549            prune,
550            quiet,
551            workspace,
552            submodule,
553            json,
554        }) => cmd_index(
555            &path,
556            rebuild,
557            check,
558            exit_code,
559            prune,
560            quiet,
561            workspace,
562            submodule.as_deref(),
563            json || terse || schema || envelope,
564            compact,
565            pretty,
566            terse,
567            absolute,
568            schema,
569        ),
570        Some(Commands::Rewrite { command, run }) => cmd_rewrite(
571            &command,
572            run,
573            OutputFormat {
574                json_output: terse || schema || envelope,
575                compact,
576                pretty,
577                terse,
578                ultra_terse,
579                schema,
580                envelope,
581            },
582        ),
583        Some(Commands::Route { task, id }) => cmd_route(&task, id),
584        Some(Commands::Memory { command }) => {
585            let json = command.json_output();
586            cmd_memory(
587                command,
588                OutputFormat {
589                    json_output: json || terse || schema || envelope,
590                    compact,
591                    pretty,
592                    terse,
593                    ultra_terse,
594                    schema,
595                    envelope,
596                },
597            )
598        }
599        Some(Commands::Finding { command }) => match command {
600            cli::FindingCommand::Add {
601                path,
602                kind,
603                title,
604                body,
605                about,
606                confidence,
607                status,
608                relates,
609                scope,
610                json,
611            } => commands::finding::cmd_finding_add(
612                &path,
613                &kind,
614                &title,
615                &body,
616                &about,
617                confidence,
618                &status,
619                relates.as_deref(),
620                scope.as_deref(),
621                json || terse || schema || envelope,
622                pretty,
623            ),
624            cli::FindingCommand::List {
625                path,
626                about,
627                kind,
628                status,
629                include_stale,
630                scope,
631                json,
632            } => commands::finding::cmd_finding_list(
633                &path,
634                about.as_deref(),
635                kind.as_deref(),
636                status.as_deref(),
637                include_stale,
638                scope.as_deref(),
639                json || terse || schema || envelope,
640                pretty,
641            ),
642            cli::FindingCommand::Harvest { path, scope, json } => {
643                commands::finding::cmd_finding_harvest(
644                    &path,
645                    scope.as_deref(),
646                    json || terse || schema || envelope,
647                    pretty,
648                )
649            }
650            cli::FindingCommand::Promote { id, path, json } => {
651                commands::finding::cmd_finding_promote(
652                    &path,
653                    &id,
654                    json || terse || schema || envelope,
655                    pretty,
656                )
657            }
658        },
659        Some(Commands::Graph {
660            symbol,
661            path,
662            callers,
663            callees,
664            scope,
665            limit,
666            json,
667            no_tagpath,
668            tagpath_strict,
669        }) => cmd_graph(
670            &symbol,
671            &path,
672            callers,
673            callees,
674            scope.as_deref(),
675            limit,
676            json || terse || schema || envelope,
677            compact,
678            pretty,
679            terse,
680            absolute,
681            tabular,
682            schema,
683            TagpathSearchOpts {
684                no_tagpath,
685                strict: tagpath_strict,
686            },
687        ),
688        Some(Commands::Sql {
689            db,
690            query,
691            table,
692            json,
693        }) => cmd_sql(
694            &db,
695            query,
696            table,
697            json || terse || schema || envelope,
698            compact,
699            pretty,
700            terse,
701            schema,
702        ),
703        Some(Commands::Communities {
704            path,
705            scope,
706            min_size,
707            limit,
708            json,
709            no_tagpath,
710            tagpath_strict,
711        }) => cmd_communities(
712            &path,
713            scope.as_deref(),
714            min_size,
715            limit,
716            json || terse || schema || envelope,
717            compact,
718            pretty,
719            terse,
720            tabular,
721            schema,
722            TagpathSearchOpts {
723                no_tagpath,
724                strict: tagpath_strict,
725            },
726        ),
727        Some(Commands::Analyze {
728            path,
729            scope,
730            entry_points,
731            limit,
732            json,
733        }) => cmd_analyze(
734            &path,
735            scope.as_deref(),
736            &entry_points,
737            limit,
738            OutputFormat {
739                json_output: json || terse || schema || envelope,
740                compact,
741                pretty,
742                terse,
743                ultra_terse,
744                schema,
745                envelope,
746            },
747        ),
748        Some(Commands::Path {
749            from,
750            to,
751            path,
752            scope,
753            json,
754            no_tagpath,
755            tagpath_strict,
756        }) => cmd_path(
757            &from,
758            &to,
759            &path,
760            scope.as_deref(),
761            json || terse || schema || envelope,
762            compact,
763            pretty,
764            terse,
765            schema,
766            TagpathSearchOpts {
767                no_tagpath,
768                strict: tagpath_strict,
769            },
770        ),
771        Some(Commands::Explain {
772            symbol,
773            path,
774            scope,
775            limit,
776            json,
777            max_items,
778            max_bytes,
779            budget,
780            no_tagpath,
781            tagpath_strict,
782        }) => cmd_explain_with_budget(
783            &symbol,
784            &path,
785            scope.as_deref(),
786            limit,
787            json || terse || schema || envelope,
788            compact,
789            pretty,
790            terse,
791            ultra_terse,
792            absolute,
793            tabular,
794            schema,
795            envelope,
796            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
797            TagpathSearchOpts {
798                no_tagpath,
799                strict: tagpath_strict,
800            },
801        ),
802        Some(Commands::Traverse {
803            node,
804            to,
805            path,
806            scope,
807            depth,
808            limit,
809            format,
810            convex_snapshot,
811        }) => cmd_traverse(
812            node.as_deref(),
813            to.as_deref(),
814            &path,
815            scope.as_deref(),
816            depth,
817            limit,
818            format,
819            pretty,
820            terse,
821            schema,
822            convex_snapshot.as_deref(),
823        ),
824        Some(Commands::ConvexSync {
825            path,
826            scope,
827            snapshot,
828            chunk_size,
829            remote_snapshot,
830            apply,
831            endpoint,
832            auth_token_env,
833            json,
834        }) => cmd_convex_sync(
835            ConvexSyncOptions {
836                path: &path,
837                scope: scope.as_deref(),
838                snapshot: snapshot.as_deref(),
839                chunk_size,
840                remote_snapshot,
841                apply,
842                endpoint: endpoint.as_deref(),
843                auth_token_env: &auth_token_env,
844            },
845            OutputFormat {
846                json_output: json || terse || schema || envelope,
847                compact,
848                pretty,
849                terse,
850                ultra_terse,
851                schema,
852                envelope,
853            },
854        ),
855        Some(Commands::GraphDb {
856            path,
857            scope,
858            backend,
859            convex_snapshot,
860            json,
861            query,
862        }) => cmd_graph_db(
863            &path,
864            scope.as_deref(),
865            backend,
866            convex_snapshot.as_deref(),
867            query,
868            OutputFormat {
869                json_output: json || terse || schema || envelope,
870                compact,
871                pretty,
872                terse,
873                ultra_terse,
874                schema,
875                envelope,
876            },
877        ),
878        Some(Commands::SourceRead {
879            file,
880            path,
881            style,
882            start,
883            lines,
884            end,
885            scope,
886            json,
887            max_items,
888            max_bytes,
889            budget,
890        }) => cmd_source_read(
891            &file,
892            &path,
893            style,
894            start,
895            lines,
896            end,
897            scope.as_deref(),
898            OutputFormat {
899                json_output: json || terse || schema || envelope,
900                compact,
901                pretty,
902                terse,
903                ultra_terse,
904                schema,
905                envelope,
906            },
907            absolute,
908            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
909        ),
910        Some(Commands::MarkdownAst {
911            file,
912            path,
913            node,
914            json,
915            max_items,
916            max_bytes,
917            budget,
918        }) => cmd_markdown_ast(
919            &file,
920            &path,
921            node.as_deref(),
922            OutputFormat {
923                json_output: json || terse || schema || envelope,
924                compact,
925                pretty,
926                terse,
927                ultra_terse,
928                schema,
929                envelope,
930            },
931            absolute,
932            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
933        ),
934        Some(Commands::SymbolRead {
935            symbol,
936            file,
937            path,
938            scope,
939            json,
940            max_items,
941            max_bytes,
942            budget,
943        }) => cmd_symbol_read(
944            &symbol,
945            file.as_deref(),
946            &path,
947            scope.as_deref(),
948            OutputFormat {
949                json_output: json || terse || schema || envelope,
950                compact,
951                pretty,
952                terse,
953                ultra_terse,
954                schema,
955                envelope,
956            },
957            absolute,
958            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
959        ),
960        Some(Commands::Audit {
961            skills_dir,
962            manifest,
963            usage,
964            cleanup,
965            report,
966            json,
967        }) => cmd_audit(
968            &skills_dir,
969            manifest,
970            usage,
971            cleanup,
972            report,
973            json || terse || schema || envelope,
974            compact,
975            pretty,
976            terse,
977            schema,
978        ),
979        Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
980            &path,
981            scope.as_deref(),
982            json || terse || schema || envelope,
983            pretty,
984            terse,
985            schema,
986        ),
987        Some(Commands::Init {
988            path,
989            codex,
990            opencode,
991            workspace,
992        }) => cmd_init(&path, codex, opencode, workspace),
993        Some(Commands::Lint {
994            file,
995            index,
996            entities_from,
997            json,
998        }) => cmd_lint(
999            &file,
1000            index,
1001            entities_from,
1002            json || terse || schema || envelope,
1003            compact,
1004            pretty,
1005            terse,
1006            schema,
1007        ),
1008        Some(Commands::Summarize {
1009            symbol,
1010            file,
1011            extract,
1012            diff,
1013            stats,
1014            path,
1015            json,
1016        }) => cmd_summarize(
1017            symbol,
1018            file,
1019            extract,
1020            diff,
1021            stats,
1022            &path,
1023            json || terse || schema || envelope,
1024            compact,
1025            pretty,
1026            terse,
1027            schema,
1028        ),
1029        Some(Commands::Semantic {
1030            query,
1031            path,
1032            scope,
1033            limit,
1034            kind,
1035            json,
1036        }) => cmd_semantic_related(
1037            &query,
1038            &path,
1039            scope.as_deref(),
1040            limit,
1041            kind,
1042            json || terse || schema || envelope,
1043            compact,
1044            pretty,
1045            terse,
1046            schema,
1047        ),
1048        Some(Commands::DiffDigest {
1049            path,
1050            cached,
1051            revision,
1052            max_parsed_files,
1053            json,
1054        }) => cmd_diff_digest(
1055            &path,
1056            cached,
1057            revision.as_deref(),
1058            max_parsed_files,
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::Impact {
1070            path,
1071            cached,
1072            revision,
1073            scope,
1074            limit,
1075            json,
1076        }) => cmd_impact(
1077            &path,
1078            cached,
1079            revision.as_deref(),
1080            scope.as_deref(),
1081            limit,
1082            OutputFormat {
1083                json_output: json || terse || schema || envelope,
1084                compact,
1085                pretty,
1086                terse,
1087                ultra_terse,
1088                schema,
1089                envelope,
1090            },
1091        ),
1092        Some(Commands::TestDigest {
1093            path,
1094            input,
1095            runner,
1096            json,
1097        }) => cmd_test_digest(
1098            &path,
1099            input.as_deref(),
1100            runner.as_deref(),
1101            OutputFormat {
1102                json_output: json || terse || schema || envelope,
1103                compact,
1104                pretty,
1105                terse,
1106                ultra_terse,
1107                schema,
1108                envelope,
1109            },
1110        ),
1111        Some(Commands::LogDigest { path, input, json }) => cmd_log_digest(
1112            &path,
1113            input.as_deref(),
1114            OutputFormat {
1115                json_output: json || terse || schema || envelope,
1116                compact,
1117                pretty,
1118                terse,
1119                ultra_terse,
1120                schema,
1121                envelope,
1122            },
1123        ),
1124        Some(Commands::ContextPack {
1125            path,
1126            test_input,
1127            runner,
1128            log_input,
1129            json,
1130            max_items,
1131            max_bytes,
1132            budget,
1133            convex_snapshot,
1134        }) => cmd_context_pack(
1135            &path,
1136            test_input.as_deref(),
1137            runner.as_deref(),
1138            log_input.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            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1149            convex_snapshot.as_deref(),
1150        ),
1151        Some(Commands::ConflictMatrix {
1152            targets,
1153            path,
1154            scope,
1155            depth,
1156            limit,
1157            impact_limit,
1158            json,
1159        }) => cmd_conflict_matrix(
1160            &path,
1161            scope.as_deref(),
1162            &targets,
1163            depth,
1164            limit,
1165            impact_limit,
1166            OutputFormat {
1167                json_output: json || terse || schema || envelope,
1168                compact,
1169                pretty,
1170                terse,
1171                ultra_terse,
1172                schema,
1173                envelope,
1174            },
1175        ),
1176        Some(Commands::DispatchTrace {
1177            targets,
1178            path,
1179            scope,
1180            depth,
1181            limit,
1182            impact_limit,
1183            format,
1184            json,
1185        }) => cmd_dispatch_trace(
1186            DispatchTraceOptions {
1187                path: &path,
1188                scope: scope.as_deref(),
1189                raw_targets: &targets,
1190                depth,
1191                limit,
1192                impact_limit,
1193                trace_format: if json {
1194                    DispatchTraceFormat::Json
1195                } else {
1196                    format
1197                },
1198            },
1199            OutputFormat {
1200                json_output: json || terse || schema || envelope,
1201                compact,
1202                pretty,
1203                terse,
1204                ultra_terse,
1205                schema,
1206                envelope,
1207            },
1208        ),
1209        Some(Commands::DependencyDag {
1210            targets,
1211            path,
1212            scope,
1213            depth,
1214            limit,
1215            json,
1216        }) => cmd_dependency_dag(
1217            &path,
1218            scope.as_deref(),
1219            &targets,
1220            depth,
1221            limit,
1222            OutputFormat {
1223                json_output: json || terse || schema || envelope,
1224                compact,
1225                pretty,
1226                terse,
1227                ultra_terse,
1228                schema,
1229                envelope,
1230            },
1231        ),
1232        Some(Commands::TokenSavings {
1233            fixture,
1234            fail_under,
1235            json,
1236        }) => token_savings::cmd_token_savings(
1237            &fixture,
1238            fail_under,
1239            OutputFormat {
1240                json_output: json || terse || schema || envelope,
1241                compact,
1242                pretty,
1243                terse,
1244                ultra_terse,
1245                schema,
1246                envelope,
1247            },
1248        ),
1249        Some(Commands::MetricDigest {
1250            input,
1251            baseline,
1252            metrics,
1253            lower_is_better,
1254            higher_is_better,
1255            history,
1256            top,
1257            json,
1258        }) => cmd_metric_digest(
1259            MetricDigestOptions {
1260                input_path: input.as_deref(),
1261                baseline_path: baseline.as_deref(),
1262                metrics: &metrics,
1263                lower_is_better: &lower_is_better,
1264                higher_is_better: &higher_is_better,
1265                history,
1266                top,
1267            },
1268            OutputFormat {
1269                json_output: json || terse || schema || envelope,
1270                compact,
1271                pretty,
1272                terse,
1273                ultra_terse,
1274                schema,
1275                envelope,
1276            },
1277        ),
1278        Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1279            &fixture,
1280            OutputFormat {
1281                json_output: json || terse || schema || envelope,
1282                compact,
1283                pretty,
1284                terse,
1285                ultra_terse,
1286                schema,
1287                envelope,
1288            },
1289        ),
1290        Some(Commands::TokenGate { command }) => {
1291            cmd_token_gate(
1292                command,
1293                OutputFormat {
1294                    json_output: true,
1295                    compact,
1296                    pretty,
1297                    terse,
1298                    ultra_terse,
1299                    schema,
1300                    envelope,
1301                },
1302            )?;
1303            Ok(())
1304        }
1305        Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1306            &topic,
1307            OutputFormat {
1308                json_output: json || terse || schema || envelope,
1309                compact,
1310                pretty,
1311                terse,
1312                ultra_terse,
1313                schema,
1314                envelope,
1315            },
1316        ),
1317        Some(Commands::SessionDigest {
1318            path,
1319            input,
1320            source,
1321            json,
1322        }) => cmd_session_digest(
1323            &path,
1324            input.as_deref(),
1325            source.as_deref(),
1326            OutputFormat {
1327                json_output: json || terse || schema || envelope,
1328                compact,
1329                pretty,
1330                terse,
1331                ultra_terse,
1332                schema,
1333                envelope,
1334            },
1335        ),
1336        Some(Commands::SessionCost {
1337            input,
1338            fixture,
1339            fail_under,
1340            source,
1341            json,
1342        }) => cmd_session_cost(
1343            input.as_deref(),
1344            fixture.as_deref(),
1345            fail_under,
1346            source.as_deref(),
1347            OutputFormat {
1348                json_output: json || terse || schema || envelope,
1349                compact,
1350                pretty,
1351                terse,
1352                ultra_terse,
1353                schema,
1354                envelope,
1355            },
1356        ),
1357        Some(Commands::SessionReview {
1358            path,
1359            next_context,
1360            json,
1361            max_items,
1362            max_bytes,
1363            budget,
1364        }) => cmd_session_review_with_budget(
1365            &path,
1366            next_context,
1367            OutputFormat {
1368                json_output: json || terse || schema || envelope,
1369                compact,
1370                pretty,
1371                terse,
1372                ultra_terse,
1373                schema,
1374                envelope,
1375            },
1376            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1377        ),
1378        Some(Commands::Status {
1379            path,
1380            fix,
1381            no_fix,
1382            json,
1383        }) => cmd_status(
1384            &path,
1385            StatusCommandOptions {
1386                fix,
1387                no_fix,
1388                json_output: json || terse || schema || envelope,
1389                compact,
1390                pretty,
1391                terse,
1392                schema,
1393            },
1394        ),
1395        Some(Commands::Locks { path, scope, json }) => cmd_locks(
1396            &path,
1397            scope.as_deref(),
1398            json || terse || schema || envelope,
1399            compact,
1400            pretty,
1401            terse,
1402            schema,
1403        ),
1404        None => {
1405            println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1406            println!("Run `tsift --help` for usage.");
1407            Ok(())
1408        }
1409    }
1410}
1411
1412/// Classify a task description into a model tier.
1413/// Returns (tier_name, model_id).
1414pub fn classify_task(task: &str) -> (&'static str, &'static str) {
1415    let lower = task.to_lowercase();
1416    // Architecture/design signals → opus
1417    for signal in &[
1418        "architect",
1419        "architecture",
1420        "design",
1421        "plan",
1422        "strateg",
1423        "analy",
1424        "review",
1425        "evaluate",
1426        "assess",
1427    ] {
1428        if lower.contains(signal) {
1429            return ("opus", "claude-opus-4-6");
1430        }
1431    }
1432    // Edit/write signals → sonnet
1433    for signal in &[
1434        "edit",
1435        "write",
1436        "fix",
1437        "change",
1438        "update",
1439        "create",
1440        "add ",
1441        "remove",
1442        "delete",
1443        "modify",
1444        "refactor",
1445        "implement",
1446        "build",
1447    ] {
1448        if lower.contains(signal) {
1449            return ("sonnet", "claude-sonnet-4-6");
1450        }
1451    }
1452    // Default: search/lookup → haiku
1453    ("haiku", "claude-haiku-4-5-20251001")
1454}
1455
1456#[cfg(test)]
1457fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
1458    to_json_schema(val, pretty, terse, false, false)
1459}
1460
1461/// Add top-level `tagpath_index_stale: true` + `tagpath_stale_reason: <reason>`
1462/// fields to a JSON response when the tagpath adapter reported any helper
1463/// going stale. JSON consumers (`tsift --envelope` / `--json` callers) can
1464/// then act on the same condition the stderr `tagpath_index_stale: …` log
1465/// already surfaces without parsing logs. No-op when `stale=false` or when
1466/// `value` is not a JSON object.
1467pub(crate) fn inject_tagpath_stale_into_json(
1468    value: &mut serde_json::Value,
1469    stale: bool,
1470    reason: Option<&str>,
1471) {
1472    if !stale {
1473        return;
1474    }
1475    if let Some(obj) = value.as_object_mut() {
1476        obj.insert(
1477            "tagpath_index_stale".to_string(),
1478            serde_json::Value::Bool(true),
1479        );
1480        if let Some(reason) = reason {
1481            obj.insert(
1482                "tagpath_stale_reason".to_string(),
1483                serde_json::Value::String(reason.to_string()),
1484            );
1485        }
1486    }
1487}
1488
1489pub(crate) fn to_json_schema<T: serde::Serialize>(
1490    val: &T,
1491    pretty: bool,
1492    terse: bool,
1493    ultra_terse: bool,
1494    schema: bool,
1495) -> anyhow::Result<String> {
1496    if terse || schema {
1497        let value = serde_json::to_value(val)?;
1498        let mut transformed = if terse { terse_transform(value) } else { value };
1499        if ultra_terse {
1500            transformed = ultra_terse_transform(transformed);
1501            transformed = edge_index_transform(transformed);
1502        }
1503        if schema {
1504            transformed = schema_transform(transformed);
1505        }
1506        if terse {
1507            let terse_schema = terse_schema_for(&transformed);
1508            let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
1509            if pretty {
1510                Ok(serde_json::to_string_pretty(&wrapped)?)
1511            } else {
1512                Ok(serde_json::to_string(&wrapped)?)
1513            }
1514        } else if pretty {
1515            Ok(serde_json::to_string_pretty(&transformed)?)
1516        } else {
1517            Ok(serde_json::to_string(&transformed)?)
1518        }
1519    } else if pretty {
1520        Ok(serde_json::to_string_pretty(val)?)
1521    } else {
1522        Ok(serde_json::to_string(val)?)
1523    }
1524}
1525
1526pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
1527    ToolEnvelopeMetric {
1528        label: label.to_string(),
1529        value: value.to_string(),
1530    }
1531}
1532
1533pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
1534    let mut seen = HashSet::new();
1535    let mut deduped = Vec::new();
1536    for value in values {
1537        if seen.insert(value.clone()) {
1538            deduped.push(value);
1539        }
1540    }
1541    deduped
1542}
1543
1544pub(crate) fn print_json_or_envelope<T: Serialize>(
1545    report: &T,
1546    format: &OutputFormat,
1547    tool: &str,
1548    view: &str,
1549    summary: ToolEnvelopeSummary,
1550    truncated: bool,
1551    follow_up: Vec<String>,
1552) -> Result<()> {
1553    if format.envelope {
1554        let schema = format.schema || tool == "source-read";
1555        let envelope = ToolEnvelope {
1556            tool,
1557            view,
1558            summary,
1559            truncated,
1560            follow_up: dedupe_preserve_order(follow_up),
1561            report,
1562        };
1563        println!(
1564            "{}",
1565            to_json_schema(
1566                &envelope,
1567                format.pretty,
1568                format.terse,
1569                format.ultra_terse,
1570                schema
1571            )?
1572        );
1573    } else {
1574        println!(
1575            "{}",
1576            to_json_schema(
1577                report,
1578                format.pretty,
1579                format.terse,
1580                format.ultra_terse,
1581                format.schema
1582            )?
1583        );
1584    }
1585    Ok(())
1586}
1587
1588pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
1589    bytes.div_ceil(4)
1590}
1591
1592fn cmd_token_gate(command: cli::TokenGateCommand, format: OutputFormat) -> Result<()> {
1593    match command {
1594        cli::TokenGateCommand::Sample {
1595            surface,
1596            path,
1597            scope,
1598            target,
1599            depth,
1600            sample_index,
1601            json: _,
1602        } => cmd_token_gate_sample(
1603            &surface,
1604            &path,
1605            scope.as_deref(),
1606            target.as_deref(),
1607            depth,
1608            sample_index,
1609        ),
1610        cli::TokenGateCommand::Evaluate {
1611            history,
1612            allowed_regression_percent,
1613            json: _,
1614        } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
1615    }
1616}
1617
1618fn cmd_token_gate_sample(
1619    surface: &str,
1620    path: &Path,
1621    scope: Option<&str>,
1622    target: Option<&str>,
1623    depth: usize,
1624    sample_index: usize,
1625) -> Result<()> {
1626    if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
1627        bail!(
1628            "unknown surface `{}`; expected one of: {}",
1629            surface,
1630            token_gate::TOKEN_GATE_SURFACES.join(", ")
1631        );
1632    }
1633
1634    let path_str = path.to_string_lossy().to_string();
1635    let tsift_bin = std::env::current_exe()?;
1636
1637    let args: Vec<String> = match surface {
1638        "context_pack" => vec!["context-pack".to_string(), "--json".to_string(), path_str],
1639        "session_review_next_context" => vec![
1640            "session-review".to_string(),
1641            "--json".to_string(),
1642            "--next-context".to_string(),
1643            path_str,
1644        ],
1645        "graph_db_evidence" => {
1646            let tgt = target.unwrap_or("default").to_string();
1647            vec![
1648                "graph-db".to_string(),
1649                "--json".to_string(),
1650                "--path".to_string(),
1651                path_str,
1652                "evidence".to_string(),
1653                tgt,
1654                "--depth".to_string(),
1655                depth.to_string(),
1656            ]
1657        }
1658        "conflict_matrix" => {
1659            let tgt = target.unwrap_or("default").to_string();
1660            let mut a = vec![
1661                "conflict-matrix".to_string(),
1662                "--json".to_string(),
1663                "--path".to_string(),
1664                path_str,
1665                "--depth".to_string(),
1666                depth.to_string(),
1667            ];
1668            if let Some(s) = scope {
1669                a.push("--scope".to_string());
1670                a.push(s.to_string());
1671            }
1672            a.push(tgt);
1673            a
1674        }
1675        "dispatch_trace" => {
1676            let tgt = target.unwrap_or("default").to_string();
1677            vec![
1678                "dispatch-trace".to_string(),
1679                "--json".to_string(),
1680                "--path".to_string(),
1681                path_str,
1682                tgt,
1683            ]
1684        }
1685        _ => bail!("unhandled surface: {}", surface),
1686    };
1687
1688    let start = Instant::now();
1689    let child = Command::new(&tsift_bin)
1690        .args(&args)
1691        .stdout(Stdio::piped())
1692        .stderr(Stdio::piped())
1693        .env("TSIFT_QUIET", "1")
1694        .spawn();
1695    let output = match child {
1696        Ok(c) => c.wait_with_output()?,
1697        Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
1698    };
1699    let runtime_micros = start.elapsed().as_micros() as f64;
1700
1701    let stdout = String::from_utf8_lossy(&output.stdout);
1702    let envelope_bytes = stdout.trim().len() as f64;
1703    let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
1704
1705    let cache_hit_rate_percent = 0.0;
1706    let raw_read_avoidance = 0.0;
1707    let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
1708
1709    let timestamp = iso_timestamp_now();
1710    let id = format!(
1711        "{surface}-baseline-{}-sample-{sample_index}",
1712        &timestamp[..10]
1713    );
1714    let label = format!(
1715        "token-gate baseline {surface} sample {sample_index} for {}",
1716        path.display()
1717    );
1718
1719    let mut metrics = BTreeMap::new();
1720    metrics.insert("prompt_tokens".to_string(), prompt_tokens);
1721    metrics.insert("envelope_bytes".to_string(), envelope_bytes);
1722    metrics.insert("runtime_micros".to_string(), runtime_micros);
1723    metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
1724    metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
1725    metrics.insert("useful_hit_density".to_string(), useful_hit_density);
1726
1727    let sample = token_gate::TokenGateSample {
1728        label,
1729        id,
1730        timestamp: Some(timestamp),
1731        surface: surface.to_string(),
1732        metrics,
1733    };
1734
1735    println!("{}", serde_json::to_string_pretty(&sample)?);
1736    Ok(())
1737}
1738
1739fn cmd_token_gate_evaluate(
1740    history_path: Option<&Path>,
1741    allowed_regression_percent: f64,
1742    format: &OutputFormat,
1743) -> Result<()> {
1744    let history_path = history_path.map(PathBuf::from).unwrap_or_else(|| {
1745        let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1746        p.push("../../fixtures/token-gate-history.json");
1747        p
1748    });
1749
1750    let raw = std::fs::read_to_string(&history_path).with_context(|| {
1751        format!(
1752            "failed to read token gate history: {}",
1753            history_path.display()
1754        )
1755    })?;
1756    let samples = token_gate::parse_token_history(&raw)?;
1757    let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
1758
1759    if format.json_output {
1760        println!(
1761            "{}",
1762            to_json_schema(&report, format.pretty, format.terse, false, format.schema)?
1763        );
1764    } else {
1765        println!("Token Gate Report");
1766        println!("  min_samples: {}", report.min_samples);
1767        println!(
1768            "  allowed_regression: {:.1}%",
1769            report.allowed_regression_percent
1770        );
1771        println!("  decision: {:?}", report.decision);
1772        for eval in &report.surface_evaluations {
1773            println!(
1774                "  {} ({} samples): {:?}",
1775                eval.display_name, eval.sample_count, eval.verdict
1776            );
1777            for me in &eval.metric_evaluations {
1778                println!("    {} ({:?}): {}", me.metric, me.direction, me.diagnostic);
1779            }
1780        }
1781        for d in &report.diagnostics {
1782            println!("  ! {}", d);
1783        }
1784    }
1785    Ok(())
1786}
1787
1788fn iso_timestamp_now() -> String {
1789    let dur = SystemTime::now()
1790        .duration_since(UNIX_EPOCH)
1791        .unwrap_or_default();
1792    let total_secs = dur.as_secs();
1793    let days_since_epoch = total_secs / 86400;
1794    let (year, month, day) = days_to_ymd(days_since_epoch);
1795    let time_of_day = total_secs % 86400;
1796    let hour = (time_of_day / 3600) as u8;
1797    let minute = ((time_of_day % 3600) / 60) as u8;
1798    let second = (time_of_day % 60) as u8;
1799    format!(
1800        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1801        year, month, day, hour, minute, second
1802    )
1803}
1804
1805fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
1806    let mut year = 1970u64;
1807    loop {
1808        let days_in_year = if is_leap(year) { 366 } else { 365 };
1809        if days < days_in_year {
1810            break;
1811        }
1812        days -= days_in_year;
1813        year += 1;
1814    }
1815    let leap = is_leap(year);
1816    let month_days: [u8; 12] = if leap {
1817        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1818    } else {
1819        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1820    };
1821    let mut month: u8 = 1;
1822    for &md in &month_days {
1823        if days < md as u64 {
1824            break;
1825        }
1826        days -= md as u64;
1827        month += 1;
1828    }
1829    let day = days as u8 + 1;
1830    (year, month, day)
1831}
1832
1833fn is_leap(year: u64) -> bool {
1834    year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
1835}
1836
1837fn persist_transcript_artifact(
1838    root: &Path,
1839    prefix: &str,
1840    suffix: &str,
1841    key: &str,
1842    body: &str,
1843    expand: String,
1844) -> Result<TranscriptArtifactRef> {
1845    let handle = stable_handle(prefix, key);
1846    let artifacts_dir = root.join(".tsift/artifacts");
1847    fs::create_dir_all(&artifacts_dir).with_context(|| {
1848        format!(
1849            "creating transcript artifacts dir: {}",
1850            artifacts_dir.display()
1851        )
1852    })?;
1853    let file_name = format!("{handle}.{suffix}");
1854    let artifact_path = artifacts_dir.join(file_name);
1855    fs::write(&artifact_path, body)
1856        .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
1857    let rel_path = relativize_pathbuf(&artifact_path, root);
1858    Ok(TranscriptArtifactRef {
1859        handle,
1860        path: rel_path.display().to_string(),
1861        bytes: body.len(),
1862        lines: body.lines().count(),
1863        expand,
1864    })
1865}
1866
1867fn terse_key(key: &str) -> &str {
1868    match key {
1869        "name" => "n",
1870        "kind" => "k",
1871        "file" => "f",
1872        "line" => "l",
1873        "path" => "p",
1874        "from" => "fr",
1875        "type" => "ty",
1876        "text" => "tx",
1877        "new" => "nw",
1878        "run" => "r",
1879        "use" => "u",
1880        "score" => "sc",
1881        "language" => "la",
1882        "status" => "st",
1883        "state" => "stt",
1884        "error" => "err",
1885        "errors" => "ers",
1886        "hops" => "hp",
1887        "tags" => "tg",
1888        "model" => "ml",
1889        "skill" => "sk",
1890        "count" => "ct",
1891        "total" => "tot",
1892        "column" => "col",
1893        "description" => "dsc",
1894        "end_line" => "el",
1895        "signature" => "sig",
1896        "parent_module" => "pm",
1897        "visibility" => "vis",
1898        "match_type" => "mt",
1899        "caller_file" => "cf",
1900        "caller_name" => "cn",
1901        "caller_line" => "cl",
1902        "callee_name" => "en",
1903        "call_site_line" => "csl",
1904        "members" => "m",
1905        "refs" => "refs",
1906        "role" => "rl",
1907        "peer" => "pr",
1908        "modularity" => "q",
1909        "modularity_contribution" => "mc",
1910        "iterations" => "it",
1911        "node_count" => "nc",
1912        "edge_count" => "ec",
1913        "community_count" => "cc",
1914        "communities" => "cms",
1915        "community" => "cm",
1916        "community_diagnostics" => "cd",
1917        "cache_hit" => "cah",
1918        "tagpath_state" => "tps",
1919        "tagpath_stale_reason" => "tsr",
1920        "annotated_community_count" => "acc",
1921        "annotated_member_count" => "amc",
1922        "ambiguous_member_count" => "ambc",
1923        "ambiguous_members" => "amb",
1924        "candidate_count" => "cand",
1925        "tagpath_candidate_count" => "tcand",
1926        "evidence" => "ev",
1927        "chosen_file" => "chf",
1928        "symbol" => "s",
1929        "symbols" => "sy",
1930        "definitions" => "df",
1931        "callers" => "crs",
1932        "callees" => "ces",
1933        "total_tracked" => "tt",
1934        "modified" => "md",
1935        "deleted" => "dl",
1936        "unchanged" => "uc",
1937        "changes" => "ch",
1938        "prune_stats" => "ps",
1939        "hits" => "h",
1940        "rank" => "rk",
1941        "snippet" => "sn",
1942        "confidence" => "co",
1943        "index" => "ix",
1944        "summaries" => "sms",
1945        "recommendations" => "rec",
1946        "total_files" => "tf",
1947        "stale_files" => "sf",
1948        "last_indexed_secs_ago" => "age",
1949        "cached_files" => "caf",
1950        "total_indexed_files" => "tif",
1951        "coverage_pct" => "cov",
1952        "symbol_name" => "syn",
1953        "file_path" => "fp",
1954        "content_hash" => "hsh",
1955        "summary" => "sum",
1956        "tool" => "tl",
1957        "view" => "vw",
1958        "truncated" => "tr",
1959        "follow_up" => "fu",
1960        "report" => "rp",
1961        "metrics" => "ms",
1962        "label" => "lb",
1963        "value" => "v",
1964        "command" => "cmd",
1965        "exit_code" => "xc",
1966        "success" => "ok",
1967        "artifact" => "art",
1968        "digest" => "dg",
1969        "bytes" => "bt",
1970        "lines" => "lns",
1971        "expand" => "xp",
1972        "entities" => "ent",
1973        "relationships" => "rel",
1974        "concept_labels" => "cls",
1975        "extracted_at" => "at",
1976        "tokens_input" => "ti",
1977        "tokens_output" => "tout",
1978        "total_summaries" => "ts",
1979        "stale_count" => "stc",
1980        "total_tokens_input" => "tti",
1981        "total_tokens_output" => "tto",
1982        "estimated_tokens_saved" => "ets",
1983        "files_processed" => "fps",
1984        "symbols_extracted" => "se",
1985        "skills_dir" => "sd",
1986        "healthy" => "ok",
1987        "broken" => "brk",
1988        "skills" => "sks",
1989        "manifest_diffs" => "mdf",
1990        "similar_pairs" => "sim",
1991        "usage" => "usg",
1992        "cleanup" => "cln",
1993        "has_skill_md" => "hsm",
1994        "is_symlink" => "isl",
1995        "issues" => "iss",
1996        "invocation_count" => "inv",
1997        "reasons" => "rsn",
1998        "token_estimate" => "te",
1999        "skill_a" => "sa",
2000        "skill_b" => "sb",
2001        "desc_a" => "da",
2002        "desc_b" => "db",
2003        "annotations" => "ann",
2004        "entity" => "ety",
2005        "suggestion" => "sug",
2006        "columns" => "cols",
2007        "row_count" => "rc",
2008        "notnull" => "nn",
2009        "default_value" => "dv",
2010        "replace_all" => "ra",
2011        other => other,
2012    }
2013}
2014
2015fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2016    match val {
2017        serde_json::Value::Object(map) => {
2018            let mut new_map = serde_json::Map::new();
2019            for (k, v) in map {
2020                new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2021            }
2022            serde_json::Value::Object(new_map)
2023        }
2024        serde_json::Value::Array(arr) => {
2025            serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2026        }
2027        other => other,
2028    }
2029}
2030
2031fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2032    match val {
2033        serde_json::Value::Object(mut map) => {
2034            let is_graph_node =
2035                map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2036            let is_graph_edge =
2037                map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2038            if is_graph_node || is_graph_edge {
2039                map.remove("properties");
2040                map.remove("provenance");
2041                map.remove("freshness");
2042            }
2043            if is_graph_edge && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2044                *s = abbreviate_edge_kind(s).to_string();
2045            }
2046            let is_coverage = map.contains_key("mode")
2047                && (map.contains_key("total_sector_count")
2048                    || map.contains_key("dirty_sector_count"));
2049            if is_coverage {
2050                map.remove("active_rebuild");
2051                map.remove("completed_dirty_sector_count");
2052                map.remove("mounted_sector_count");
2053                map.remove("rebuilding_sector_count");
2054                map.remove("resumed_sector_count");
2055                map.remove("reused_sector_count");
2056            }
2057            if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2058                *s = truncate_for_ultra_terse(s, 80);
2059            }
2060            if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2061                *s = truncate_for_ultra_terse(s, 80);
2062            }
2063            let new_map: serde_json::Map<String, serde_json::Value> = map
2064                .into_iter()
2065                .map(|(k, v)| (k, ultra_terse_transform(v)))
2066                .collect();
2067            serde_json::Value::Object(new_map)
2068        }
2069        serde_json::Value::Array(arr) => {
2070            serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2071        }
2072        other => other,
2073    }
2074}
2075
2076fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2077    match val {
2078        serde_json::Value::Object(mut map) => {
2079            let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2080                nodes.as_array().map(|arr| {
2081                    arr.iter()
2082                        .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2083                        .collect()
2084                })
2085            });
2086            if let Some(ref ids) = node_ids {
2087                let id_map: std::collections::HashMap<&str, usize> = ids
2088                    .iter()
2089                    .enumerate()
2090                    .map(|(i, id)| (id.as_str(), i))
2091                    .collect();
2092                if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2093                    for edge in edges.iter_mut() {
2094                        if let serde_json::Value::Object(edge_map) = edge {
2095                            if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id")
2096                            {
2097                                if let Some(&idx) = id_map.get(fid.as_str()) {
2098                                    edge_map.insert(
2099                                        "from".to_string(),
2100                                        serde_json::Value::Number(idx.into()),
2101                                    );
2102                                } else {
2103                                    edge_map.insert(
2104                                        "from_id".to_string(),
2105                                        serde_json::Value::String(fid),
2106                                    );
2107                                }
2108                            }
2109                            if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2110                                if let Some(&idx) = id_map.get(tid.as_str()) {
2111                                    edge_map.insert(
2112                                        "to".to_string(),
2113                                        serde_json::Value::Number(idx.into()),
2114                                    );
2115                                } else {
2116                                    edge_map.insert(
2117                                        "to_id".to_string(),
2118                                        serde_json::Value::String(tid),
2119                                    );
2120                                }
2121                            }
2122                        }
2123                    }
2124                }
2125            }
2126            let new_map: serde_json::Map<String, serde_json::Value> = map
2127                .into_iter()
2128                .map(|(k, v)| (k, edge_index_transform(v)))
2129                .collect();
2130            serde_json::Value::Object(new_map)
2131        }
2132        serde_json::Value::Array(arr) => {
2133            serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2134        }
2135        other => other,
2136    }
2137}
2138
2139fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2140    if s.len() <= max_len {
2141        s.to_string()
2142    } else {
2143        let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2144        format!("{truncated}...")
2145    }
2146}
2147
2148fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2149    let mut keys = HashSet::new();
2150    collect_terse_keys(val, &mut keys);
2151    let mut schema = serde_json::Map::new();
2152    for (long, short) in TERSE_PAIRS {
2153        if keys.contains(*short) {
2154            schema.insert(
2155                short.to_string(),
2156                serde_json::Value::String(long.to_string()),
2157            );
2158        }
2159    }
2160    serde_json::Value::Object(schema)
2161}
2162
2163fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2164    match val {
2165        serde_json::Value::Object(map) => {
2166            for (k, v) in map {
2167                keys.insert(k.clone());
2168                collect_terse_keys(v, keys);
2169            }
2170        }
2171        serde_json::Value::Array(arr) => {
2172            for v in arr {
2173                collect_terse_keys(v, keys);
2174            }
2175        }
2176        _ => {}
2177    }
2178}
2179
2180fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2181    match val {
2182        serde_json::Value::Array(arr) if arr.len() >= 2 => {
2183            if let Some(cols) = homogeneous_keys(&arr) {
2184                let rows: Vec<serde_json::Value> = arr
2185                    .into_iter()
2186                    .map(|item| {
2187                        if let serde_json::Value::Object(map) = item {
2188                            let vals: Vec<serde_json::Value> = cols
2189                                .iter()
2190                                .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2191                                .collect();
2192                            serde_json::Value::Array(vals)
2193                        } else {
2194                            item
2195                        }
2196                    })
2197                    .collect();
2198                let col_vals: Vec<serde_json::Value> =
2199                    cols.into_iter().map(serde_json::Value::String).collect();
2200                serde_json::json!({"_c": col_vals, "_r": rows})
2201            } else {
2202                serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2203            }
2204        }
2205        serde_json::Value::Array(arr) => {
2206            serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2207        }
2208        serde_json::Value::Object(map) => {
2209            let new_map: serde_json::Map<String, serde_json::Value> = map
2210                .into_iter()
2211                .map(|(k, v)| (k, schema_transform(v)))
2212                .collect();
2213            serde_json::Value::Object(new_map)
2214        }
2215        other => other,
2216    }
2217}
2218
2219fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2220    let first = arr.first()?.as_object()?;
2221    let keys: Vec<String> = first.keys().cloned().collect();
2222    for item in &arr[1..] {
2223        let obj = item.as_object()?;
2224        if obj.len() != keys.len() {
2225            return None;
2226        }
2227        for k in &keys {
2228            if !obj.contains_key(k) {
2229                return None;
2230            }
2231        }
2232    }
2233    Some(keys)
2234}
2235
2236const TERSE_PAIRS: &[(&str, &str)] = &[
2237    ("name", "n"),
2238    ("kind", "k"),
2239    ("file", "f"),
2240    ("line", "l"),
2241    ("path", "p"),
2242    ("from", "fr"),
2243    ("type", "ty"),
2244    ("text", "tx"),
2245    ("new", "nw"),
2246    ("run", "r"),
2247    ("use", "u"),
2248    ("score", "sc"),
2249    ("language", "la"),
2250    ("status", "st"),
2251    ("state", "stt"),
2252    ("error", "err"),
2253    ("errors", "ers"),
2254    ("hops", "hp"),
2255    ("tags", "tg"),
2256    ("model", "ml"),
2257    ("skill", "sk"),
2258    ("count", "ct"),
2259    ("total", "tot"),
2260    ("column", "col"),
2261    ("description", "dsc"),
2262    ("end_line", "el"),
2263    ("signature", "sig"),
2264    ("parent_module", "pm"),
2265    ("visibility", "vis"),
2266    ("match_type", "mt"),
2267    ("caller_file", "cf"),
2268    ("caller_name", "cn"),
2269    ("caller_line", "cl"),
2270    ("callee_name", "en"),
2271    ("call_site_line", "csl"),
2272    ("members", "m"),
2273    ("refs", "refs"),
2274    ("role", "rl"),
2275    ("peer", "pr"),
2276    ("modularity", "q"),
2277    ("modularity_contribution", "mc"),
2278    ("iterations", "it"),
2279    ("node_count", "nc"),
2280    ("edge_count", "ec"),
2281    ("community_count", "cc"),
2282    ("communities", "cms"),
2283    ("community", "cm"),
2284    ("community_diagnostics", "cd"),
2285    ("cache_hit", "cah"),
2286    ("tagpath_state", "tps"),
2287    ("tagpath_stale_reason", "tsr"),
2288    ("annotated_community_count", "acc"),
2289    ("annotated_member_count", "amc"),
2290    ("ambiguous_member_count", "ambc"),
2291    ("ambiguous_members", "amb"),
2292    ("candidate_count", "cand"),
2293    ("tagpath_candidate_count", "tcand"),
2294    ("evidence", "ev"),
2295    ("chosen_file", "chf"),
2296    ("symbol", "s"),
2297    ("symbols", "sy"),
2298    ("definitions", "df"),
2299    ("callers", "crs"),
2300    ("callees", "ces"),
2301    ("total_tracked", "tt"),
2302    ("modified", "md"),
2303    ("deleted", "dl"),
2304    ("unchanged", "uc"),
2305    ("changes", "ch"),
2306    ("prune_stats", "ps"),
2307    ("hits", "h"),
2308    ("rank", "rk"),
2309    ("snippet", "sn"),
2310    ("confidence", "co"),
2311    ("index", "ix"),
2312    ("summaries", "sms"),
2313    ("recommendations", "rec"),
2314    ("total_files", "tf"),
2315    ("stale_files", "sf"),
2316    ("last_indexed_secs_ago", "age"),
2317    ("cached_files", "caf"),
2318    ("total_indexed_files", "tif"),
2319    ("coverage_pct", "cov"),
2320    ("symbol_name", "syn"),
2321    ("file_path", "fp"),
2322    ("content_hash", "hsh"),
2323    ("summary", "sum"),
2324    ("tool", "tl"),
2325    ("view", "vw"),
2326    ("truncated", "tr"),
2327    ("follow_up", "fu"),
2328    ("report", "rp"),
2329    ("metrics", "ms"),
2330    ("label", "lb"),
2331    ("value", "v"),
2332    ("command", "cmd"),
2333    ("exit_code", "xc"),
2334    ("success", "ok"),
2335    ("artifact", "art"),
2336    ("digest", "dg"),
2337    ("bytes", "bt"),
2338    ("lines", "lns"),
2339    ("expand", "xp"),
2340    ("entities", "ent"),
2341    ("relationships", "rel"),
2342    ("concept_labels", "cls"),
2343    ("extracted_at", "at"),
2344    ("tokens_input", "ti"),
2345    ("tokens_output", "tout"),
2346    ("total_summaries", "ts"),
2347    ("stale_count", "stc"),
2348    ("total_tokens_input", "tti"),
2349    ("total_tokens_output", "tto"),
2350    ("estimated_tokens_saved", "ets"),
2351    ("files_processed", "fps"),
2352    ("symbols_extracted", "se"),
2353    ("skills_dir", "sd"),
2354    ("healthy", "ok"),
2355    ("broken", "brk"),
2356    ("skills", "sks"),
2357    ("manifest_diffs", "mdf"),
2358    ("similar_pairs", "sim"),
2359    ("usage", "usg"),
2360    ("cleanup", "cln"),
2361    ("has_skill_md", "hsm"),
2362    ("is_symlink", "isl"),
2363    ("issues", "iss"),
2364    ("invocation_count", "inv"),
2365    ("reasons", "rsn"),
2366    ("token_estimate", "te"),
2367    ("skill_a", "sa"),
2368    ("skill_b", "sb"),
2369    ("desc_a", "da"),
2370    ("desc_b", "db"),
2371    ("annotations", "ann"),
2372    ("entity", "ety"),
2373    ("suggestion", "sug"),
2374    ("columns", "cols"),
2375    ("row_count", "rc"),
2376    ("notnull", "nn"),
2377    ("default_value", "dv"),
2378    ("replace_all", "ra"),
2379];
2380
2381pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
2382    let root_str = root.to_string_lossy();
2383    let prefix = format!("{}/", root_str.trim_end_matches('/'));
2384    path.strip_prefix(&prefix).unwrap_or(path).to_string()
2385}
2386
2387fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
2388    let canonical = path
2389        .canonicalize()
2390        .with_context(|| format!("canonicalizing {}", path.display()))?;
2391    let start = if canonical.is_dir() {
2392        canonical.clone()
2393    } else {
2394        canonical
2395            .parent()
2396            .map(Path::to_path_buf)
2397            .unwrap_or_else(|| canonical.clone())
2398    };
2399
2400    for ancestor in start.ancestors() {
2401        if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
2402            return Ok(ancestor.to_path_buf());
2403        }
2404    }
2405
2406    Ok(start)
2407}
2408
2409pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
2410    path.strip_prefix(root)
2411        .map(|p| p.to_path_buf())
2412        .unwrap_or_else(|_| path.to_path_buf())
2413}
2414
2415pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
2416    for edge in edges {
2417        edge.caller_file = relativize(&edge.caller_file, root);
2418    }
2419}
2420
2421pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
2422    for sym in symbols {
2423        sym.file = relativize(&sym.file, root);
2424    }
2425}
2426
2427pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
2428    for hit in hits {
2429        hit.file = relativize(&hit.file, root);
2430    }
2431}
2432
2433/// Which endpoint of a `StoredEdge` is the row's primary symbol — caller
2434/// (caller list) or callee (callee list).
2435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2436pub enum EdgeSide {
2437    Caller,
2438    Callee,
2439}
2440
2441const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
2442
2443pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
2444    let root_str = root.to_string_lossy();
2445    let prefix = format!("{}/", root_str.trim_end_matches('/'));
2446    relativize_json_inner(val, &prefix);
2447}
2448
2449fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
2450    match val {
2451        serde_json::Value::Array(arr) => {
2452            for v in arr {
2453                relativize_json_inner(v, prefix);
2454            }
2455        }
2456        serde_json::Value::Object(map) => {
2457            for (k, v) in map.iter_mut() {
2458                if JSON_PATH_KEYS.contains(&k.as_str())
2459                    && let serde_json::Value::String(s) = v
2460                    && let Some(rest) = s.strip_prefix(prefix)
2461                {
2462                    *s = rest.to_string();
2463                }
2464                relativize_json_inner(v, prefix);
2465            }
2466        }
2467        _ => {}
2468    }
2469}
2470
2471pub(crate) fn format_score(score: f64, compact: bool) -> String {
2472    if compact {
2473        format!("{score:.2}")
2474    } else {
2475        format!("{score:.4}")
2476    }
2477}
2478
2479pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
2480    let trimmed = input.trim();
2481    let count = trimmed.chars().count();
2482    if count <= max_chars {
2483        return trimmed.to_string();
2484    }
2485    let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
2486    format!("{prefix}...")
2487}
2488
2489pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
2490    snippet
2491        .lines()
2492        .find(|line| !line.trim().is_empty())
2493        .map(|line| truncate_for_compact(line, 100))
2494}
2495
2496pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
2497    let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
2498    if names.len() <= limit {
2499        return names.join(", ");
2500    }
2501    format!(
2502        "{} (+{} more)",
2503        names[..limit].join(", "),
2504        names.len() - limit
2505    )
2506}
2507
2508pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
2509    let mut hasher = blake3::Hasher::new();
2510    hasher.update(prefix.as_bytes());
2511    hasher.update(&[0]);
2512    hasher.update(key.as_bytes());
2513    let hex = hasher.finalize().to_hex();
2514    format!("{prefix}-{}", &hex[..10])
2515}
2516
2517#[derive(Clone, Debug, PartialEq, Eq)]
2518struct CanonicalTagFamily {
2519    canonical: String,
2520    tag_alias: String,
2521}
2522
2523fn canonical_family_from_tagpath_family(
2524    family: tagpath_family::TagFamily,
2525) -> Option<CanonicalTagFamily> {
2526    let tag_alias = if family.dimensions.is_empty() {
2527        family.tags.join("/")
2528    } else {
2529        family
2530            .dimensions
2531            .iter()
2532            .filter(|dimension| !dimension.tags.is_empty())
2533            .map(|dimension| dimension.tags.join("."))
2534            .collect::<Vec<_>>()
2535            .join("/")
2536    };
2537
2538    if tag_alias.is_empty() {
2539        None
2540    } else {
2541        Some(CanonicalTagFamily {
2542            canonical: family.canonical,
2543            tag_alias,
2544        })
2545    }
2546}
2547
2548fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
2549    let trimmed = name.trim();
2550    if trimmed.is_empty() {
2551        return None;
2552    }
2553
2554    canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
2555}
2556
2557fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
2558    let canonical = tags
2559        .split(',')
2560        .map(str::trim)
2561        .filter(|tag| !tag.is_empty())
2562        .collect::<Vec<_>>()
2563        .join("_");
2564    if canonical.is_empty() {
2565        None
2566    } else {
2567        canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
2568    }
2569}
2570
2571pub(crate) fn canonical_tag_family_from_symbol(
2572    name: &str,
2573    tags: Option<&str>,
2574) -> Option<CanonicalTagFamily> {
2575    tags.and_then(canonical_tag_family_from_tags)
2576        .or_else(|| canonical_tag_family_from_name(name))
2577}
2578
2579fn tag_alias_from_name(name: &str) -> Option<String> {
2580    canonical_tag_family_from_name(name).map(|family| family.tag_alias)
2581}
2582
2583fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
2584    canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
2585}
2586
2587pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
2588    let query = tag_alias
2589        .split(['/', '.'])
2590        .map(str::trim)
2591        .filter(|part| !part.is_empty())
2592        .collect::<Vec<_>>()
2593        .join(" ");
2594    if query.is_empty() { None } else { Some(query) }
2595}
2596
2597#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2598struct CompactOntologyRefPreview {
2599    handle: String,
2600    tag: String,
2601    path: String,
2602    #[serde(skip_serializing_if = "Option::is_none")]
2603    title: Option<String>,
2604    #[serde(skip_serializing_if = "Option::is_none")]
2605    domain: Option<String>,
2606}
2607
2608#[derive(Clone, Debug)]
2609struct TagOntologyPreviewContext {
2610    project_root: PathBuf,
2611    tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
2612}
2613
2614#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2615struct CompactSymbolRefPreview {
2616    handle: String,
2617    name: String,
2618    #[serde(skip_serializing_if = "Option::is_none")]
2619    tag_alias: Option<String>,
2620    #[serde(skip_serializing_if = "Vec::is_empty", default)]
2621    ontology_refs: Vec<CompactOntologyRefPreview>,
2622}
2623
2624fn build_compact_symbol_ref(
2625    prefix: &str,
2626    key: &str,
2627    name: &str,
2628    tags: Option<&str>,
2629    max_bytes: usize,
2630) -> CompactSymbolRefPreview {
2631    build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
2632}
2633
2634fn build_compact_symbol_ref_with_ontology(
2635    prefix: &str,
2636    key: &str,
2637    name: &str,
2638    tags: Option<&str>,
2639    max_bytes: usize,
2640    ontology: Option<&TagOntologyPreviewContext>,
2641) -> CompactSymbolRefPreview {
2642    let tag_alias = tag_alias_from_tags(name, tags);
2643    let ontology_refs = tag_alias
2644        .as_deref()
2645        .map(|alias| ontology_refs_for_alias(ontology, alias))
2646        .unwrap_or_default();
2647    CompactSymbolRefPreview {
2648        handle: stable_handle(prefix, key),
2649        name: truncate_for_budget(name, max_bytes),
2650        tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
2651        ontology_refs,
2652    }
2653}
2654
2655fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
2656    let report = tagpath_ontology::load_project(root).ok()?;
2657    if report.tags.is_empty() {
2658        return None;
2659    }
2660    Some(TagOntologyPreviewContext {
2661        project_root: report.project_path,
2662        tags: report
2663            .tags
2664            .into_iter()
2665            .map(|tag| (tag.tag.clone(), tag))
2666            .collect(),
2667    })
2668}
2669
2670fn ontology_refs_for_alias(
2671    ontology: Option<&TagOntologyPreviewContext>,
2672    alias: &str,
2673) -> Vec<CompactOntologyRefPreview> {
2674    let Some(ontology) = ontology else {
2675        return Vec::new();
2676    };
2677    let mut seen = BTreeSet::new();
2678    alias
2679        .split('/')
2680        .flat_map(|part| part.split('.'))
2681        .map(str::trim)
2682        .filter(|tag| !tag.is_empty())
2683        .filter_map(|tag| {
2684            let key = tag.to_ascii_lowercase();
2685            if !seen.insert(key.clone()) {
2686                return None;
2687            }
2688            let ontology_tag = ontology.tags.get(&key)?;
2689            let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
2690            Some(CompactOntologyRefPreview {
2691                handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
2692                tag: ontology_tag.tag.clone(),
2693                path,
2694                title: ontology_tag.title.clone(),
2695                domain: ontology_tag.domain.clone(),
2696            })
2697        })
2698        .collect()
2699}
2700
2701fn relativize_ontology_path(path: &Path, root: &Path) -> String {
2702    path.strip_prefix(root)
2703        .unwrap_or(path)
2704        .to_string_lossy()
2705        .replace('\\', "/")
2706}
2707
2708fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
2709    match tag_alias {
2710        Some(alias) => format!("{handle} {name} tag:{alias}"),
2711        None => format!("{handle} {name}"),
2712    }
2713}
2714
2715fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
2716    match summary.tag_alias.as_deref() {
2717        Some(alias) => format!(
2718            "{} {} tag:{} expand:{}",
2719            summary.handle, summary.symbol, alias, summary.expand
2720        ),
2721        None => format!(
2722            "{} {} expand:{}",
2723            summary.handle, summary.symbol, summary.expand
2724        ),
2725    }
2726}
2727
2728fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
2729    match symbol.tag_alias.as_deref() {
2730        Some(alias) => format!("{}@{}", symbol.handle, alias),
2731        None => format!("{}@{}", symbol.handle, symbol.name),
2732    }
2733}
2734
2735pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
2736    let trimmed = input.trim();
2737    if trimmed.len() <= max_bytes {
2738        return trimmed.to_string();
2739    }
2740    if max_bytes <= 3 {
2741        return ".".repeat(max_bytes);
2742    }
2743
2744    let mut end = 0usize;
2745    for (idx, ch) in trimmed.char_indices() {
2746        let next = idx + ch.len_utf8();
2747        if next > max_bytes.saturating_sub(3) {
2748            break;
2749        }
2750        end = next;
2751    }
2752
2753    if end == 0 {
2754        "...".to_string()
2755    } else {
2756        format!("{}...", &trimmed[..end])
2757    }
2758}
2759
2760struct TokenCappedPreview {
2761    preview: Vec<SourceLinePreview>,
2762    capped_end: usize,
2763    was_capped: bool,
2764}
2765
2766fn build_token_capped_preview(
2767    all_lines: &[&str],
2768    start: usize,
2769    end: usize,
2770    max_bytes: usize,
2771    token_cap: usize,
2772) -> TokenCappedPreview {
2773    let mut preview = Vec::new();
2774    let mut accumulated_tokens = 0usize;
2775    let mut capped_end = end;
2776    let mut was_capped = false;
2777
2778    for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
2779        let truncated = truncate_for_budget(line, max_bytes);
2780        let line_tokens = estimated_tokens_from_bytes(truncated.len());
2781        if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
2782            capped_end = start + idx - 1;
2783            was_capped = true;
2784            break;
2785        }
2786        accumulated_tokens += line_tokens;
2787        preview.push(SourceLinePreview {
2788            line: start + idx,
2789            text: truncated,
2790        });
2791    }
2792
2793    TokenCappedPreview {
2794        preview,
2795        capped_end,
2796        was_capped,
2797    }
2798}
2799
2800pub(crate) fn abbreviate_kind(kind: &str) -> &str {
2801    match kind {
2802        "function" => "fn",
2803        "method" => "meth",
2804        "module" | "mod" => "mod",
2805        "struct" => "struct",
2806        "trait" => "trait",
2807        "impl" => "impl",
2808        "class" => "cls",
2809        "interface" => "iface",
2810        "type_alias" => "type",
2811        "data_class" => "data_cls",
2812        "sealed_class" => "sealed_cls",
2813        "enum_class" => "enum_cls",
2814        "companion_object" => "comp_obj",
2815        "object" => "obj",
2816        "heading" => "h",
2817        "code_block" => "code",
2818        "alias" => "alias",
2819        other => other,
2820    }
2821}
2822
2823pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
2824    match kind {
2825        "calls" => "c",
2826        "defines" => "d",
2827        "contains" => "ct",
2828        "imports" => "i",
2829        "mentions" => "m",
2830        "mentions_concept" => "mc",
2831        "mentions_entity" => "me",
2832        "semantic_relation" => "sr",
2833        "belongs_to" => "bt",
2834        "scopes_context" => "sctx",
2835        "scopes_source" => "ssrc",
2836        "requests_context" => "rctx",
2837        "explains_result" => "er",
2838        "tagged_concept" => "tc",
2839        "tagged_entity" => "te",
2840        "related_concept" => "relc",
2841        "handled_by" => "hb",
2842        "defines_route" => "dr",
2843        "handles_route" => "hr",
2844        "targets" => "tgt",
2845        "has_vector_handle" => "hv",
2846        "parent" => "p",
2847        "child" => "ch",
2848        "uses" => "u",
2849        "projects_source" => "psrc",
2850        "records_memory_source" => "rms",
2851        "records_memory_event" => "rme",
2852        "has_ast_span" => "ha",
2853        "represents_symbol" => "rs",
2854        "contains_embedded_symbol" => "ces",
2855        "embedded_in_fence" => "ef",
2856        "contains_markdown_block" => "cmb",
2857        "contains_embedded_code" => "cec",
2858        "enclosing_module" => "em",
2859        "enclosing_section" => "es",
2860        "previous_sibling" => "psib",
2861        "next_sibling" => "nsib",
2862        "explicit_depends_on" => "edo",
2863        "worker_result_follow_up" => "wrf",
2864        "shared_resource" => "shr",
2865        "community_member" => "cm",
2866        other => other,
2867    }
2868}
2869
2870pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
2871    match mt {
2872        "exact_name" => "exact",
2873        "all_tags" => "all_tags",
2874        "partial_tags" => "partial",
2875        other => other,
2876    }
2877}
2878
2879pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
2880    path.iter()
2881        .map(|n| n.name.as_str())
2882        .collect::<Vec<_>>()
2883        .join(" -> ")
2884}
2885
2886const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
2887
2888struct SearchHitGroup {
2889    path: String,
2890    first_rank: usize,
2891    top_score: f64,
2892    confidence: String,
2893    hits: usize,
2894    samples: Vec<String>,
2895}
2896
2897fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
2898    let snippet = compact_snippet(&hit.snippet)?;
2899    Some(match hit.location.as_deref() {
2900        Some(location) => format!("{location}: {snippet}"),
2901        None => snippet,
2902    })
2903}
2904
2905pub(crate) fn group_search_hits(
2906    hits: &[sift::SearchHit],
2907    root: &Path,
2908    absolute: bool,
2909) -> Vec<SearchHitGroup> {
2910    let mut positions = BTreeMap::new();
2911    let mut groups = Vec::new();
2912    for hit in hits {
2913        let path = if absolute {
2914            hit.path.clone()
2915        } else {
2916            relativize(&hit.path, root)
2917        };
2918        let entry = positions.entry(path.clone()).or_insert_with(|| {
2919            groups.push(SearchHitGroup {
2920                path: path.clone(),
2921                first_rank: hit.rank,
2922                top_score: hit.score,
2923                confidence: format!("{:?}", hit.confidence),
2924                hits: 0,
2925                samples: Vec::new(),
2926            });
2927            groups.len() - 1
2928        });
2929        let group = &mut groups[*entry];
2930        group.hits += 1;
2931        if hit.rank < group.first_rank {
2932            group.first_rank = hit.rank;
2933        }
2934        if hit.score > group.top_score {
2935            group.top_score = hit.score;
2936        }
2937        if let Some(sample) = format_search_sample(hit)
2938            && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
2939            && !group.samples.contains(&sample)
2940        {
2941            group.samples.push(sample);
2942        }
2943    }
2944    groups.sort_by_key(|group| group.first_rank);
2945    groups
2946}
2947
2948pub(crate) fn should_collapse_search_hits(
2949    hits: &[sift::SearchHit],
2950    root: &Path,
2951    absolute: bool,
2952) -> bool {
2953    let groups = group_search_hits(hits, root, absolute);
2954    let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
2955    max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
2956}
2957
2958pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
2959    let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
2960    for edge in edges {
2961        let key = edge.caller_file.as_str();
2962        let name = if use_callers {
2963            edge.caller_name.as_str()
2964        } else {
2965            edge.callee_name.as_str()
2966        };
2967        let names = grouped.entry(key).or_default();
2968        if !names.contains(&name) {
2969            names.push(name);
2970        }
2971    }
2972
2973    grouped
2974        .into_iter()
2975        .map(|(file, names)| format!("  {} ({}): {}", file, names.len(), names.join(", ")))
2976        .collect()
2977}
2978
2979pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
2980    let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
2981    for edge in edges {
2982        *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
2983    }
2984    let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
2985    max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
2986}
2987
2988fn resolve_query_index_target(
2989    root: &Path,
2990    path_hint: &Path,
2991    scope: Option<&str>,
2992) -> Result<SearchIndexTarget> {
2993    let cfg = config::Config::load(root)?;
2994    if let Some(scope_name) = scope {
2995        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
2996            return Ok(SearchIndexTarget {
2997                label: format!("submodule `{}` index", scope.id),
2998                db_path: cfg.db_path_for(root, &scope.id),
2999                source_root: scope.source_root.clone(),
3000                scope_name: Some(scope.id.clone()),
3001                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3002            });
3003        }
3004        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3005            return Ok(cargo_package_index_target(root, package));
3006        }
3007        config::Config::resolve_submodule(root, scope_name)?;
3008    }
3009
3010    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3011        return Ok(SearchIndexTarget {
3012            label: format!("submodule `{}` index", scope.id),
3013            db_path: cfg.db_path_for(root, &scope.id),
3014            source_root: scope.source_root.clone(),
3015            scope_name: Some(scope.id.clone()),
3016            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3017        });
3018    }
3019
3020    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3021        return Ok(cargo_package_index_target(root, package));
3022    }
3023
3024    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3025        return Ok(SearchIndexTarget {
3026            label: format!("submodule `{}` index", scope.id),
3027            db_path: cfg.db_path_for(root, &scope.id),
3028            source_root: scope.source_root.clone(),
3029            scope_name: Some(scope.id.clone()),
3030            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3031        });
3032    }
3033
3034    let db_path = root.join(".tsift/index.db");
3035    if db_path.exists() {
3036        return Ok(SearchIndexTarget {
3037            label: "index".to_string(),
3038            db_path,
3039            source_root: root.to_path_buf(),
3040            scope_name: None,
3041            reindex_cmd: format!("tsift index {}", root.display()),
3042        });
3043    }
3044
3045    let scopes = config::Config::submodule_dirs(root)?;
3046    if scopes.is_empty() {
3047        return Ok(SearchIndexTarget {
3048            label: "index".to_string(),
3049            db_path,
3050            source_root: root.to_path_buf(),
3051            scope_name: None,
3052            reindex_cmd: format!("tsift index {}", root.display()),
3053        });
3054    }
3055
3056    let available_scopes = scopes
3057        .iter()
3058        .map(|scope| scope.id.as_str())
3059        .collect::<Vec<_>>()
3060        .join(", ");
3061    let indexed_scopes = scopes
3062        .iter()
3063        .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3064        .map(|scope| scope.id.as_str())
3065        .collect::<Vec<_>>();
3066    let indexed_label = if indexed_scopes.is_empty() {
3067        "none".to_string()
3068    } else {
3069        indexed_scopes.join(", ")
3070    };
3071
3072    bail!(
3073        "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: {}.",
3074        root.display(),
3075        db_path.display(),
3076        available_scopes,
3077        indexed_label
3078    );
3079}
3080
3081pub(crate) fn resolve_query_db_path(
3082    root: &Path,
3083    path_hint: &Path,
3084    scope: Option<&str>,
3085) -> Result<PathBuf> {
3086    Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3087}
3088
3089fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3090    let state = inspect_search_index(target)?;
3091    let Some(reason) = index_reason_for_state(state) else {
3092        return Ok(());
3093    };
3094
3095    match apply_search_index_update(root, target) {
3096        Ok(_) => {
3097            index::inspect_scope_invalidate_all();
3098            Ok(())
3099        }
3100        Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3101            eprintln!(
3102                "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3103                 Continuing with the current read-only index snapshot; graph results may lag. \
3104                 Retry `{}` after the active writer finishes for fresh graph results.",
3105                index_reason_detail(target, reason),
3106                target.reindex_cmd
3107            );
3108            Ok(())
3109        }
3110        Err(err) => Err(err),
3111    }
3112}
3113
3114pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3115    let root = lint::resolve_project_root_or_canonical_path(path)?;
3116    let target = resolve_query_index_target(&root, path, scope)?;
3117    ensure_query_index_current(&root, &target)?;
3118    let db_path = target.db_path;
3119    if !db_path.exists() {
3120        bail!(
3121            "no index found at {}. Run `tsift index` first.",
3122            db_path.display()
3123        );
3124    }
3125    index::IndexDb::open_read_only_resilient(&db_path)
3126}
3127
3128pub(crate) fn query_tagpath_root(
3129    root: &std::path::Path,
3130    path_hint: &std::path::Path,
3131    scope: Option<&str>,
3132) -> Result<PathBuf> {
3133    if let Some(scope_name) = scope {
3134        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3135            return Ok(scope.source_root);
3136        }
3137        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3138            return Ok(package.package_root);
3139        }
3140        config::Config::resolve_submodule(root, scope_name)?;
3141    }
3142    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3143        return Ok(scope.source_root);
3144    }
3145    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3146        return Ok(package.package_root);
3147    }
3148    Ok(root.to_path_buf())
3149}
3150
3151#[derive(Clone, Debug, Serialize, PartialEq)]
3152struct TraversalNode {
3153    handle: String,
3154    kind: String,
3155    label: String,
3156    #[serde(skip_serializing_if = "Option::is_none")]
3157    ref_id: Option<String>,
3158    #[serde(skip_serializing_if = "Option::is_none")]
3159    path: Option<String>,
3160    #[serde(skip_serializing_if = "Option::is_none")]
3161    line: Option<i64>,
3162    #[serde(skip_serializing_if = "Option::is_none")]
3163    detail: Option<String>,
3164    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3165    properties: BTreeMap<String, String>,
3166    expand: String,
3167}
3168
3169#[derive(Clone, Debug, Serialize, PartialEq)]
3170struct TraversalEdge {
3171    from: String,
3172    to: String,
3173    relation: String,
3174    #[serde(skip_serializing_if = "Option::is_none")]
3175    label: Option<String>,
3176    weight: usize,
3177}
3178
3179#[derive(Clone, Debug, Default)]
3180struct TraversalGraphBuild {
3181    nodes: BTreeMap<String, TraversalNode>,
3182    edges: Vec<TraversalEdge>,
3183    edge_keys: BTreeSet<(String, String, String)>,
3184    warnings: Vec<String>,
3185}
3186
3187pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3188const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3189const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3190const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3191const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3192    "context-pack-graph-orchestration-v1";
3193const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3194const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3195const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3196const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3197const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3198const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3199const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3200
3201#[derive(Debug, Serialize, PartialEq)]
3202struct TraversalTotals {
3203    nodes: usize,
3204    edges: usize,
3205}
3206
3207#[derive(Debug, Serialize, PartialEq)]
3208struct TraversalPathReport {
3209    from: TraversalNode,
3210    to: TraversalNode,
3211    hops: usize,
3212    nodes: Vec<TraversalNode>,
3213    edges: Vec<TraversalEdge>,
3214}
3215
3216#[derive(Debug, Serialize, PartialEq)]
3217struct TraversalRecommendation {
3218    handle: String,
3219    kind: String,
3220    label: String,
3221    reason: String,
3222    score: usize,
3223    expand: String,
3224}
3225
3226#[derive(Debug, Serialize, PartialEq)]
3227struct TraversalReport {
3228    root: String,
3229    #[serde(skip_serializing_if = "Option::is_none")]
3230    scope: Option<String>,
3231    mode: String,
3232    totals: TraversalTotals,
3233    #[serde(skip_serializing_if = "Option::is_none")]
3234    query: Option<String>,
3235    #[serde(skip_serializing_if = "Option::is_none")]
3236    target: Option<String>,
3237    nodes: Vec<TraversalNode>,
3238    edges: Vec<TraversalEdge>,
3239    #[serde(skip_serializing_if = "Option::is_none")]
3240    shortest_path: Option<TraversalPathReport>,
3241    recommendations: Vec<TraversalRecommendation>,
3242    exploration: ExplorationPacket,
3243    truncated: bool,
3244    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3245    warnings: Vec<String>,
3246}
3247
3248#[derive(Debug, Serialize, PartialEq)]
3249struct SemanticRelatedReport {
3250    root: String,
3251    #[serde(skip_serializing_if = "Option::is_none")]
3252    scope: Option<String>,
3253    query: String,
3254    embedding_model: String,
3255    count: usize,
3256    items: Vec<SemanticRelatedItem>,
3257    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3258    warnings: Vec<String>,
3259}
3260
3261#[derive(Clone, Debug, Serialize, PartialEq)]
3262struct SemanticRelatedItem {
3263    handle: String,
3264    kind: String,
3265    label: String,
3266    score: f64,
3267    #[serde(skip_serializing_if = "Option::is_none")]
3268    file_path: Option<String>,
3269    #[serde(skip_serializing_if = "Option::is_none")]
3270    source_symbol: Option<String>,
3271    #[serde(skip_serializing_if = "Option::is_none")]
3272    detail: Option<String>,
3273    expand: String,
3274}
3275
3276#[derive(Clone)]
3277struct TraversalSymbolIndexEntry {
3278    handle: String,
3279    node: TraversalNode,
3280    tokens: BTreeSet<String>,
3281}
3282
3283#[derive(Clone)]
3284struct TraversalFileIndexEntry {
3285    handle: String,
3286    node: TraversalNode,
3287    tokens: BTreeSet<String>,
3288}
3289
3290#[derive(Clone)]
3291struct TraversalRouteIndexEntry {
3292    handle: String,
3293    node: TraversalNode,
3294    tokens: BTreeSet<String>,
3295}
3296
3297#[derive(Clone)]
3298struct TraversalAstSpanIndexEntry {
3299    handle: String,
3300    symbol_handle: String,
3301    file_handle: Option<String>,
3302    file: String,
3303    name: String,
3304    kind: String,
3305    language: String,
3306    node_kind: String,
3307    start_byte: usize,
3308    end_byte: usize,
3309    parent_module: Option<String>,
3310    markdown: Option<MarkdownSpanMetadata>,
3311}
3312
3313#[derive(Clone)]
3314struct TraversalMultiplicityIndexEntry {
3315    handle: String,
3316    node: TraversalNode,
3317    tokens: BTreeSet<String>,
3318}
3319
3320struct TraversalCodeLookup<'a> {
3321    symbols: &'a [TraversalSymbolIndexEntry],
3322    files: &'a [TraversalFileIndexEntry],
3323    routes: &'a [TraversalRouteIndexEntry],
3324    multiplicities: &'a [TraversalMultiplicityIndexEntry],
3325    symbol_index: HashMap<String, Vec<usize>>,
3326    file_index: HashMap<String, Vec<usize>>,
3327    route_index: HashMap<String, Vec<usize>>,
3328    multiplicity_index: HashMap<String, Vec<usize>>,
3329    file_path_index: HashMap<String, String>,
3330}
3331
3332#[derive(Clone, Debug, Serialize, PartialEq)]
3333struct ExplorationBudget {
3334    project_size: String,
3335    max_source_windows: usize,
3336    lines_per_window: usize,
3337    relationship_limit: usize,
3338}
3339
3340#[derive(Clone, Debug, Serialize, PartialEq)]
3341struct ExplorationRelation {
3342    from: String,
3343    relation: String,
3344    to: String,
3345    #[serde(skip_serializing_if = "Option::is_none")]
3346    label: Option<String>,
3347}
3348
3349#[derive(Clone, Debug, Serialize, PartialEq)]
3350struct ExplorationSourceWindow {
3351    handle: String,
3352    file: String,
3353    start: usize,
3354    end: usize,
3355    reason: String,
3356    expand: String,
3357}
3358
3359#[derive(Clone, Debug, Serialize, PartialEq)]
3360struct ExplorationWorkerContext {
3361    handle: String,
3362    target: String,
3363    summary: String,
3364    expand: String,
3365}
3366
3367#[derive(Clone, Debug, Serialize, PartialEq)]
3368struct ExplorationPacket {
3369    budget: ExplorationBudget,
3370    relationship_map: Vec<ExplorationRelation>,
3371    source_windows: Vec<ExplorationSourceWindow>,
3372    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3373    worker_context: Vec<ExplorationWorkerContext>,
3374    no_reread_guidance: String,
3375}
3376
3377impl TraversalGraphBuild {
3378    fn add_node(&mut self, node: TraversalNode) {
3379        self.nodes.entry(node.handle.clone()).or_insert(node);
3380    }
3381
3382    fn add_edge(
3383        &mut self,
3384        from: &str,
3385        to: &str,
3386        relation: &str,
3387        label: Option<String>,
3388        weight: usize,
3389    ) {
3390        if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
3391            return;
3392        }
3393        let key = (from.to_string(), to.to_string(), relation.to_string());
3394        if self.edge_keys.insert(key) {
3395            self.edges.push(TraversalEdge {
3396                from: from.to_string(),
3397                to: to.to_string(),
3398                relation: relation.to_string(),
3399                label,
3400                weight,
3401            });
3402        }
3403    }
3404}
3405
3406pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
3407    match scope {
3408        Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
3409        None => root.join(".tsift/graph.db"),
3410    }
3411}
3412
3413fn graph_projection_meta_id(scope: Option<&str>) -> String {
3414    format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
3415}
3416
3417pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
3418    let bytes = serde_json::to_vec(value)?;
3419    Ok(blake3::hash(&bytes).to_hex().to_string())
3420}
3421
3422fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
3423    let mut hashable = node.clone();
3424    hashable.freshness = None;
3425    node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3426    Ok(node)
3427}
3428
3429fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
3430    let mut hashable = edge.clone();
3431    hashable.freshness = None;
3432    edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3433    Ok(edge)
3434}
3435
3436const SEMANTIC_EMBEDDING_DIM: usize = 32;
3437const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
3438
3439fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
3440    match kind {
3441        SemanticRelatedKind::Concept => "concept",
3442        SemanticRelatedKind::Entity => "entity",
3443        SemanticRelatedKind::All => "all",
3444    }
3445}
3446
3447fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
3448    format!(
3449        "tsift semantic {} --path {} --kind {} --limit 10",
3450        shell_quote(query),
3451        shell_quote(root.to_string_lossy().as_ref()),
3452        semantic_related_kind_name(kind)
3453    )
3454}
3455
3456fn semantic_embedding(input: &str) -> Vec<f64> {
3457    let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
3458    let mut tokens = traversal_tokens(input);
3459    if tokens.is_empty() {
3460        let trimmed = input.trim().to_ascii_lowercase();
3461        if !trimmed.is_empty() {
3462            tokens.insert(trimmed);
3463        }
3464    }
3465
3466    for token in tokens {
3467        let hash = blake3::hash(token.as_bytes());
3468        let bytes = hash.as_bytes();
3469        let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
3470        let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
3471        vector[idx] += sign;
3472    }
3473
3474    let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
3475    if norm > 0.0 {
3476        for value in &mut vector {
3477            *value /= norm;
3478        }
3479    }
3480    vector
3481}
3482
3483fn semantic_embedding_property(input: &str) -> String {
3484    semantic_embedding(input)
3485        .iter()
3486        .map(|value| format!("{value:.6}"))
3487        .collect::<Vec<_>>()
3488        .join(",")
3489}
3490
3491fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
3492    let parsed = value
3493        .split(',')
3494        .map(str::trim)
3495        .map(str::parse::<f64>)
3496        .collect::<std::result::Result<Vec<_>, _>>()
3497        .ok()?;
3498    (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
3499}
3500
3501fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
3502    if left.len() != right.len() {
3503        return 0.0;
3504    }
3505    left.iter()
3506        .zip(right.iter())
3507        .map(|(left, right)| left * right)
3508        .sum::<f64>()
3509}
3510
3511fn semantic_entity_handle(name: &str, kind: &str) -> String {
3512    stable_handle(
3513        "gent",
3514        &format!(
3515            "entity:{}:{}",
3516            kind.trim().to_ascii_lowercase(),
3517            name.trim().to_ascii_lowercase()
3518        ),
3519    )
3520}
3521
3522fn semantic_concept_handle(label: &str) -> String {
3523    stable_handle(
3524        "gcon",
3525        &format!("concept:{}", label.trim().to_ascii_lowercase()),
3526    )
3527}
3528
3529fn summary_source_handles(
3530    summary: &summarize::Summary,
3531    file_node_by_path: &BTreeMap<String, String>,
3532    symbol_node_by_file_label: &BTreeMap<(String, String), String>,
3533) -> Vec<String> {
3534    let mut handles = Vec::new();
3535    if let Some(handle) = file_node_by_path.get(&summary.file_path) {
3536        handles.push(handle.clone());
3537    }
3538    if let Some(handle) =
3539        symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
3540        && !handles.iter().any(|existing| existing == handle)
3541    {
3542        handles.push(handle.clone());
3543    }
3544    handles
3545}
3546
3547fn semantic_entity_node(
3548    root: &Path,
3549    summary: &summarize::Summary,
3550    name: &str,
3551    kind: &str,
3552    description: &str,
3553    provenance: &GraphProvenance,
3554) -> SubstrateGraphNode {
3555    let handle = semantic_entity_handle(name, kind);
3556    let detail = if description.trim().is_empty() {
3557        format!("{kind} entity from cached summaries")
3558    } else {
3559        format!("{kind}: {description}")
3560    };
3561    SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
3562        .with_property("handle", handle)
3563        .with_property("ref_id", name.to_string())
3564        .with_property("detail", detail)
3565        .with_property("entity_kind", kind.to_string())
3566        .with_property("description", description.to_string())
3567        .with_property("source_file", summary.file_path.clone())
3568        .with_property("source_symbol", summary.symbol_name.clone())
3569        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3570        .with_property(
3571            "embedding",
3572            semantic_embedding_property(&format!("{name} {kind} {description}")),
3573        )
3574        .with_property(
3575            "expand",
3576            semantic_related_command(root, name, SemanticRelatedKind::Entity),
3577        )
3578        .with_provenance(provenance.clone())
3579}
3580
3581fn semantic_concept_node(
3582    root: &Path,
3583    summary: &summarize::Summary,
3584    label: &str,
3585    provenance: &GraphProvenance,
3586) -> SubstrateGraphNode {
3587    let handle = semantic_concept_handle(label);
3588    SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
3589        .with_property("handle", handle)
3590        .with_property("ref_id", label.to_string())
3591        .with_property("detail", "concept label from cached summaries".to_string())
3592        .with_property("source_file", summary.file_path.clone())
3593        .with_property("source_symbol", summary.symbol_name.clone())
3594        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3595        .with_property("embedding", semantic_embedding_property(label))
3596        .with_property(
3597            "expand",
3598            semantic_related_command(root, label, SemanticRelatedKind::Concept),
3599        )
3600        .with_provenance(provenance.clone())
3601}
3602
3603fn insert_semantic_edge(
3604    edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
3605    edge: SubstrateGraphEdge,
3606) {
3607    edge_map
3608        .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
3609        .or_insert(edge);
3610}
3611
3612fn append_summary_semantic_projection_rows(
3613    root: &Path,
3614    graph: &TraversalGraphBuild,
3615    provenance: &GraphProvenance,
3616    nodes: &mut Vec<SubstrateGraphNode>,
3617    edges: &mut Vec<SubstrateGraphEdge>,
3618) -> Result<()> {
3619    let summaries_db = root.join(".tsift/summaries.db");
3620    if !summaries_db.exists() {
3621        return Ok(());
3622    }
3623
3624    let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
3625    let summaries = summary_db.all()?;
3626    if summaries.is_empty() {
3627        return Ok(());
3628    }
3629
3630    let file_node_by_path = graph
3631        .nodes
3632        .values()
3633        .filter(|node| node.kind == "file")
3634        .filter_map(|node| {
3635            node.path
3636                .as_ref()
3637                .map(|path| (path.clone(), node.handle.clone()))
3638        })
3639        .collect::<BTreeMap<_, _>>();
3640    let symbol_node_by_file_label = graph
3641        .nodes
3642        .values()
3643        .filter(|node| node.kind == "symbol")
3644        .filter_map(|node| {
3645            Some((
3646                (node.path.clone()?, node.label.clone()),
3647                node.handle.clone(),
3648            ))
3649        })
3650        .collect::<BTreeMap<_, _>>();
3651
3652    let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
3653    let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
3654
3655    for summary in &summaries {
3656        let source_handles =
3657            summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
3658        let mut entity_ids_by_name = BTreeMap::<String, String>::new();
3659
3660        if let Some(entities) = &summary.entities {
3661            for entity in entities {
3662                let node = semantic_entity_node(
3663                    root,
3664                    summary,
3665                    &entity.name,
3666                    &entity.kind,
3667                    &entity.description,
3668                    provenance,
3669                );
3670                let entity_id = node.id.clone();
3671                entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
3672                semantic_nodes.entry(entity_id.clone()).or_insert(node);
3673
3674                for source_handle in &source_handles {
3675                    insert_semantic_edge(
3676                        &mut semantic_edges,
3677                        SubstrateGraphEdge::new(
3678                            source_handle.clone(),
3679                            entity_id.clone(),
3680                            "mentions_entity",
3681                        )
3682                        .with_property("label", format!("summary entity: {}", entity.name))
3683                        .with_property("source_file", summary.file_path.clone())
3684                        .with_provenance(provenance.clone()),
3685                    );
3686                }
3687            }
3688        }
3689
3690        let mut concept_ids = Vec::new();
3691        if let Some(labels) = &summary.concept_labels {
3692            for label in labels
3693                .iter()
3694                .map(|label| label.trim())
3695                .filter(|label| !label.is_empty())
3696            {
3697                let node = semantic_concept_node(root, summary, label, provenance);
3698                let concept_id = node.id.clone();
3699                semantic_nodes.entry(concept_id.clone()).or_insert(node);
3700                concept_ids.push(concept_id.clone());
3701
3702                for source_handle in &source_handles {
3703                    insert_semantic_edge(
3704                        &mut semantic_edges,
3705                        SubstrateGraphEdge::new(
3706                            source_handle.clone(),
3707                            concept_id.clone(),
3708                            "mentions_concept",
3709                        )
3710                        .with_property("label", format!("summary concept: {label}"))
3711                        .with_property("source_file", summary.file_path.clone())
3712                        .with_provenance(provenance.clone()),
3713                    );
3714                }
3715            }
3716        }
3717
3718        for entity_id in entity_ids_by_name.values() {
3719            for concept_id in &concept_ids {
3720                insert_semantic_edge(
3721                    &mut semantic_edges,
3722                    SubstrateGraphEdge::new(
3723                        entity_id.clone(),
3724                        concept_id.clone(),
3725                        "tagged_concept",
3726                    )
3727                    .with_property("label", "entity concept label".to_string())
3728                    .with_property("source_file", summary.file_path.clone())
3729                    .with_provenance(provenance.clone()),
3730                );
3731            }
3732        }
3733
3734        for idx in 0..concept_ids.len() {
3735            for next_idx in (idx + 1)..concept_ids.len() {
3736                insert_semantic_edge(
3737                    &mut semantic_edges,
3738                    SubstrateGraphEdge::new(
3739                        concept_ids[idx].clone(),
3740                        concept_ids[next_idx].clone(),
3741                        "related_concept",
3742                    )
3743                    .with_property("label", format!("co-occurs in {}", summary.symbol_name))
3744                    .with_property("source_file", summary.file_path.clone())
3745                    .with_provenance(provenance.clone()),
3746                );
3747            }
3748        }
3749
3750        if let Some(relationships) = &summary.relationships {
3751            for relationship in relationships {
3752                let from_id = entity_ids_by_name
3753                    .get(&relationship.from.to_ascii_lowercase())
3754                    .cloned()
3755                    .unwrap_or_else(|| {
3756                        let node = semantic_entity_node(
3757                            root,
3758                            summary,
3759                            &relationship.from,
3760                            "unknown",
3761                            "",
3762                            provenance,
3763                        );
3764                        let id = node.id.clone();
3765                        semantic_nodes.entry(id.clone()).or_insert(node);
3766                        id
3767                    });
3768                let to_id = entity_ids_by_name
3769                    .get(&relationship.to.to_ascii_lowercase())
3770                    .cloned()
3771                    .unwrap_or_else(|| {
3772                        let node = semantic_entity_node(
3773                            root,
3774                            summary,
3775                            &relationship.to,
3776                            "unknown",
3777                            "",
3778                            provenance,
3779                        );
3780                        let id = node.id.clone();
3781                        semantic_nodes.entry(id.clone()).or_insert(node);
3782                        id
3783                    });
3784                insert_semantic_edge(
3785                    &mut semantic_edges,
3786                    SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
3787                        .with_property("relationship_kind", relationship.kind.clone())
3788                        .with_property("label", relationship.kind.clone())
3789                        .with_property("source_file", summary.file_path.clone())
3790                        .with_property("source_symbol", summary.symbol_name.clone())
3791                        .with_provenance(provenance.clone()),
3792                );
3793            }
3794        }
3795    }
3796
3797    for node in semantic_nodes.into_values() {
3798        nodes.push(node_with_content_freshness(node)?);
3799    }
3800    for edge in semantic_edges.into_values() {
3801        edges.push(edge_with_content_freshness(edge)?);
3802    }
3803
3804    Ok(())
3805}
3806
3807fn projection_content_hash(
3808    nodes: &[SubstrateGraphNode],
3809    edges: &[SubstrateGraphEdge],
3810) -> Result<String> {
3811    #[derive(Serialize)]
3812    struct Payload<'a> {
3813        version: &'static str,
3814        nodes: &'a [SubstrateGraphNode],
3815        edges: &'a [SubstrateGraphEdge],
3816    }
3817
3818    content_hash(&Payload {
3819        version: GRAPH_PROJECTION_VERSION,
3820        nodes,
3821        edges,
3822    })
3823}
3824
3825pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
3826    projection
3827        .nodes
3828        .iter()
3829        .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
3830        .and_then(|node| node.properties.get("content_hash").cloned())
3831}
3832
3833fn traversal_projection_from_graph(
3834    root: &Path,
3835    scope: Option<&str>,
3836    graph: &TraversalGraphBuild,
3837) -> Result<GraphProjection> {
3838    let provenance = GraphProvenance::new(
3839        "tsift.traverse",
3840        format!("{}:{}", root.display(), scope.unwrap_or("root")),
3841    );
3842    let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
3843    for node in graph.nodes.values() {
3844        let mut projected =
3845            SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
3846                .with_property("handle", node.handle.clone())
3847                .with_property("expand", node.expand.clone())
3848                .with_provenance(provenance.clone());
3849        if let Some(ref_id) = &node.ref_id {
3850            projected = projected.with_property("ref_id", ref_id.clone());
3851        }
3852        if let Some(path) = &node.path {
3853            projected = projected.with_property("path", path.clone());
3854        }
3855        if let Some(line) = node.line {
3856            projected = projected.with_property("line", line.to_string());
3857        }
3858        if let Some(detail) = &node.detail {
3859            projected = projected.with_property("detail", detail.clone());
3860        }
3861        for (key, value) in &node.properties {
3862            projected = projected.with_property(key.clone(), value.clone());
3863        }
3864        nodes.push(node_with_content_freshness(projected)?);
3865    }
3866
3867    let mut edges = Vec::with_capacity(graph.edges.len());
3868    for edge in &graph.edges {
3869        let mut projected =
3870            SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
3871                .with_property("weight", edge.weight.to_string())
3872                .with_provenance(provenance.clone());
3873        if let Some(label) = &edge.label {
3874            projected = projected.with_property("label", label.clone());
3875        }
3876        edges.push(edge_with_content_freshness(projected)?);
3877    }
3878
3879    append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3880    append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3881    append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
3882
3883    let projection_hash = projection_content_hash(&nodes, &edges)?;
3884    let meta = SubstrateGraphNode::new(
3885        graph_projection_meta_id(scope),
3886        GRAPH_PROJECTION_META_KIND,
3887        "tsift traversal projection",
3888    )
3889    .with_property("projection_version", GRAPH_PROJECTION_VERSION)
3890    .with_property("content_hash", projection_hash.clone())
3891    .with_property("root", root.to_string_lossy().to_string())
3892    .with_property("scope", scope.unwrap_or("root"))
3893    .with_property("node_count", graph.nodes.len().to_string())
3894    .with_property("edge_count", graph.edges.len().to_string())
3895    .with_provenance(provenance)
3896    .with_freshness(GraphFreshness::content_hash(projection_hash));
3897    nodes.push(meta);
3898
3899    Ok(GraphProjection { nodes, edges })
3900}
3901
3902#[allow(clippy::too_many_arguments)]
3903fn ensure_traversal_source_handle(
3904    root: &Path,
3905    provenance: &GraphProvenance,
3906    file_node_by_path: &BTreeMap<String, String>,
3907    node: &TraversalNode,
3908    budget: &ExplorationBudget,
3909    source_handle_by_node: &mut BTreeMap<String, String>,
3910    seen_windows: &mut BTreeMap<(String, usize, usize), String>,
3911    nodes: &mut Vec<SubstrateGraphNode>,
3912    edges: &mut Vec<SubstrateGraphEdge>,
3913) -> Result<Option<String>> {
3914    if let Some(handle) = source_handle_by_node.get(&node.handle) {
3915        return Ok(Some(handle.clone()));
3916    }
3917    let Some(window) = exploration_source_window_for_node(root, node, budget) else {
3918        return Ok(None);
3919    };
3920    let window_key = (window.file.clone(), window.start, window.end);
3921    let handle = if let Some(handle) = seen_windows.get(&window_key) {
3922        handle.clone()
3923    } else {
3924        let label = format!("{}:{}-{}", window.file, window.start, window.end);
3925        let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
3926            .with_property("handle", window.handle.clone())
3927            .with_property("file", window.file.clone())
3928            .with_property("start", window.start.to_string())
3929            .with_property("end", window.end.to_string())
3930            .with_property("reason", window.reason.clone())
3931            .with_property("expand", window.expand.clone())
3932            .with_provenance(provenance.clone());
3933        nodes.push(node_with_content_freshness(projected)?);
3934
3935        if let Some(file_handle) = file_node_by_path.get(&window.file) {
3936            let edge = SubstrateGraphEdge::new(
3937                window.handle.clone(),
3938                file_handle.clone(),
3939                "expands_source",
3940            )
3941            .with_property("label", window.reason.clone())
3942            .with_provenance(provenance.clone());
3943            edges.push(edge_with_content_freshness(edge)?);
3944        }
3945        if node.kind != "file" {
3946            let edge = SubstrateGraphEdge::new(
3947                window.handle.clone(),
3948                node.handle.clone(),
3949                "anchors_source",
3950            )
3951            .with_property("label", window.reason.clone())
3952            .with_provenance(provenance.clone());
3953            edges.push(edge_with_content_freshness(edge)?);
3954        }
3955        seen_windows.insert(window_key, window.handle.clone());
3956        window.handle
3957    };
3958    source_handle_by_node.insert(node.handle.clone(), handle.clone());
3959    Ok(Some(handle))
3960}
3961
3962fn push_traversal_backlog_target_handles<'a>(
3963    backlog: &TraversalNode,
3964    edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
3965    node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
3966    max_handles: usize,
3967    seen_target_nodes: &mut BTreeSet<String>,
3968    target_node_handles: &mut Vec<String>,
3969) {
3970    for edge in edges_by_from
3971        .get(backlog.handle.as_str())
3972        .into_iter()
3973        .flatten()
3974        .filter(|edge| edge.relation == "mentions")
3975    {
3976        let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
3977            continue;
3978        };
3979        if !matches!(
3980            target_node.kind.as_str(),
3981            "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
3982        ) {
3983            continue;
3984        }
3985        if target_node
3986            .path
3987            .as_deref()
3988            .zip(backlog.path.as_deref())
3989            .is_some_and(|(target_path, backlog_path)| {
3990                target_path == backlog_path && target_path.ends_with(".md")
3991            })
3992        {
3993            continue;
3994        }
3995        if seen_target_nodes.insert(target_node.handle.clone()) {
3996            target_node_handles.push(target_node.handle.clone());
3997        }
3998        if target_node_handles.len() >= max_handles {
3999            break;
4000        }
4001    }
4002}
4003
4004fn append_traversal_context_projection_rows(
4005    root: &Path,
4006    graph: &TraversalGraphBuild,
4007    provenance: &GraphProvenance,
4008    nodes: &mut Vec<SubstrateGraphNode>,
4009    edges: &mut Vec<SubstrateGraphEdge>,
4010) -> Result<()> {
4011    let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4012    let file_node_by_path = graph
4013        .nodes
4014        .values()
4015        .filter(|node| node.kind == "file")
4016        .filter_map(|node| {
4017            node.path
4018                .as_ref()
4019                .map(|path| (path.clone(), node.handle.clone()))
4020        })
4021        .collect::<BTreeMap<_, _>>();
4022
4023    let node_by_handle = graph
4024        .nodes
4025        .values()
4026        .map(|node| (node.handle.as_str(), node))
4027        .collect::<BTreeMap<_, _>>();
4028    let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4029    for edge in &graph.edges {
4030        edges_by_from
4031            .entry(edge.from.as_str())
4032            .or_default()
4033            .push(edge);
4034    }
4035    for rows in edges_by_from.values_mut() {
4036        rows.sort_by(|left, right| {
4037            right
4038                .weight
4039                .cmp(&left.weight)
4040                .then(left.relation.cmp(&right.relation))
4041                .then(left.to.cmp(&right.to))
4042        });
4043    }
4044
4045    let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4046    let mut source_handle_by_node = BTreeMap::<String, String>::new();
4047
4048    let mut code_context_count = 0usize;
4049    let code_context_limit = budget.relationship_limit.min(8);
4050    for node in graph.nodes.values() {
4051        if !matches!(
4052            node.kind.as_str(),
4053            "backlog" | "job_packet" | "worker_result"
4054        ) {
4055            continue;
4056        }
4057        let mut target_node_handles = Vec::new();
4058        let mut fallback_target_handles = Vec::new();
4059        let mut seen_target_nodes = BTreeSet::new();
4060        if node.kind == "backlog" || node.kind == "worker_result" {
4061            push_traversal_backlog_target_handles(
4062                node,
4063                &edges_by_from,
4064                &node_by_handle,
4065                budget.max_source_windows,
4066                &mut seen_target_nodes,
4067                &mut target_node_handles,
4068            );
4069            fallback_target_handles.push(node.handle.clone());
4070        } else {
4071            for edge in edges_by_from
4072                .get(node.handle.as_str())
4073                .into_iter()
4074                .flatten()
4075                .filter(|edge| edge.relation == "targets")
4076            {
4077                let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4078                    continue;
4079                };
4080                fallback_target_handles.push(backlog.handle.clone());
4081                push_traversal_backlog_target_handles(
4082                    backlog,
4083                    &edges_by_from,
4084                    &node_by_handle,
4085                    budget.max_source_windows,
4086                    &mut seen_target_nodes,
4087                    &mut target_node_handles,
4088                );
4089                if target_node_handles.len() >= budget.max_source_windows {
4090                    break;
4091                }
4092            }
4093            if fallback_target_handles.is_empty() {
4094                continue;
4095            }
4096        }
4097        let code_context = !target_node_handles.is_empty();
4098        if target_node_handles.is_empty() {
4099            target_node_handles = dedupe_preserve_order(fallback_target_handles);
4100        } else if code_context_count >= code_context_limit {
4101            continue;
4102        }
4103
4104        let mut worker_source_handles = Vec::new();
4105        let mut seen_worker_handles = BTreeSet::new();
4106        for target_handle in target_node_handles {
4107            if worker_source_handles.len() >= budget.max_source_windows {
4108                break;
4109            }
4110            let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4111                continue;
4112            };
4113            let Some(handle) = ensure_traversal_source_handle(
4114                root,
4115                provenance,
4116                &file_node_by_path,
4117                target_node,
4118                &budget,
4119                &mut source_handle_by_node,
4120                &mut seen_windows,
4121                nodes,
4122                edges,
4123            )?
4124            else {
4125                continue;
4126            };
4127            if seen_worker_handles.insert(handle.clone()) {
4128                worker_source_handles.push(handle);
4129            }
4130        }
4131        if worker_source_handles.is_empty() {
4132            continue;
4133        }
4134        let target = node
4135            .path
4136            .clone()
4137            .unwrap_or_else(|| root.to_string_lossy().to_string());
4138        let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4139        let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4140        let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4141            .with_property("handle", handle.clone())
4142            .with_property("target", target.clone())
4143            .with_property("summary", summary)
4144            .with_property(
4145                "source_handle_count",
4146                worker_source_handles.len().to_string(),
4147            )
4148            .with_property(
4149                "expand",
4150                format!(
4151                    "tsift --envelope context-pack {} --budget normal",
4152                    shell_quote(&target)
4153                ),
4154            )
4155            .with_provenance(provenance.clone());
4156        nodes.push(node_with_content_freshness(projected)?);
4157
4158        let request_edge =
4159            SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4160                .with_property("label", "bounded worker context".to_string())
4161                .with_provenance(provenance.clone());
4162        edges.push(edge_with_content_freshness(request_edge)?);
4163
4164        for source_handle in &worker_source_handles {
4165            let scope_edge =
4166                SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4167                    .with_property("label", "bounded worker source window".to_string())
4168                    .with_provenance(provenance.clone());
4169            edges.push(edge_with_content_freshness(scope_edge)?);
4170        }
4171        if code_context {
4172            code_context_count += 1;
4173        }
4174    }
4175
4176    Ok(())
4177}
4178
4179fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4180    let handle = node
4181        .properties
4182        .get("handle")
4183        .cloned()
4184        .unwrap_or_else(|| node.id.clone());
4185    TraversalNode {
4186        expand: node
4187            .properties
4188            .get("expand")
4189            .cloned()
4190            .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4191        handle,
4192        kind: node.kind,
4193        label: node.label,
4194        ref_id: node.properties.get("ref_id").cloned(),
4195        path: node.properties.get("path").cloned(),
4196        line: node
4197            .properties
4198            .get("line")
4199            .and_then(|value| value.parse::<i64>().ok()),
4200        detail: node.properties.get("detail").cloned(),
4201        properties: node.properties,
4202    }
4203}
4204
4205fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4206    let mut graph = TraversalGraphBuild::default();
4207    for node in store.all_nodes()? {
4208        if node.kind == GRAPH_PROJECTION_META_KIND {
4209            continue;
4210        }
4211        graph.add_node(traversal_node_from_graph_node(root, node));
4212    }
4213    for edge in store.all_edges()? {
4214        graph.add_edge(
4215            &edge.from_id,
4216            &edge.to_id,
4217            &edge.kind,
4218            edge.properties.get("label").cloned(),
4219            edge.properties
4220                .get("weight")
4221                .and_then(|value| value.parse::<usize>().ok())
4222                .unwrap_or(1),
4223        );
4224    }
4225    Ok(graph)
4226}
4227
4228pub(crate) fn convex_rows_from_graph_store(
4229    store: &impl GraphStore,
4230) -> Result<ConvexProjectionRows> {
4231    Ok(GraphProjection {
4232        nodes: store.all_nodes()?,
4233        edges: store.all_edges()?,
4234    }
4235    .to_convex_rows())
4236}
4237
4238#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4239struct ConvexRequiredIndex {
4240    table: String,
4241    name: String,
4242    fields: Vec<String>,
4243}
4244
4245#[derive(Clone, Debug, Serialize, PartialEq)]
4246struct ConvexSyncChunk {
4247    operation: String,
4248    chunk: usize,
4249    count: usize,
4250    keys: Vec<String>,
4251    max_attempts: usize,
4252    retry_policy: String,
4253}
4254
4255#[derive(Clone, Debug, Serialize, PartialEq)]
4256struct ConvexTransportSummary {
4257    endpoint_env: String,
4258    endpoint_configured: bool,
4259    auth_token_env: String,
4260    auth_configured: bool,
4261    remote_snapshot: bool,
4262    applied_chunks: usize,
4263}
4264
4265#[derive(Clone, Debug, Serialize, PartialEq)]
4266struct ConvexTransportReceipt {
4267    operation: String,
4268    chunk: usize,
4269    attempt: usize,
4270    status: String,
4271    message: Option<String>,
4272}
4273
4274#[derive(Serialize)]
4275#[serde(rename_all = "camelCase")]
4276struct ConvexTransportRequest<'a> {
4277    operation: &'a str,
4278    chunk: usize,
4279    projection_version: &'a str,
4280    projection_hash: Option<&'a str>,
4281    #[serde(skip_serializing_if = "Option::is_none")]
4282    projection_meta_id: Option<&'a str>,
4283    node_rows: Vec<ConvexNodeRow>,
4284    edge_rows: Vec<ConvexEdgeRow>,
4285    keys: Vec<String>,
4286    #[serde(skip_serializing_if = "Option::is_none")]
4287    cursor: Option<String>,
4288    #[serde(skip_serializing_if = "Option::is_none")]
4289    limit: Option<usize>,
4290}
4291
4292#[derive(Deserialize)]
4293#[serde(rename_all = "camelCase")]
4294struct ConvexTransportResponse {
4295    status: Option<String>,
4296    message: Option<String>,
4297    rows: Option<ConvexProjectionRows>,
4298    #[serde(default)]
4299    meta: Option<ConvexSnapshotMeta>,
4300    #[serde(default)]
4301    page: Option<ConvexSnapshotPage>,
4302}
4303
4304#[derive(Deserialize, Debug, Clone)]
4305#[serde(rename_all = "camelCase")]
4306struct ConvexSnapshotMeta {
4307    // Captured for completeness/debugging; not currently consumed by the
4308    // freshness diff (indexes are already validated against the required set
4309    // via `convex_required_indexes`, and `page_size` is informational only).
4310    #[serde(default)]
4311    #[allow(dead_code)]
4312    indexes: Vec<ConvexRequiredIndex>,
4313    #[serde(default)]
4314    #[allow(dead_code)]
4315    node_count: Option<usize>,
4316    #[serde(default)]
4317    #[allow(dead_code)]
4318    edge_count: Option<usize>,
4319    #[serde(default)]
4320    projection_hash: Option<String>,
4321    #[serde(default)]
4322    #[allow(dead_code)]
4323    page_size: Option<usize>,
4324}
4325
4326/// Paginated snapshot page response. `rows` is either node rows or edge rows
4327/// depending on which operation was called; we deserialize as raw values to
4328/// keep the transport struct shared between both shapes, then narrow per call
4329/// site.
4330#[derive(Deserialize, Debug, Clone)]
4331#[serde(rename_all = "camelCase")]
4332struct ConvexSnapshotPage {
4333    rows: Vec<serde_json::Value>,
4334    #[serde(default)]
4335    next_cursor: Option<String>,
4336}
4337
4338#[derive(Clone, Debug, Serialize, PartialEq)]
4339struct ConvexProjectionFreshness {
4340    status: String,
4341    fail_closed: bool,
4342    local_hash: Option<String>,
4343    snapshot_hash: Option<String>,
4344    missing_nodes: Vec<String>,
4345    stale_nodes: Vec<String>,
4346    missing_edges: Vec<String>,
4347    stale_edges: Vec<String>,
4348    diagnostics: Vec<String>,
4349}
4350
4351const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
4352
4353impl ConvexProjectionFreshness {
4354    fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
4355        Self {
4356            status: "current".to_string(),
4357            fail_closed: false,
4358            local_hash,
4359            snapshot_hash,
4360            missing_nodes: Vec::new(),
4361            stale_nodes: Vec::new(),
4362            missing_edges: Vec::new(),
4363            stale_edges: Vec::new(),
4364            diagnostics: Vec::new(),
4365        }
4366    }
4367}
4368
4369#[derive(Clone, Debug, Serialize, PartialEq)]
4370struct ConvexSyncReport {
4371    root: String,
4372    #[serde(skip_serializing_if = "Option::is_none")]
4373    scope: Option<String>,
4374    graph_db: String,
4375    dry_run: bool,
4376    projection_version: String,
4377    projection_hash: Option<String>,
4378    required_indexes: Vec<ConvexRequiredIndex>,
4379    node_upserts: Vec<ConvexNodeRow>,
4380    edge_upserts: Vec<ConvexEdgeRow>,
4381    node_tombstones: Vec<String>,
4382    edge_tombstones: Vec<String>,
4383    chunks: Vec<ConvexSyncChunk>,
4384    freshness: ConvexProjectionFreshness,
4385    transport: Option<ConvexTransportSummary>,
4386    receipts: Vec<ConvexTransportReceipt>,
4387    diagnostics: Vec<String>,
4388    warnings: Vec<String>,
4389}
4390
4391fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
4392    vec![
4393        ConvexRequiredIndex {
4394            table: "nodes".to_string(),
4395            name: "by_external_id".to_string(),
4396            fields: vec!["externalId".to_string()],
4397        },
4398        ConvexRequiredIndex {
4399            table: "nodes".to_string(),
4400            name: "by_kind".to_string(),
4401            fields: vec!["kind".to_string()],
4402        },
4403        ConvexRequiredIndex {
4404            table: "edges".to_string(),
4405            name: "by_edge_key".to_string(),
4406            fields: vec!["edgeKey".to_string()],
4407        },
4408        ConvexRequiredIndex {
4409            table: "edges".to_string(),
4410            name: "by_from_kind".to_string(),
4411            fields: vec!["fromExternalId".to_string(), "kind".to_string()],
4412        },
4413        ConvexRequiredIndex {
4414            table: "edges".to_string(),
4415            name: "by_to_kind".to_string(),
4416            fields: vec!["toExternalId".to_string(), "kind".to_string()],
4417        },
4418    ]
4419}
4420
4421pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
4422    let content = fs::read_to_string(path)
4423        .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
4424    serde_json::from_str(&content)
4425        .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
4426}
4427
4428fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
4429    let mut diagnostics = Vec::new();
4430    let mut node_counts = BTreeMap::<&str, usize>::new();
4431    for row in &rows.nodes {
4432        *node_counts.entry(row.external_id.as_str()).or_default() += 1;
4433    }
4434    for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
4435        diagnostics.push(format!(
4436            "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
4437        ));
4438    }
4439
4440    let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
4441    let mut edge_counts = BTreeMap::<&str, usize>::new();
4442    for edge in &rows.edges {
4443        *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
4444        if !node_ids.contains(edge.from_external_id.as_str()) {
4445            diagnostics.push(format!(
4446                "Convex snapshot edge {} references missing from node {}",
4447                edge.edge_key, edge.from_external_id
4448            ));
4449        }
4450        if !node_ids.contains(edge.to_external_id.as_str()) {
4451            diagnostics.push(format!(
4452                "Convex snapshot edge {} references missing to node {}",
4453                edge.edge_key, edge.to_external_id
4454            ));
4455        }
4456        let expected_key =
4457            ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
4458        if edge.edge_key != expected_key {
4459            diagnostics.push(format!(
4460                "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
4461                edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
4462            ));
4463        }
4464    }
4465    for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
4466        diagnostics.push(format!(
4467            "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
4468        ));
4469    }
4470    diagnostics
4471}
4472
4473pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
4474    let diagnostics = convex_projection_row_diagnostics(rows);
4475    if diagnostics.is_empty() {
4476        Ok(())
4477    } else {
4478        bail!("{}", diagnostics.join("; "))
4479    }
4480}
4481
4482pub(crate) struct ConvexHttpTransport {
4483    endpoint: String,
4484    auth_token_env: String,
4485    auth_token: Option<String>,
4486}
4487
4488impl ConvexHttpTransport {
4489    fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
4490        let endpoint = endpoint
4491            .map(str::to_string)
4492            .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
4493            .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
4494        let auth_token = env::var(auth_token_env)
4495            .ok()
4496            .filter(|value| !value.trim().is_empty());
4497        Ok(Self {
4498            endpoint,
4499            auth_token_env: auth_token_env.to_string(),
4500            auth_token,
4501        })
4502    }
4503
4504    fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
4505        ConvexTransportSummary {
4506            endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
4507            endpoint_configured: true,
4508            auth_token_env: self.auth_token_env.clone(),
4509            auth_configured: self.auth_token.is_some(),
4510            remote_snapshot,
4511            applied_chunks,
4512        }
4513    }
4514
4515    fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
4516        let mut builder = ureq::post(&self.endpoint);
4517        if let Some(token) = &self.auth_token {
4518            builder = builder.header("Authorization", &format!("Bearer {token}"));
4519        }
4520        builder
4521            .send_json(request)
4522            .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
4523            .body_mut()
4524            .read_json::<ConvexTransportResponse>()
4525            .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
4526    }
4527
4528    /// Fetch a full snapshot of the Convex graph backend.
4529    ///
4530    /// Uses the paginated `snapshot_meta` + `snapshot_nodes_page` +
4531    /// `snapshot_edges_page` triplet so the call works on tables larger than
4532    /// ~5k rows (the single-shot `snapshot` query hits Convex's 15s per-request
4533    /// syscall budget at that scale; see `#convexsnapshotscale`).
4534    ///
4535    /// Falls back to the legacy single-shot `snapshot` operation if the
4536    /// backend doesn't recognize `snapshot_meta` (older deployments that
4537    /// haven't redeployed the new schema).
4538    fn fetch_snapshot(
4539        &self,
4540        projection_version: &str,
4541        scope: Option<&str>,
4542        local_hash: Option<&str>,
4543        local_rows: Option<&ConvexProjectionRows>,
4544    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4545        match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
4546            Ok(rows) => Ok(rows),
4547            Err(err) => {
4548                // Only fall through to the legacy path if the failure looks
4549                // like "operation unknown" (older backend). Any other failure
4550                // (HTTP timeout, deserialization mismatch) should surface so
4551                // the operator sees the real cause.
4552                let msg = format!("{err:#}");
4553                let is_unknown_op = msg.contains("unknown operation")
4554                    || msg.contains("snapshot_meta")
4555                    || msg.contains("404");
4556                if !is_unknown_op {
4557                    return Err(err);
4558                }
4559                self.fetch_snapshot_legacy(projection_version)
4560                    .map(|rows| (rows, Vec::new()))
4561            }
4562        }
4563    }
4564
4565    fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
4566        let response = self.post(&ConvexTransportRequest {
4567            operation: "snapshot",
4568            chunk: 0,
4569            projection_version,
4570            projection_hash: None,
4571            projection_meta_id: None,
4572            node_rows: Vec::new(),
4573            edge_rows: Vec::new(),
4574            keys: Vec::new(),
4575            cursor: None,
4576            limit: None,
4577        })?;
4578        response
4579            .rows
4580            .context("Convex snapshot response did not include rows")
4581    }
4582
4583    fn fetch_snapshot_paginated(
4584        &self,
4585        projection_version: &str,
4586        scope: Option<&str>,
4587        local_hash: Option<&str>,
4588        local_rows: Option<&ConvexProjectionRows>,
4589    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4590        let projection_meta_id = graph_projection_meta_id(scope);
4591        let meta_response = self.post(&ConvexTransportRequest {
4592            operation: "snapshot_meta",
4593            chunk: 0,
4594            projection_version,
4595            projection_hash: None,
4596            projection_meta_id: Some(&projection_meta_id),
4597            node_rows: Vec::new(),
4598            edge_rows: Vec::new(),
4599            keys: Vec::new(),
4600            cursor: None,
4601            limit: None,
4602        })?;
4603        if matches!(meta_response.status.as_deref(), Some("error")) {
4604            anyhow::bail!(
4605                "Convex snapshot_meta returned error: {}",
4606                meta_response.message.unwrap_or_default()
4607            );
4608        }
4609        let meta = meta_response
4610            .meta
4611            .context("Convex snapshot_meta response did not include meta")?;
4612        if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
4613            (meta.projection_hash.as_deref(), local_hash, local_rows)
4614            && remote_hash == local_hash
4615        {
4616            return Ok((
4617                local_rows.clone(),
4618                vec![
4619                    "remote projection hash matched local graph; skipped full row-page snapshot diff"
4620                        .to_string(),
4621                ],
4622            ));
4623        }
4624
4625        let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
4626        let mut node_cursor: Option<String> = None;
4627        loop {
4628            let response = self.post(&ConvexTransportRequest {
4629                operation: "snapshot_nodes_page",
4630                chunk: 0,
4631                projection_version,
4632                projection_hash: None,
4633                projection_meta_id: None,
4634                node_rows: Vec::new(),
4635                edge_rows: Vec::new(),
4636                keys: Vec::new(),
4637                cursor: node_cursor.clone(),
4638                limit: None,
4639            })?;
4640            let page = response
4641                .page
4642                .context("Convex snapshot_nodes_page response did not include page")?;
4643            for raw in page.rows {
4644                let row: ConvexNodeRow =
4645                    serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
4646                nodes.push(row);
4647            }
4648            match page.next_cursor {
4649                Some(next) => node_cursor = Some(next),
4650                None => break,
4651            }
4652        }
4653
4654        let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
4655        let mut edge_cursor: Option<String> = None;
4656        loop {
4657            let response = self.post(&ConvexTransportRequest {
4658                operation: "snapshot_edges_page",
4659                chunk: 0,
4660                projection_version,
4661                projection_hash: None,
4662                projection_meta_id: None,
4663                node_rows: Vec::new(),
4664                edge_rows: Vec::new(),
4665                keys: Vec::new(),
4666                cursor: edge_cursor.clone(),
4667                limit: None,
4668            })?;
4669            let page = response
4670                .page
4671                .context("Convex snapshot_edges_page response did not include page")?;
4672            for raw in page.rows {
4673                let row: ConvexEdgeRow =
4674                    serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
4675                edges.push(row);
4676            }
4677            match page.next_cursor {
4678                Some(next) => edge_cursor = Some(next),
4679                None => break,
4680            }
4681        }
4682
4683        Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
4684    }
4685
4686    fn apply_chunk(
4687        &self,
4688        report: &ConvexSyncReport,
4689        chunk: &ConvexSyncChunk,
4690    ) -> Result<ConvexTransportReceipt> {
4691        let node_rows = if chunk.operation == "upsert_nodes" {
4692            report
4693                .node_upserts
4694                .iter()
4695                .filter(|row| chunk.keys.contains(&row.external_id))
4696                .cloned()
4697                .collect()
4698        } else {
4699            Vec::new()
4700        };
4701        let edge_rows = if chunk.operation == "upsert_edges" {
4702            report
4703                .edge_upserts
4704                .iter()
4705                .filter(|row| chunk.keys.contains(&row.edge_key))
4706                .cloned()
4707                .collect()
4708        } else {
4709            Vec::new()
4710        };
4711        let request = ConvexTransportRequest {
4712            operation: &chunk.operation,
4713            chunk: chunk.chunk,
4714            projection_version: &report.projection_version,
4715            projection_hash: report.projection_hash.as_deref(),
4716            projection_meta_id: None,
4717            node_rows,
4718            edge_rows,
4719            keys: chunk.keys.clone(),
4720            cursor: None,
4721            limit: None,
4722        };
4723        let mut last_error = None;
4724        for attempt in 1..=chunk.max_attempts {
4725            match self.post(&request) {
4726                Ok(response) => {
4727                    return Ok(ConvexTransportReceipt {
4728                        operation: chunk.operation.clone(),
4729                        chunk: chunk.chunk,
4730                        attempt,
4731                        status: response.status.unwrap_or_else(|| "ok".to_string()),
4732                        message: response.message,
4733                    });
4734                }
4735                Err(err) => {
4736                    last_error = Some(err);
4737                    if attempt < chunk.max_attempts {
4738                        std::thread::sleep(Duration::from_millis(100 * attempt as u64));
4739                    }
4740                }
4741            }
4742        }
4743        Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
4744            .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
4745    }
4746}
4747
4748fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
4749    let meta_id = graph_projection_meta_id(scope);
4750    rows.nodes
4751        .iter()
4752        .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
4753        .and_then(|row| row.properties.get("content_hash").cloned())
4754}
4755
4756fn convex_projection_freshness(
4757    local: &ConvexProjectionRows,
4758    snapshot: Option<&ConvexProjectionRows>,
4759    scope: Option<&str>,
4760) -> ConvexProjectionFreshness {
4761    let local_hash = convex_projection_hash(local, scope);
4762    let Some(snapshot) = snapshot else {
4763        return ConvexProjectionFreshness {
4764            status: "unchecked".to_string(),
4765            fail_closed: false,
4766            local_hash,
4767            snapshot_hash: None,
4768            missing_nodes: Vec::new(),
4769            stale_nodes: Vec::new(),
4770            missing_edges: Vec::new(),
4771            stale_edges: Vec::new(),
4772            diagnostics: vec![
4773                "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
4774            ],
4775        };
4776    };
4777
4778    let snapshot_hash = convex_projection_hash(snapshot, scope);
4779    let snapshot_nodes = snapshot
4780        .nodes
4781        .iter()
4782        .map(|row| (row.external_id.as_str(), row))
4783        .collect::<BTreeMap<_, _>>();
4784    let snapshot_edges = snapshot
4785        .edges
4786        .iter()
4787        .map(|row| (row.edge_key.as_str(), row))
4788        .collect::<BTreeMap<_, _>>();
4789
4790    let mut missing_nodes = Vec::new();
4791    let mut stale_nodes = Vec::new();
4792    for row in &local.nodes {
4793        match snapshot_nodes.get(row.external_id.as_str()) {
4794            Some(snapshot_row) if *snapshot_row == row => {}
4795            Some(_) => stale_nodes.push(row.external_id.clone()),
4796            None => missing_nodes.push(row.external_id.clone()),
4797        }
4798    }
4799
4800    let mut missing_edges = Vec::new();
4801    let mut stale_edges = Vec::new();
4802    for row in &local.edges {
4803        match snapshot_edges.get(row.edge_key.as_str()) {
4804            Some(snapshot_row) if *snapshot_row == row => {}
4805            Some(_) => stale_edges.push(row.edge_key.clone()),
4806            None => missing_edges.push(row.edge_key.clone()),
4807        }
4808    }
4809
4810    let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
4811    let rows_current = missing_nodes.is_empty()
4812        && stale_nodes.is_empty()
4813        && missing_edges.is_empty()
4814        && stale_edges.is_empty();
4815    if hash_current && rows_current {
4816        return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
4817    }
4818
4819    let mut diagnostics = Vec::new();
4820    if local_hash != snapshot_hash {
4821        diagnostics.push(format!(
4822            "projection hash mismatch: local={} snapshot={}",
4823            local_hash.as_deref().unwrap_or("missing"),
4824            snapshot_hash.as_deref().unwrap_or("missing")
4825        ));
4826    }
4827    if !missing_nodes.is_empty() || !missing_edges.is_empty() {
4828        diagnostics.push(format!(
4829            "Convex snapshot is missing {} node(s) and {} edge(s)",
4830            missing_nodes.len(),
4831            missing_edges.len()
4832        ));
4833    }
4834    if !stale_nodes.is_empty() || !stale_edges.is_empty() {
4835        diagnostics.push(format!(
4836            "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
4837            stale_nodes.len(),
4838            stale_edges.len()
4839        ));
4840    }
4841
4842    ConvexProjectionFreshness {
4843        status: "stale".to_string(),
4844        fail_closed: true,
4845        local_hash,
4846        snapshot_hash,
4847        missing_nodes,
4848        stale_nodes,
4849        missing_edges,
4850        stale_edges,
4851        diagnostics,
4852    }
4853}
4854
4855pub(crate) fn verify_convex_projection_snapshot(
4856    root: &Path,
4857    scope: Option<&str>,
4858    snapshot_path: &Path,
4859) -> Result<()> {
4860    let graph_db = graph_substrate_db_path(root, scope);
4861    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4862    let local = convex_rows_from_graph_store(&store)?;
4863    let snapshot = load_convex_projection_rows(snapshot_path)?;
4864    validate_convex_projection_rows(&snapshot)?;
4865    let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
4866    if freshness.fail_closed {
4867        bail!(
4868            "Convex graph projection is not current for {}: {}",
4869            root.display(),
4870            freshness.diagnostics.join("; ")
4871        );
4872    }
4873    Ok(())
4874}
4875
4876fn convex_rows_diff(
4877    local: &ConvexProjectionRows,
4878    snapshot: Option<&ConvexProjectionRows>,
4879) -> (
4880    Vec<ConvexNodeRow>,
4881    Vec<ConvexEdgeRow>,
4882    Vec<String>,
4883    Vec<String>,
4884) {
4885    let Some(snapshot) = snapshot else {
4886        return (
4887            local.nodes.clone(),
4888            local.edges.clone(),
4889            Vec::new(),
4890            Vec::new(),
4891        );
4892    };
4893    let local_nodes = local
4894        .nodes
4895        .iter()
4896        .map(|row| (row.external_id.as_str(), row))
4897        .collect::<BTreeMap<_, _>>();
4898    let local_edges = local
4899        .edges
4900        .iter()
4901        .map(|row| (row.edge_key.as_str(), row))
4902        .collect::<BTreeMap<_, _>>();
4903    let snapshot_nodes = snapshot
4904        .nodes
4905        .iter()
4906        .map(|row| (row.external_id.as_str(), row))
4907        .collect::<BTreeMap<_, _>>();
4908    let snapshot_edges = snapshot
4909        .edges
4910        .iter()
4911        .map(|row| (row.edge_key.as_str(), row))
4912        .collect::<BTreeMap<_, _>>();
4913
4914    let node_upserts = local
4915        .nodes
4916        .iter()
4917        .filter(|row| {
4918            snapshot_nodes
4919                .get(row.external_id.as_str())
4920                .is_none_or(|snapshot_row| *snapshot_row != *row)
4921        })
4922        .cloned()
4923        .collect::<Vec<_>>();
4924    let edge_upserts = local
4925        .edges
4926        .iter()
4927        .filter(|row| {
4928            snapshot_edges
4929                .get(row.edge_key.as_str())
4930                .is_none_or(|snapshot_row| *snapshot_row != *row)
4931        })
4932        .cloned()
4933        .collect::<Vec<_>>();
4934    let node_tombstones = snapshot
4935        .nodes
4936        .iter()
4937        .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
4938        .map(|row| row.external_id.clone())
4939        .collect::<Vec<_>>();
4940    let edge_tombstones = snapshot
4941        .edges
4942        .iter()
4943        .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
4944        .map(|row| row.edge_key.clone())
4945        .collect::<Vec<_>>();
4946
4947    (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
4948}
4949
4950fn push_sync_chunks(
4951    chunks: &mut Vec<ConvexSyncChunk>,
4952    operation: &str,
4953    keys: Vec<String>,
4954    size: usize,
4955) {
4956    if keys.is_empty() {
4957        return;
4958    }
4959    for (idx, chunk) in keys.chunks(size).enumerate() {
4960        chunks.push(ConvexSyncChunk {
4961            operation: operation.to_string(),
4962            chunk: idx + 1,
4963            count: chunk.len(),
4964            keys: chunk.to_vec(),
4965            max_attempts: 3,
4966            retry_policy:
4967                "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
4968                    .to_string(),
4969        });
4970    }
4971}
4972
4973pub(crate) fn build_convex_sync_report_with_snapshot(
4974    path: &Path,
4975    scope: Option<&str>,
4976    snapshot: Option<ConvexProjectionRows>,
4977    chunk_size: usize,
4978    dry_run: bool,
4979) -> Result<ConvexSyncReport> {
4980    if chunk_size == 0 {
4981        bail!("--chunk-size must be greater than zero");
4982    }
4983    let root = lint::resolve_project_root_or_canonical_path(path)?;
4984    let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
4985    let graph_db = graph_substrate_db_path(&root, scope);
4986    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4987    let local = convex_rows_from_graph_store(&store)?;
4988    let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
4989    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
4990        convex_rows_diff(&local, snapshot.as_ref());
4991
4992    let mut chunks = Vec::new();
4993    push_sync_chunks(
4994        &mut chunks,
4995        "delete_edges",
4996        edge_tombstones.clone(),
4997        chunk_size,
4998    );
4999    push_sync_chunks(
5000        &mut chunks,
5001        "upsert_nodes",
5002        node_upserts
5003            .iter()
5004            .map(|row| row.external_id.clone())
5005            .collect(),
5006        chunk_size,
5007    );
5008    push_sync_chunks(
5009        &mut chunks,
5010        "upsert_edges",
5011        edge_upserts
5012            .iter()
5013            .map(|row| row.edge_key.clone())
5014            .collect(),
5015        chunk_size,
5016    );
5017    push_sync_chunks(
5018        &mut chunks,
5019        "delete_nodes",
5020        node_tombstones.clone(),
5021        chunk_size,
5022    );
5023
5024    let mut diagnostics = vec![
5025        "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5026            .to_string(),
5027    ];
5028    if dry_run {
5029        diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5030    }
5031    if freshness.fail_closed {
5032        diagnostics.push(
5033            "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5034                .to_string(),
5035        );
5036    }
5037
5038    Ok(ConvexSyncReport {
5039        root: root.to_string_lossy().to_string(),
5040        scope: scope.map(str::to_string),
5041        graph_db: graph_db.to_string_lossy().to_string(),
5042        dry_run,
5043        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5044        projection_hash: convex_projection_hash(&local, scope),
5045        required_indexes: convex_required_indexes(),
5046        node_upserts,
5047        edge_upserts,
5048        node_tombstones,
5049        edge_tombstones,
5050        chunks,
5051        freshness,
5052        transport: None,
5053        receipts: Vec::new(),
5054        diagnostics,
5055        warnings: graph.warnings,
5056    })
5057}
5058
5059#[cfg(test)]
5060fn build_convex_sync_report(
5061    path: &Path,
5062    scope: Option<&str>,
5063    snapshot_path: Option<&Path>,
5064    chunk_size: usize,
5065) -> Result<ConvexSyncReport> {
5066    let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5067    build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5068}
5069
5070pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5071    if compact {
5072        println!(
5073            "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5074            report.node_upserts.len(),
5075            report.node_tombstones.len(),
5076            report.edge_upserts.len(),
5077            report.edge_tombstones.len(),
5078            report.chunks.len(),
5079            report.freshness.status
5080        );
5081        return;
5082    }
5083
5084    println!(
5085        "Convex graph sync {}",
5086        if report.dry_run { "dry-run" } else { "apply" }
5087    );
5088    println!("root: {}", report.root);
5089    println!("graph_db: {}", report.graph_db);
5090    println!(
5091        "upserts: {} node(s), {} edge(s)",
5092        report.node_upserts.len(),
5093        report.edge_upserts.len()
5094    );
5095    println!(
5096        "tombstones: {} node(s), {} edge(s)",
5097        report.node_tombstones.len(),
5098        report.edge_tombstones.len()
5099    );
5100    println!("chunks: {}", report.chunks.len());
5101    println!("freshness: {}", report.freshness.status);
5102    if let Some(transport) = &report.transport {
5103        println!(
5104            "transport: endpoint_env={} auth_env={} applied_chunks={}",
5105            transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5106        );
5107    }
5108    for receipt in &report.receipts {
5109        println!(
5110            "receipt: {} chunk {} attempt {} {}",
5111            receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5112        );
5113    }
5114    for diagnostic in report
5115        .diagnostics
5116        .iter()
5117        .chain(report.freshness.diagnostics.iter())
5118    {
5119        println!("- {}", diagnostic);
5120    }
5121}
5122
5123pub(crate) struct ConvexSyncOptions<'a> {
5124    path: &'a Path,
5125    scope: Option<&'a str>,
5126    snapshot: Option<&'a Path>,
5127    chunk_size: usize,
5128    remote_snapshot: bool,
5129    apply: bool,
5130    endpoint: Option<&'a str>,
5131    auth_token_env: &'a str,
5132}
5133
5134#[derive(Serialize)]
5135struct GraphDbSchemaField {
5136    name: &'static str,
5137    value_type: &'static str,
5138    description: &'static str,
5139}
5140
5141#[derive(Serialize)]
5142struct GraphDbSchemaOperation {
5143    command: &'static str,
5144    description: &'static str,
5145}
5146
5147#[derive(Serialize)]
5148struct GraphDbSchemaContract {
5149    name: &'static str,
5150    version: &'static str,
5151    description: &'static str,
5152}
5153
5154#[derive(Serialize)]
5155struct GraphDbSchema {
5156    contract_versions: Vec<GraphDbSchemaContract>,
5157    node_fields: Vec<GraphDbSchemaField>,
5158    edge_fields: Vec<GraphDbSchemaField>,
5159    operations: Vec<GraphDbSchemaOperation>,
5160}
5161
5162#[derive(Clone, Serialize, Deserialize)]
5163struct GraphDbFreshnessReport {
5164    status: String,
5165    fail_closed: bool,
5166    projection_version: Option<String>,
5167    content_hash: Option<String>,
5168    source_watermark: Option<String>,
5169    diagnostics: Vec<String>,
5170}
5171
5172#[derive(Clone, Debug, Serialize)]
5173pub(crate) struct GraphEffectivenessReadiness {
5174    pub(crate) status: String,
5175    pub(crate) fail_closed: bool,
5176    pub(crate) reason: String,
5177    pub(crate) diagnostics: Vec<String>,
5178    pub(crate) next_commands: Vec<String>,
5179}
5180
5181#[derive(Clone, Debug, Serialize, PartialEq)]
5182struct GraphDbPropertyFilter {
5183    key: String,
5184    value: String,
5185}
5186
5187#[derive(Clone, Debug, Default)]
5188struct GraphDbQueryOptions {
5189    cursor: Option<String>,
5190    limit: Option<usize>,
5191    property_filters: Vec<GraphDbPropertyFilter>,
5192}
5193
5194#[derive(Clone, Debug, Serialize, PartialEq)]
5195struct GraphDbPageReport {
5196    #[serde(skip_serializing_if = "Option::is_none")]
5197    cursor: Option<String>,
5198    #[serde(skip_serializing_if = "Option::is_none")]
5199    limit: Option<usize>,
5200    #[serde(skip_serializing_if = "Option::is_none")]
5201    next_cursor: Option<String>,
5202    returned_nodes: usize,
5203    returned_edges: usize,
5204    truncated: bool,
5205    property_filters: Vec<GraphDbPropertyFilter>,
5206    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5207    diagnostics: Vec<String>,
5208}
5209
5210type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5211
5212#[derive(Clone, Debug, Serialize)]
5213struct CommunityTruncationSummary {
5214    total_communities: usize,
5215    fully_kept: usize,
5216    partially_pruned: usize,
5217    fully_pruned: usize,
5218    pruned_community_kinds: Vec<String>,
5219    pruned_community_top_labels: Vec<String>,
5220}
5221
5222#[derive(Clone, Debug, Serialize)]
5223struct GraphDbRankedNeighborhoodComparison {
5224    traversal_nodes: usize,
5225    traversal_edges: usize,
5226    pruned_count: usize,
5227    total_discovered: usize,
5228    latency_micros: u128,
5229    overlap_with_unranked_pct: f64,
5230    useful_hit_density_ranked: f64,
5231    useful_hit_density_unranked: f64,
5232    duplicate_name_count_ranked: usize,
5233    duplicate_name_count_unranked: usize,
5234    handle_coverage_ranked_pct: f64,
5235    handle_coverage_unranked_pct: f64,
5236    #[serde(skip_serializing_if = "Option::is_none")]
5237    community_truncation_summary: Option<CommunityTruncationSummary>,
5238    diagnostics: Vec<String>,
5239}
5240
5241#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5242struct GraphDbDroppedByBudget {
5243    item: String,
5244    kind: String,
5245    dropped: usize,
5246    reason: String,
5247}
5248
5249#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5250struct GraphDbOutputBudgetReport {
5251    max_tokens: usize,
5252    estimated_tokens: usize,
5253    selected_nodes: usize,
5254    selected_edges: usize,
5255    candidate_nodes: usize,
5256    candidate_edges: usize,
5257    dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5258    diagnostics: Vec<String>,
5259}
5260
5261#[derive(Clone, Debug, Serialize, PartialEq)]
5262struct GraphDbKnowledgeRetrieval {
5263    mode: String,
5264    query: String,
5265    seed_kind: String,
5266    seed_limit: usize,
5267    seed_count: usize,
5268    depth: usize,
5269    limit: usize,
5270    node_count: usize,
5271    edge_count: usize,
5272    truncated: bool,
5273    traversal: String,
5274    freshness_boundary: String,
5275    privacy_boundary: String,
5276    diagnostics: Vec<String>,
5277}
5278
5279struct GraphDbSemanticSeededSubgraph {
5280    nodes: Vec<SubstrateGraphNode>,
5281    edges: Vec<SubstrateGraphEdge>,
5282    truncated: bool,
5283    diagnostics: Vec<String>,
5284}
5285
5286type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5287
5288#[derive(Serialize)]
5289struct GraphDbReport {
5290    root: String,
5291    #[serde(skip_serializing_if = "Option::is_none")]
5292    scope: Option<String>,
5293    backend: String,
5294    query: String,
5295    freshness: GraphDbFreshnessReport,
5296    #[serde(skip_serializing_if = "Option::is_none")]
5297    readiness: Option<GraphEffectivenessReadiness>,
5298    #[serde(skip_serializing_if = "Option::is_none")]
5299    schema: Option<GraphDbSchema>,
5300    #[serde(skip_serializing_if = "Option::is_none")]
5301    node: Option<SubstrateTerseGraphNode>,
5302    #[serde(skip_serializing_if = "Option::is_none")]
5303    edge: Option<SubstrateTerseGraphEdge>,
5304    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5305    nodes: Vec<SubstrateTerseGraphNode>,
5306    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5307    edges: Vec<SubstrateTerseGraphEdge>,
5308    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5309    ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5310    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5311    semantic_related: Vec<SemanticRelatedItem>,
5312    #[serde(skip_serializing_if = "Option::is_none")]
5313    neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5314    #[serde(skip_serializing_if = "Option::is_none")]
5315    ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5316    #[serde(skip_serializing_if = "Option::is_none")]
5317    knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5318    #[serde(skip_serializing_if = "Option::is_none")]
5319    output_budget: Option<GraphDbOutputBudgetReport>,
5320    #[serde(skip_serializing_if = "Option::is_none")]
5321    path: Option<substrate::GraphPath>,
5322    #[serde(skip_serializing_if = "Option::is_none")]
5323    page: Option<GraphDbPageReport>,
5324    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5325    warnings: Vec<String>,
5326}
5327
5328struct ExperimentalReadOnlyGraphStore {
5329    backend: GraphDbExperimentalBackend,
5330    nodes: BTreeMap<String, SubstrateGraphNode>,
5331    edges: BTreeMap<String, SubstrateGraphEdge>,
5332    node_ids_by_kind: BTreeMap<String, Vec<String>>,
5333    outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5334}
5335
5336impl ExperimentalReadOnlyGraphStore {
5337    fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5338        validate_convex_projection_rows(rows)?;
5339        let nodes = rows
5340            .nodes
5341            .iter()
5342            .map(|row| {
5343                let node = SubstrateGraphNode {
5344                    id: row.external_id.clone(),
5345                    kind: row.kind.clone(),
5346                    label: row.label.clone(),
5347                    properties: row.properties.clone(),
5348                    provenance: row.provenance.clone(),
5349                    freshness: row.freshness.clone(),
5350                };
5351                (node.id.clone(), node)
5352            })
5353            .collect::<BTreeMap<_, _>>();
5354        let edges = rows
5355            .edges
5356            .iter()
5357            .map(|row| {
5358                let edge = SubstrateGraphEdge {
5359                    id: row.edge_key.clone(),
5360                    from_id: row.from_external_id.clone(),
5361                    to_id: row.to_external_id.clone(),
5362                    kind: row.kind.clone(),
5363                    properties: row.properties.clone(),
5364                    provenance: row.provenance.clone(),
5365                    freshness: row.freshness.clone(),
5366                };
5367                (graph_db_edge_key(&edge), edge)
5368            })
5369            .collect::<BTreeMap<_, _>>();
5370        let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
5371        for node in nodes.values() {
5372            node_ids_by_kind
5373                .entry(node.kind.clone())
5374                .or_default()
5375                .push(node.id.clone());
5376        }
5377        for ids in node_ids_by_kind.values_mut() {
5378            ids.sort();
5379        }
5380        let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
5381        for edge in edges.values() {
5382            outgoing_edge_keys_by_from
5383                .entry(edge.from_id.clone())
5384                .or_default()
5385                .push(graph_db_edge_key(edge));
5386        }
5387        for edge_keys in outgoing_edge_keys_by_from.values_mut() {
5388            edge_keys.sort_by(|left_key, right_key| {
5389                let left = &edges[left_key];
5390                let right = &edges[right_key];
5391                left.to_id
5392                    .cmp(&right.to_id)
5393                    .then(left.kind.cmp(&right.kind))
5394                    .then(left_key.cmp(right_key))
5395            });
5396        }
5397        Ok(Self {
5398            backend,
5399            nodes,
5400            edges,
5401            node_ids_by_kind,
5402            outgoing_edge_keys_by_from,
5403        })
5404    }
5405}
5406
5407impl GraphStore for ExperimentalReadOnlyGraphStore {
5408    fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
5409        bail!("{} backend-eval adapter is read-only", self.backend.name())
5410    }
5411
5412    fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
5413        bail!("{} backend-eval adapter is read-only", self.backend.name())
5414    }
5415
5416    fn delete_node(&self, _id: &str) -> Result<usize> {
5417        bail!("{} backend-eval adapter is read-only", self.backend.name())
5418    }
5419
5420    fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
5421        bail!("{} backend-eval adapter is read-only", self.backend.name())
5422    }
5423
5424    fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
5425        Ok(self.nodes.get(id).cloned())
5426    }
5427
5428    fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
5429        Ok(self.nodes.values().cloned().collect())
5430    }
5431
5432    fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
5433        let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
5434        edges.sort_by(|left, right| {
5435            left.from_id
5436                .cmp(&right.from_id)
5437                .then(left.kind.cmp(&right.kind))
5438                .then(left.to_id.cmp(&right.to_id))
5439        });
5440        Ok(edges)
5441    }
5442
5443    fn graph_counts(&self) -> Result<(usize, usize)> {
5444        Ok((self.nodes.len(), self.edges.len()))
5445    }
5446
5447    fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
5448        let mut edges = self
5449            .edges
5450            .values()
5451            .filter(|edge| edge.from_id != edge.to_id)
5452            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5453            .cloned()
5454            .collect::<Vec<_>>();
5455        edges.sort_by(|left, right| {
5456            left.from_id
5457                .cmp(&right.from_id)
5458                .then(left.kind.cmp(&right.kind))
5459                .then(left.to_id.cmp(&right.to_id))
5460        });
5461        Ok(edges.into_iter().next())
5462    }
5463
5464    fn sample_edge_with_property(
5465        &self,
5466    ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
5467        Ok(self
5468            .edges
5469            .values()
5470            .filter(|edge| edge.from_id != edge.to_id)
5471            .filter_map(|edge| {
5472                edge.properties.iter().next().map(|(key, value)| {
5473                    (
5474                        edge,
5475                        GraphPropertyFilter {
5476                            key: key.clone(),
5477                            value: value.clone(),
5478                        },
5479                    )
5480                })
5481            })
5482            .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
5483                left_filter
5484                    .key
5485                    .cmp(&right_filter.key)
5486                    .then(left_filter.value.cmp(&right_filter.value))
5487                    .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
5488            })
5489            .map(|(edge, filter)| (edge.clone(), filter)))
5490    }
5491
5492    fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
5493        Ok(self
5494            .node_ids_by_kind
5495            .get(kind)
5496            .into_iter()
5497            .flatten()
5498            .filter_map(|id| self.nodes.get(id).cloned())
5499            .collect())
5500    }
5501
5502    fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
5503        Ok(self
5504            .outgoing_edge_keys_by_from
5505            .get(from_id)
5506            .into_iter()
5507            .flatten()
5508            .filter_map(|key| self.edges.get(key))
5509            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5510            .cloned()
5511            .collect())
5512    }
5513
5514    fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
5515        Ok(self
5516            .edges
5517            .values()
5518            .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
5519            .cloned()
5520            .collect())
5521    }
5522
5523    fn shortest_path(
5524        &self,
5525        from_id: &str,
5526        to_id: &str,
5527        kind: Option<&str>,
5528    ) -> Result<Option<substrate::GraphPath>> {
5529        if from_id == to_id {
5530            return Ok(Some(substrate::GraphPath {
5531                nodes: vec![from_id.to_string()],
5532                hops: 0,
5533            }));
5534        }
5535
5536        let mut queue = VecDeque::new();
5537        let mut parent = BTreeMap::<String, String>::new();
5538        parent.insert(from_id.to_string(), String::new());
5539        queue.push_back(from_id.to_string());
5540
5541        while let Some(current) = queue.pop_front() {
5542            for edge in self.outgoing_edges(&current, kind)? {
5543                if parent.contains_key(&edge.to_id) {
5544                    continue;
5545                }
5546                parent.insert(edge.to_id.clone(), current.clone());
5547                if edge.to_id == to_id {
5548                    let mut nodes = vec![to_id.to_string()];
5549                    let mut cursor = to_id;
5550                    while let Some(previous) = parent.get(cursor) {
5551                        if previous.is_empty() {
5552                            break;
5553                        }
5554                        nodes.push(previous.clone());
5555                        cursor = previous;
5556                    }
5557                    nodes.reverse();
5558                    return Ok(Some(substrate::GraphPath {
5559                        hops: nodes.len().saturating_sub(1),
5560                        nodes,
5561                    }));
5562                }
5563                queue.push_back(edge.to_id);
5564            }
5565        }
5566
5567        Ok(None)
5568    }
5569
5570    fn reachable_nodes_by_kinds(
5571        &self,
5572        from_id: &str,
5573        kinds: &[&str],
5574        depth: usize,
5575        limit: usize,
5576    ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
5577        let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
5578        let mut rows = requested
5579            .iter()
5580            .map(|kind| {
5581                (
5582                    (*kind).to_string(),
5583                    BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
5584                )
5585            })
5586            .collect::<BTreeMap<_, _>>();
5587        if requested.is_empty() {
5588            return Ok(BTreeMap::new());
5589        }
5590
5591        let mut seen = BTreeSet::from([from_id.to_string()]);
5592        let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
5593        while let Some((current, path)) = queue.pop_front() {
5594            let current_depth = path.len().saturating_sub(1);
5595            if current_depth >= depth {
5596                continue;
5597            }
5598            for edge in self.outgoing_edges(&current, None)? {
5599                if !seen.insert(edge.to_id.clone()) {
5600                    continue;
5601                }
5602                let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
5603                    continue;
5604                };
5605                let mut next_path = path.clone();
5606                next_path.push(edge.to_id.clone());
5607                let graph_path = substrate::GraphPath {
5608                    hops: next_path.len().saturating_sub(1),
5609                    nodes: next_path.clone(),
5610                };
5611                if requested.contains(node.kind.as_str()) {
5612                    rows.entry(node.kind.clone())
5613                        .or_default()
5614                        .entry(node.id.clone())
5615                        .or_insert((node.clone(), graph_path));
5616                }
5617                queue.push_back((edge.to_id, next_path));
5618            }
5619        }
5620
5621        Ok(rows
5622            .into_iter()
5623            .map(|(kind, values)| {
5624                let mut values = values.into_values().collect::<Vec<_>>();
5625                values.sort_by(|(left_node, left_path), (right_node, right_path)| {
5626                    left_path
5627                        .hops
5628                        .cmp(&right_path.hops)
5629                        .then(left_node.label.cmp(&right_node.label))
5630                        .then(left_node.id.cmp(&right_node.id))
5631                });
5632                if limit > 0 && values.len() > limit {
5633                    values.truncate(limit);
5634                }
5635                (kind, values)
5636            })
5637            .collect())
5638    }
5639}
5640
5641pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
5642pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
5643pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
5644const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
5645pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
5646const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
5647const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
5648const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
5649const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
5650
5651#[derive(Clone, Serialize, Deserialize)]
5652pub(crate) struct GraphDbBackendEvalPhaseTiming {
5653    name: String,
5654    duration_micros: u128,
5655    detail: String,
5656}
5657
5658#[derive(Serialize, Deserialize)]
5659struct GraphDbBackendEvalFullProjectionCache {
5660    version: String,
5661    key: String,
5662    source_watermark: String,
5663    projection: GraphProjection,
5664    warnings: Vec<String>,
5665}
5666
5667#[derive(Clone, Default)]
5668struct GraphDbBackendEvalFullProjectionCacheStats {
5669    hit: bool,
5670    disk_bytes: u64,
5671    json_bytes: u64,
5672    pruned_files: usize,
5673    pruned_bytes: u64,
5674}
5675
5676#[derive(Serialize)]
5677struct GraphDbBackendEvalRawSourceWatermarkRow {
5678    path: String,
5679    bytes: u64,
5680    content_hash: String,
5681}
5682
5683#[derive(Clone)]
5684struct GraphDbBackendEvalFullProjectionSourceWatermark {
5685    value: String,
5686    detail: String,
5687}
5688
5689#[derive(Serialize)]
5690pub(crate) struct GraphDbBackendEvalConfig {
5691    high_degree_nodes: usize,
5692    high_degree_fanout: usize,
5693    deep_chain_nodes: usize,
5694    deep_chain_fanout: usize,
5695    depth: usize,
5696    limit: usize,
5697    impact_limit: usize,
5698    path_max_hops: usize,
5699    path_direct_hop_budget: usize,
5700    path_deep_chain_hop_budget: usize,
5701    path_extended_hop_budgets: Vec<usize>,
5702    path_hop_policy: String,
5703    path_probe_strategy: String,
5704    path_query_plan_checks: Vec<String>,
5705    full_projection_enabled: bool,
5706    full_projection_profile: String,
5707    normalization_row_unit: usize,
5708}
5709
5710#[derive(Clone)]
5711struct GraphDbBackendEvalSignature {
5712    operation: String,
5713    value: serde_json::Value,
5714}
5715
5716#[derive(Serialize)]
5717struct GraphDbBackendEvalOperation {
5718    name: String,
5719    supported: bool,
5720    status: String,
5721    duration_micros: u128,
5722    #[serde(skip_serializing_if = "Option::is_none")]
5723    rows: Option<usize>,
5724    #[serde(skip_serializing_if = "Option::is_none")]
5725    error: Option<String>,
5726}
5727
5728#[derive(Serialize)]
5729struct GraphDbBackendEvalParity {
5730    matches_sqlite: bool,
5731    diagnostics: Vec<String>,
5732}
5733
5734#[derive(Serialize)]
5735struct GraphDbBackendEvalBackendReport {
5736    backend: String,
5737    adapter: String,
5738    read_only: bool,
5739    projection_load: String,
5740    operations: Vec<GraphDbBackendEvalOperation>,
5741    total_micros: u128,
5742    parity: GraphDbBackendEvalParity,
5743    lock_behavior: String,
5744    install_portability: String,
5745}
5746
5747#[derive(Serialize)]
5748struct GraphDbBackendEvalDataset {
5749    name: String,
5750    target_count: usize,
5751    nodes: usize,
5752    edges: usize,
5753    backends: Vec<GraphDbBackendEvalBackendReport>,
5754}
5755
5756#[derive(Serialize)]
5757struct GraphDbBackendPromotionDecision {
5758    backend: String,
5759    decision: String,
5760    reasons: Vec<String>,
5761    gate: GraphDbBackendPromotionGate,
5762}
5763
5764#[derive(Serialize)]
5765struct GraphDbBackendEvalPerformanceGate {
5766    baseline_fixture: String,
5767    ci_profile: String,
5768    opt_in_real_profile: String,
5769    full_projection_cache_hit_gate: String,
5770    allowed_regression_percent: f64,
5771    minimum_sample_runs: usize,
5772    normalized_metric_unit: String,
5773    required_metrics: Vec<String>,
5774    digest_command: String,
5775    repeated_sample_command: String,
5776    hop_cap_promotion: GraphDbHopCapPromotionGate,
5777    backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
5778}
5779
5780#[derive(Serialize)]
5781struct GraphDbHopCapPromotionGate {
5782    status: String,
5783    current_default_hops: usize,
5784    candidate_hop_tiers: Vec<usize>,
5785    required_backend: String,
5786    required_workloads: Vec<String>,
5787    required_metrics: Vec<String>,
5788    allowed_regression_percent: f64,
5789    minimum_sample_runs: usize,
5790    decision_rule: String,
5791}
5792
5793#[derive(Serialize)]
5794struct GraphDbBackendAdapterSpikeGate {
5795    status: String,
5796    candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
5797    required_workloads: Vec<String>,
5798    required_checks: Vec<String>,
5799    decision_rule: String,
5800    evidence_plan: String,
5801}
5802
5803#[derive(Serialize)]
5804struct GraphDbBackendAdapterSpikeCandidate {
5805    backend: String,
5806    adapter_label: String,
5807    projection_load: String,
5808    lock_behavior: String,
5809    install_portability: String,
5810}
5811
5812#[derive(Serialize)]
5813pub(crate) struct GraphDbBackendEvalReport {
5814    root: String,
5815    #[serde(skip_serializing_if = "Option::is_none")]
5816    scope: Option<String>,
5817    label: String,
5818    baseline_backend: String,
5819    candidates: Vec<String>,
5820    targets: Vec<String>,
5821    config: GraphDbBackendEvalConfig,
5822    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5823    datasets: Vec<GraphDbBackendEvalDataset>,
5824    promotion: Vec<GraphDbBackendPromotionDecision>,
5825    performance_gate: GraphDbBackendEvalPerformanceGate,
5826    metrics: BTreeMap<String, f64>,
5827    metric_digest_command: String,
5828    warnings: Vec<String>,
5829}
5830
5831#[derive(Clone, Debug, Serialize)]
5832struct GraphDbDoctorCheck {
5833    name: String,
5834    status: String,
5835    fail_closed: bool,
5836    diagnostics: Vec<String>,
5837    repair_commands: Vec<String>,
5838}
5839
5840#[derive(Serialize)]
5841pub(crate) struct GraphDbDoctorReport {
5842    root: String,
5843    #[serde(skip_serializing_if = "Option::is_none")]
5844    scope: Option<String>,
5845    backend: String,
5846    graph_db: String,
5847    #[serde(skip_serializing_if = "Option::is_none")]
5848    convex_snapshot: Option<String>,
5849    status: String,
5850    fail_closed: bool,
5851    checks: Vec<GraphDbDoctorCheck>,
5852    repair_commands: Vec<String>,
5853    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5854    required_indexes: Vec<ConvexRequiredIndex>,
5855}
5856
5857#[derive(Serialize)]
5858struct GraphDbDriftSummary {
5859    node_upserts: usize,
5860    edge_upserts: usize,
5861    node_tombstones: usize,
5862    edge_tombstones: usize,
5863    stale_nodes: usize,
5864    stale_edges: usize,
5865    stale_projection_metadata: usize,
5866    duplicate_failures: usize,
5867    orphan_failures: usize,
5868    missing_required_indexes: usize,
5869}
5870
5871#[derive(Serialize)]
5872struct GraphDbDriftReport {
5873    root: String,
5874    #[serde(skip_serializing_if = "Option::is_none")]
5875    scope: Option<String>,
5876    graph_db: String,
5877    convex_snapshot: String,
5878    status: String,
5879    graph_reads_allowed: bool,
5880    projection_version: String,
5881    local_hash: Option<String>,
5882    snapshot_hash: Option<String>,
5883    summary: GraphDbDriftSummary,
5884    node_upserts: Vec<String>,
5885    edge_upserts: Vec<String>,
5886    node_tombstones: Vec<String>,
5887    edge_tombstones: Vec<String>,
5888    stale_nodes: Vec<String>,
5889    stale_edges: Vec<String>,
5890    diagnostics: Vec<String>,
5891    next_commands: Vec<String>,
5892    required_indexes: Vec<ConvexRequiredIndex>,
5893    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5894    warnings: Vec<String>,
5895}
5896
5897#[derive(Clone, Serialize)]
5898struct GraphDbTombstoneCounts {
5899    nodes: usize,
5900    edges: usize,
5901    total: usize,
5902}
5903
5904#[derive(Clone, Serialize)]
5905struct GraphDbOperatorCounts {
5906    nodes: usize,
5907    edges: usize,
5908    tombstones: GraphDbTombstoneCounts,
5909    #[serde(skip_serializing_if = "Option::is_none")]
5910    file_size_bytes: Option<u64>,
5911    #[serde(skip_serializing_if = "Option::is_none")]
5912    freelist_bytes: Option<u64>,
5913}
5914
5915#[derive(Clone, Serialize)]
5916struct GraphDbCompactionPolicy {
5917    status: String,
5918    tombstone_scan_rows: usize,
5919    live_rows: usize,
5920    file_size_bytes: Option<u64>,
5921    freelist_bytes: Option<u64>,
5922    safe_to_prune_tombstones: bool,
5923    requires_convex_reconciliation: bool,
5924    recommendations: Vec<String>,
5925    proof: Vec<String>,
5926}
5927
5928#[derive(Serialize)]
5929pub(crate) struct GraphDbRefreshSummary {
5930    scope: String,
5931    projection_version: String,
5932    mode: String,
5933    #[serde(skip_serializing_if = "Option::is_none")]
5934    source_watermark: Option<String>,
5935    tombstoned_nodes: usize,
5936    tombstoned_edges: usize,
5937    upserted_nodes: usize,
5938    upserted_edges: usize,
5939    unchanged_nodes: usize,
5940    unchanged_edges: usize,
5941    upserted_properties: usize,
5942    unchanged_properties: usize,
5943    deleted_properties: usize,
5944    deleted_nodes: usize,
5945    deleted_edges: usize,
5946    pruned_tombstones: usize,
5947    #[serde(skip_serializing_if = "Option::is_none")]
5948    file_size_bytes_before: Option<u64>,
5949    #[serde(skip_serializing_if = "Option::is_none")]
5950    file_size_bytes_after: Option<u64>,
5951    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5952    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5953}
5954
5955#[derive(Serialize)]
5956struct GraphDbOperatorReport {
5957    root: String,
5958    #[serde(skip_serializing_if = "Option::is_none")]
5959    scope: Option<String>,
5960    graph_db: String,
5961    operation: String,
5962    status: String,
5963    materialized: bool,
5964    freshness: GraphDbFreshnessReport,
5965    readiness: GraphEffectivenessReadiness,
5966    counts: GraphDbOperatorCounts,
5967    #[serde(skip_serializing_if = "Option::is_none")]
5968    refresh: Option<GraphDbRefreshSummary>,
5969    compaction: GraphDbCompactionPolicy,
5970    #[serde(skip_serializing_if = "Option::is_none")]
5971    recovery: Option<index::ReadOnlyRecovery>,
5972    next_commands: Vec<String>,
5973    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5974    warnings: Vec<String>,
5975}
5976
5977#[derive(Serialize)]
5978pub(crate) struct GraphDbCompactionReport {
5979    root: String,
5980    #[serde(skip_serializing_if = "Option::is_none")]
5981    scope: Option<String>,
5982    graph_db: String,
5983    applied: bool,
5984    pruned_tombstones: usize,
5985    counts_before: GraphDbOperatorCounts,
5986    counts_after: GraphDbOperatorCounts,
5987    compaction_before: GraphDbCompactionPolicy,
5988    compaction_after: GraphDbCompactionPolicy,
5989    reclaimed_bytes: i64,
5990    next_commands: Vec<String>,
5991    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5992    warnings: Vec<String>,
5993}
5994
5995#[derive(Clone, Serialize, Deserialize)]
5996struct GraphDbEvidencePath {
5997    to: String,
5998    kind: String,
5999    label: String,
6000    #[serde(skip_serializing_if = "Option::is_none")]
6001    path: Option<substrate::GraphPath>,
6002    #[serde(skip_serializing_if = "Option::is_none")]
6003    expand: Option<String>,
6004}
6005
6006#[derive(Clone, Serialize, Deserialize)]
6007struct GraphDbFixtureCoverage {
6008    test: String,
6009    fixture: String,
6010    assertions: Vec<String>,
6011}
6012
6013#[derive(Clone, Serialize, Deserialize)]
6014struct GraphDbEvidenceReport {
6015    root: String,
6016    #[serde(skip_serializing_if = "Option::is_none")]
6017    scope: Option<String>,
6018    backend: String,
6019    contract_version: String,
6020    target: String,
6021    packet_id: String,
6022    #[serde(skip_serializing_if = "Option::is_none")]
6023    projection_hash: Option<String>,
6024    freshness: GraphDbFreshnessReport,
6025    target_node: SubstrateTerseGraphNode,
6026    worker_context: Vec<SubstrateTerseGraphNode>,
6027    source_handles: Vec<SubstrateTerseGraphNode>,
6028    worker_results: Vec<SubstrateTerseGraphNode>,
6029    semantic_related: Vec<SubstrateTerseGraphNode>,
6030    shortest_paths: Vec<GraphDbEvidencePath>,
6031    #[serde(skip_serializing_if = "Option::is_none")]
6032    output_budget: Option<GraphDbOutputBudgetReport>,
6033    #[serde(default)]
6034    truncated: bool,
6035    #[serde(skip_serializing_if = "Option::is_none")]
6036    next_cursor: Option<String>,
6037    next_commands: Vec<String>,
6038    replay_commands: Vec<String>,
6039    repair_commands: Vec<String>,
6040    fixture_coverage: GraphDbFixtureCoverage,
6041    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6042    warnings: Vec<String>,
6043}
6044
6045pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6046    root: &'a Path,
6047    scope: Option<&'a str>,
6048    backend: &'a str,
6049    target: &'a str,
6050    depth: usize,
6051    limit: usize,
6052    cursor: Option<&'a str>,
6053    store: &'a S,
6054    freshness: GraphDbFreshnessReport,
6055    warnings: Vec<String>,
6056}
6057
6058impl GraphDbDoctorReport {
6059    fn new(
6060        root: &Path,
6061        scope: Option<&str>,
6062        backend: &str,
6063        graph_db: &Path,
6064        convex_snapshot: Option<&Path>,
6065    ) -> Self {
6066        Self {
6067            root: root.to_string_lossy().to_string(),
6068            scope: scope.map(str::to_string),
6069            backend: backend.to_string(),
6070            graph_db: graph_db.to_string_lossy().to_string(),
6071            convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6072            status: "ok".to_string(),
6073            fail_closed: false,
6074            checks: Vec::new(),
6075            repair_commands: Vec::new(),
6076            required_indexes: Vec::new(),
6077        }
6078    }
6079
6080    fn push_check(&mut self, check: GraphDbDoctorCheck) {
6081        self.checks.push(check);
6082    }
6083
6084    fn finalize(&mut self) {
6085        self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6086        self.status = if self.fail_closed {
6087            "fail_closed"
6088        } else {
6089            "ok"
6090        }
6091        .to_string();
6092        let mut commands = BTreeSet::new();
6093        for check in &self.checks {
6094            commands.extend(check.repair_commands.iter().cloned());
6095        }
6096        self.repair_commands = commands.into_iter().collect();
6097    }
6098
6099    fn summary(&self) -> String {
6100        self.checks
6101            .iter()
6102            .filter(|check| check.fail_closed)
6103            .flat_map(|check| check.diagnostics.iter())
6104            .take(3)
6105            .cloned()
6106            .collect::<Vec<_>>()
6107            .join("; ")
6108    }
6109}
6110
6111fn graph_db_doctor_check(
6112    name: impl Into<String>,
6113    diagnostics: Vec<String>,
6114    repair_commands: Vec<String>,
6115) -> GraphDbDoctorCheck {
6116    let fail_closed = !diagnostics.is_empty();
6117    GraphDbDoctorCheck {
6118        name: name.into(),
6119        status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6120        fail_closed,
6121        diagnostics,
6122        repair_commands: if fail_closed {
6123            repair_commands
6124        } else {
6125            Vec::new()
6126        },
6127    }
6128}
6129
6130pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6131    scope
6132        .map(|scope| format!(" --scope {}", shell_quote(scope)))
6133        .unwrap_or_default()
6134}
6135
6136fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6137    format!(
6138        "tsift graph-db --path {}{} refresh --json",
6139        shell_quote(root.to_string_lossy().as_ref()),
6140        graph_db_scope_arg(scope)
6141    )
6142}
6143
6144fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6145    graph_db_refresh_command(root, scope)
6146}
6147
6148fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6149    let backup = format!("{}.bak", graph_db.to_string_lossy());
6150    format!(
6151        "mv {} {} && {}",
6152        shell_quote(graph_db.to_string_lossy().as_ref()),
6153        shell_quote(&backup),
6154        graph_db_rebuild_command(root, scope)
6155    )
6156}
6157
6158fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6159    format!(
6160        "tsift convex-sync {}{} --remote-snapshot --apply --json",
6161        shell_quote(root.to_string_lossy().as_ref()),
6162        graph_db_scope_arg(scope)
6163    )
6164}
6165
6166fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6167    substrate::open_graph_read_only_connection_resilient(graph_db)
6168}
6169
6170fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6171    conn.query_row(
6172        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6173        [table],
6174        |row| row.get::<_, bool>(0),
6175    )
6176    .map_err(Into::into)
6177}
6178
6179fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6180    let sql = match table {
6181        "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6182        "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6183        "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6184        other => bail!("unsupported graph count table {other}"),
6185    };
6186    conn.query_row(sql, [], |row| row.get::<_, usize>(0))
6187        .map_err(Into::into)
6188}
6189
6190fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6191    if !sqlite_table_exists(conn, "graph_tombstones")? {
6192        return Ok(GraphDbTombstoneCounts {
6193            nodes: 0,
6194            edges: 0,
6195            total: 0,
6196        });
6197    }
6198    let mut stmt =
6199        conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6200    let mut rows = stmt.query([])?;
6201    let mut nodes = 0usize;
6202    let mut edges = 0usize;
6203    while let Some(row) = rows.next()? {
6204        let row_kind: String = row.get(0)?;
6205        let count: usize = row.get(1)?;
6206        match row_kind.as_str() {
6207            "node" => nodes = count,
6208            "edge" => edges = count,
6209            _ => {}
6210        }
6211    }
6212    Ok(GraphDbTombstoneCounts {
6213        nodes,
6214        edges,
6215        total: nodes + edges,
6216    })
6217}
6218
6219fn sqlite_graph_counts_from_cache(
6220    conn: &Connection,
6221    scope: &str,
6222) -> Result<Option<GraphDbOperatorCounts>> {
6223    if !sqlite_table_exists(conn, "graph_operator_stats")? {
6224        return Ok(None);
6225    }
6226    let row = conn
6227        .query_row(
6228            r#"
6229        SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6230        FROM graph_operator_stats
6231        WHERE scope = ?1
6232        "#,
6233            [scope],
6234            |row| {
6235                Ok((
6236                    row.get::<_, usize>(0)?,
6237                    row.get::<_, usize>(1)?,
6238                    row.get::<_, usize>(2)?,
6239                    row.get::<_, usize>(3)?,
6240                    row.get::<_, Option<i64>>(4)?,
6241                    row.get::<_, Option<i64>>(5)?,
6242                ))
6243            },
6244        )
6245        .optional()?;
6246    Ok(row.map(
6247        |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6248            GraphDbOperatorCounts {
6249                nodes,
6250                edges,
6251                tombstones: GraphDbTombstoneCounts {
6252                    nodes: tombstone_nodes,
6253                    edges: tombstone_edges,
6254                    total: tombstone_nodes + tombstone_edges,
6255                },
6256                file_size_bytes: file_size_bytes
6257                    .and_then(|value| u64::try_from(value).ok())
6258                    .or_else(|| sqlite_database_size_bytes(conn).ok()),
6259                freelist_bytes: freelist_bytes
6260                    .and_then(|value| u64::try_from(value).ok())
6261                    .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6262            }
6263        },
6264    ))
6265}
6266
6267fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6268    if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6269        return Ok(counts);
6270    }
6271    let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6272        sqlite_known_table_count(conn, "graph_nodes")?
6273    } else {
6274        0
6275    };
6276    let edges = if sqlite_table_exists(conn, "graph_edges")? {
6277        sqlite_known_table_count(conn, "graph_edges")?
6278    } else {
6279        0
6280    };
6281    Ok(GraphDbOperatorCounts {
6282        nodes,
6283        edges,
6284        tombstones: sqlite_tombstone_counts(conn)?,
6285        file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6286        freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6287    })
6288}
6289
6290fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6291    if !sqlite_table_exists(conn, "graph_nodes")? {
6292        return Ok(0);
6293    }
6294    let count: i64 = conn.query_row(
6295        "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6296        [],
6297        |row| row.get(0),
6298    )?;
6299    Ok(count as usize)
6300}
6301
6302pub(crate) fn graph_db_compaction_policy(
6303    root: &Path,
6304    scope: Option<&str>,
6305    counts: &GraphDbOperatorCounts,
6306    prune_confirmed: bool,
6307) -> GraphDbCompactionPolicy {
6308    let live_rows = counts.nodes + counts.edges;
6309    let tombstone_scan_rows = counts.tombstones.total;
6310    let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6311    let freelist_heavy = counts
6312        .file_size_bytes
6313        .zip(counts.freelist_bytes)
6314        .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6315    let status = if tombstone_heavy || freelist_heavy {
6316        "recommended"
6317    } else {
6318        "not_needed"
6319    }
6320    .to_string();
6321    let mut recommendations = vec![
6322        convex_refresh_command(root, scope),
6323        graph_db_refresh_command(root, scope),
6324        format!(
6325            "tsift graph-db --path {}{} compact --apply --json",
6326            shell_quote(root.to_string_lossy().as_ref()),
6327            graph_db_scope_arg(scope)
6328        ),
6329    ];
6330    if prune_confirmed {
6331        recommendations.push(format!(
6332            "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6333            shell_quote(root.to_string_lossy().as_ref()),
6334            graph_db_scope_arg(scope)
6335        ));
6336    }
6337    let proof = vec![
6338        format!("{live_rows} live graph row(s)"),
6339        format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
6340        format!(
6341            "graph.db file_size={} byte(s), freelist={} byte(s)",
6342            counts.file_size_bytes.unwrap_or(0),
6343            counts.freelist_bytes.unwrap_or(0)
6344        ),
6345    ];
6346    GraphDbCompactionPolicy {
6347        status,
6348        tombstone_scan_rows,
6349        live_rows,
6350        file_size_bytes: counts.file_size_bytes,
6351        freelist_bytes: counts.freelist_bytes,
6352        safe_to_prune_tombstones: prune_confirmed,
6353        requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
6354        recommendations,
6355        proof,
6356    }
6357}
6358
6359fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
6360    let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
6361    let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6362    Ok(page_count.saturating_mul(page_size))
6363}
6364
6365fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
6366    let freelist_count: u64 = conn.query_row("PRAGMA freelist_count", [], |row| row.get(0))?;
6367    let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6368    Ok(freelist_count.saturating_mul(page_size))
6369}
6370
6371fn sqlite_graph_tombstone_retention_diagnostics(
6372    conn: &Connection,
6373    scope: &str,
6374) -> Result<Vec<String>> {
6375    if !sqlite_table_exists(conn, "graph_tombstones")? {
6376        return Ok(Vec::new());
6377    }
6378    let cached = sqlite_graph_counts_from_cache(conn, scope)?;
6379    let counts = match cached.clone() {
6380        Some(counts) => counts,
6381        None => sqlite_graph_counts(conn, scope)?,
6382    };
6383    let live_rows = counts.nodes + counts.edges;
6384    let file_size = counts.file_size_bytes.unwrap_or(0);
6385    let freelist = counts.freelist_bytes.unwrap_or(0);
6386    let stale_live_tombstones = if cached.is_some() {
6387        0
6388    } else {
6389        let mut live_keys = BTreeSet::new();
6390        if sqlite_table_exists(conn, "graph_nodes")? {
6391            let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
6392            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6393                live_keys.insert(format!("node:{}", row?));
6394            }
6395        }
6396        if sqlite_table_exists(conn, "graph_edges")? {
6397            let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
6398            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6399                live_keys.insert(format!("edge:{}", row?));
6400            }
6401        }
6402        let mut stale_live_tombstones = 0usize;
6403        let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
6404        for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6405            if live_keys.contains(&row?) {
6406                stale_live_tombstones += 1;
6407            }
6408        }
6409        stale_live_tombstones
6410    };
6411
6412    let mut diagnostics = Vec::new();
6413    if stale_live_tombstones > 0 {
6414        diagnostics.push(format!(
6415            "{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"
6416        ));
6417    }
6418    if counts.tombstones.total > live_rows.max(1) {
6419        let source = if cached.is_some() {
6420            "cached refresh stats"
6421        } else {
6422            "live row scan"
6423        };
6424        diagnostics.push(format!(
6425            "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.",
6426            counts.tombstones.total,
6427            live_rows,
6428            source,
6429            file_size,
6430            freelist,
6431            counts.tombstones.total
6432        ));
6433    }
6434    Ok(diagnostics)
6435}
6436
6437fn sqlite_graph_freshness_from_conn(
6438    conn: &Connection,
6439    scope: &str,
6440) -> Result<GraphDbFreshnessReport> {
6441    if !sqlite_table_exists(conn, "graph_projection_versions")? {
6442        return Ok(GraphDbFreshnessReport {
6443            status: "missing".to_string(),
6444            fail_closed: true,
6445            projection_version: None,
6446            content_hash: None,
6447            source_watermark: None,
6448            diagnostics: vec![
6449                "graph projection metadata table is missing; refresh graph.db before trusting reads"
6450                    .to_string(),
6451            ],
6452        });
6453    }
6454    let version = conn
6455        .query_row(
6456            r#"
6457            SELECT projection_version, content_hash, source_watermark
6458            FROM graph_projection_versions
6459            WHERE scope = ?1
6460            "#,
6461            [scope],
6462            |row| {
6463                Ok((
6464                    row.get::<_, String>(0)?,
6465                    row.get::<_, Option<String>>(1)?,
6466                    row.get::<_, Option<String>>(2)?,
6467                ))
6468            },
6469        )
6470        .optional()?;
6471    let Some((projection_version, content_hash, source_watermark)) = version else {
6472        return Ok(GraphDbFreshnessReport {
6473            status: "missing".to_string(),
6474            fail_closed: true,
6475            projection_version: None,
6476            content_hash: None,
6477            source_watermark: None,
6478            diagnostics: vec![
6479                "graph projection metadata is missing; refresh graph.db before trusting reads"
6480                    .to_string(),
6481            ],
6482        });
6483    };
6484
6485    let mut diagnostics = Vec::new();
6486    if projection_version != GRAPH_PROJECTION_VERSION {
6487        diagnostics.push(format!(
6488            "projection version mismatch: expected {} got {}",
6489            GRAPH_PROJECTION_VERSION, projection_version
6490        ));
6491    }
6492    if content_hash.is_none() {
6493        diagnostics.push("projection content hash is missing".to_string());
6494    }
6495    let fail_closed = !diagnostics.is_empty();
6496    Ok(GraphDbFreshnessReport {
6497        status: if fail_closed { "stale" } else { "current" }.to_string(),
6498        fail_closed,
6499        projection_version: Some(projection_version),
6500        content_hash,
6501        source_watermark,
6502        diagnostics,
6503    })
6504}
6505
6506fn graph_db_operator_next_commands(
6507    root: &Path,
6508    scope: Option<&str>,
6509    include_refresh: bool,
6510) -> Vec<String> {
6511    let mut commands = Vec::new();
6512    if include_refresh {
6513        commands.push(graph_db_refresh_command(root, scope));
6514    }
6515    commands.push(format!(
6516        "tsift graph-db --path {}{} doctor --json",
6517        shell_quote(root.to_string_lossy().as_ref()),
6518        graph_db_scope_arg(scope)
6519    ));
6520    commands.push(format!(
6521        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
6522        shell_quote(root.to_string_lossy().as_ref()),
6523        graph_db_scope_arg(scope)
6524    ));
6525    commands.push(format!(
6526        "tsift convex-sync {}{} --remote-snapshot --apply --json",
6527        shell_quote(root.to_string_lossy().as_ref()),
6528        graph_db_scope_arg(scope)
6529    ));
6530    commands
6531}
6532
6533pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
6534    match recovery {
6535        index::ReadOnlyRecovery::SnapshotFallback => {
6536            "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
6537        }
6538        index::ReadOnlyRecovery::SnapshotFallbackWal => {
6539            "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
6540        }
6541    }
6542}
6543
6544fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
6545    let mut stmt = conn.prepare(sql)?;
6546    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6547    let mut values = BTreeSet::new();
6548    for row in rows {
6549        values.insert(row?);
6550    }
6551    Ok(values)
6552}
6553
6554fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
6555    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
6556    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
6557    let mut columns = BTreeSet::new();
6558    for row in rows {
6559        columns.insert(row?);
6560    }
6561    Ok(columns)
6562}
6563
6564fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6565    let mut diagnostics = Vec::new();
6566    let user_version: i64 =
6567        conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
6568    if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
6569        diagnostics.push(format!(
6570            "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6571        ));
6572    } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
6573        diagnostics.push(format!(
6574            "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6575        ));
6576    }
6577
6578    let tables = sqlite_string_set(
6579        conn,
6580        "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
6581    )?;
6582    let required_tables = [
6583        (
6584            "graph_nodes",
6585            vec![
6586                "id",
6587                "kind",
6588                "label",
6589                "properties_json",
6590                "provenance_json",
6591                "freshness_json",
6592                "row_hash",
6593                "source_watermark",
6594            ],
6595        ),
6596        (
6597            "graph_edges",
6598            vec![
6599                "edge_key",
6600                "from_id",
6601                "to_id",
6602                "kind",
6603                "properties_json",
6604                "provenance_json",
6605                "freshness_json",
6606                "row_hash",
6607                "source_watermark",
6608            ],
6609        ),
6610        (
6611            "graph_projection_versions",
6612            vec![
6613                "scope",
6614                "projection_version",
6615                "content_hash",
6616                "source_watermark",
6617                "observed_at_unix",
6618            ],
6619        ),
6620        (
6621            "graph_tombstones",
6622            vec!["row_key", "row_kind", "deleted_at_unix"],
6623        ),
6624        ("graph_node_properties", vec!["node_id", "key", "value"]),
6625        ("graph_edge_properties", vec!["edge_key", "key", "value"]),
6626    ];
6627    for (table, required_columns) in required_tables {
6628        if !tables.contains(table) {
6629            diagnostics.push(format!("graph.db schema drift: missing table {table}"));
6630            continue;
6631        }
6632        let columns = sqlite_column_names(conn, table)?;
6633        for column in required_columns {
6634            if !columns.contains(column) {
6635                diagnostics.push(format!(
6636                    "graph.db schema drift: missing column {table}.{column}"
6637                ));
6638            }
6639        }
6640    }
6641
6642    let indexes = sqlite_string_set(
6643        conn,
6644        "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
6645    )?;
6646    for index in [
6647        "idx_graph_nodes_kind",
6648        "idx_graph_edges_from_kind",
6649        "idx_graph_edges_to_kind",
6650        "idx_graph_edges_edge_key",
6651        "idx_graph_node_properties_key_value_node",
6652        "idx_graph_edge_properties_key_value_edge",
6653    ] {
6654        if !indexes.contains(index) {
6655            diagnostics.push(format!("graph.db schema drift: missing index {index}"));
6656        }
6657    }
6658
6659    if tables.contains("graph_edges") {
6660        let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
6661        let rows = stmt.query_map([], |row| {
6662            Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
6663        })?;
6664        let mut fks = BTreeSet::new();
6665        for row in rows {
6666            fks.insert(row?);
6667        }
6668        for expected in [
6669            ("from_id".to_string(), "id".to_string()),
6670            ("to_id".to_string(), "id".to_string()),
6671        ] {
6672            if !fks.contains(&expected) {
6673                diagnostics.push(format!(
6674                    "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
6675                    expected.0, expected.1
6676                ));
6677            }
6678        }
6679    }
6680
6681    Ok(diagnostics)
6682}
6683
6684fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
6685    let mut stmt = conn.prepare(sql)?;
6686    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6687    let mut diagnostics = Vec::new();
6688    for row in rows {
6689        diagnostics.push(row?);
6690    }
6691    Ok(diagnostics)
6692}
6693
6694fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6695    let mut diagnostics = sqlite_query_diagnostics(
6696        conn,
6697        r#"
6698        SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
6699        FROM graph_nodes
6700        GROUP BY id
6701        HAVING COUNT(*) > 1
6702        ORDER BY id
6703        "#,
6704    )?;
6705    diagnostics.extend(sqlite_query_diagnostics(
6706        conn,
6707        r#"
6708        SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
6709        FROM graph_edges
6710        GROUP BY from_id, to_id, kind
6711        HAVING COUNT(*) > 1
6712        ORDER BY from_id, kind, to_id
6713        "#,
6714    )?);
6715    diagnostics.extend(sqlite_query_diagnostics(
6716        conn,
6717        r#"
6718        SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
6719        FROM graph_edges
6720        GROUP BY edge_key
6721        HAVING COUNT(*) > 1
6722        ORDER BY edge_key
6723        "#,
6724    )?);
6725    Ok(diagnostics)
6726}
6727
6728fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6729    sqlite_query_diagnostics(
6730        conn,
6731        r#"
6732        SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6733        FROM graph_edges e
6734        LEFT JOIN graph_nodes n ON n.id = e.from_id
6735        WHERE n.id IS NULL
6736        UNION ALL
6737        SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6738        FROM graph_edges e
6739        LEFT JOIN graph_nodes n ON n.id = e.to_id
6740        WHERE n.id IS NULL
6741        ORDER BY 1
6742        "#,
6743    )
6744}
6745
6746fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6747    let mut diagnostics = Vec::new();
6748    let mut node_stmt = conn.prepare(
6749        "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6750    )?;
6751    let node_rows = node_stmt.query_map([], |row| {
6752        Ok((
6753            row.get::<_, String>(0)?,
6754            row.get::<_, String>(1)?,
6755            row.get::<_, String>(2)?,
6756            row.get::<_, Option<String>>(3)?,
6757        ))
6758    })?;
6759    for row in node_rows {
6760        let (id, properties_json, provenance_json, freshness_json) = row?;
6761        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6762            diagnostics.push(format!(
6763                "graph_nodes {id} properties_json is invalid: {err}"
6764            ));
6765        }
6766        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6767            diagnostics.push(format!(
6768                "graph_nodes {id} provenance_json is invalid: {err}"
6769            ));
6770        }
6771        if let Some(freshness_json) = freshness_json
6772            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6773        {
6774            diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
6775        }
6776    }
6777
6778    let mut edge_stmt = conn.prepare(
6779        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6780    )?;
6781    let edge_rows = edge_stmt.query_map([], |row| {
6782        Ok((
6783            row.get::<_, String>(0)?,
6784            row.get::<_, String>(1)?,
6785            row.get::<_, String>(2)?,
6786            row.get::<_, String>(3)?,
6787            row.get::<_, String>(4)?,
6788            row.get::<_, String>(5)?,
6789            row.get::<_, Option<String>>(6)?,
6790        ))
6791    })?;
6792    for row in edge_rows {
6793        let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
6794            row?;
6795        let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
6796        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6797            diagnostics.push(format!(
6798                "graph_edges {edge} properties_json is invalid: {err}"
6799            ));
6800        }
6801        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6802            diagnostics.push(format!(
6803                "graph_edges {edge} provenance_json is invalid: {err}"
6804            ));
6805        }
6806        if let Some(freshness_json) = freshness_json
6807            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6808        {
6809            diagnostics.push(format!(
6810                "graph_edges {edge} freshness_json is invalid: {err}"
6811            ));
6812        }
6813    }
6814    Ok(diagnostics)
6815}
6816
6817fn sqlite_graph_projection_metadata_diagnostics(
6818    conn: &Connection,
6819    scope: Option<&str>,
6820) -> Result<Vec<String>> {
6821    let mut diagnostics = Vec::new();
6822    let scope_key = scope.unwrap_or("root");
6823    let version = conn
6824        .query_row(
6825            r#"
6826            SELECT projection_version, content_hash, source_watermark
6827            FROM graph_projection_versions
6828            WHERE scope = ?1
6829            "#,
6830            [scope_key],
6831            |row| {
6832                Ok((
6833                    row.get::<_, String>(0)?,
6834                    row.get::<_, Option<String>>(1)?,
6835                    row.get::<_, Option<String>>(2)?,
6836                ))
6837            },
6838        )
6839        .optional()?;
6840    let Some((projection_version, content_hash, _source_watermark)) = version else {
6841        diagnostics.push(format!(
6842            "graph projection metadata is missing for scope {scope_key}"
6843        ));
6844        return Ok(diagnostics);
6845    };
6846    if projection_version != GRAPH_PROJECTION_VERSION {
6847        diagnostics.push(format!(
6848            "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
6849        ));
6850    }
6851    if content_hash.is_none() {
6852        diagnostics.push("projection content hash is missing".to_string());
6853    }
6854
6855    let meta_id = graph_projection_meta_id(scope);
6856    let meta_properties = conn
6857        .query_row(
6858            "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
6859            (&meta_id, GRAPH_PROJECTION_META_KIND),
6860            |row| row.get::<_, String>(0),
6861        )
6862        .optional()?;
6863    let Some(meta_properties) = meta_properties else {
6864        diagnostics.push(format!("projection_meta node {meta_id} is missing"));
6865        return Ok(diagnostics);
6866    };
6867    let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
6868        .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
6869    if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
6870        diagnostics.push(format!(
6871            "projection_meta node {meta_id} has stale projection_version"
6872        ));
6873    }
6874    if properties.get("content_hash") != content_hash.as_ref() {
6875        diagnostics.push(format!(
6876            "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
6877        ));
6878    }
6879    Ok(diagnostics)
6880}
6881
6882pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
6883    let mut node_stmt = conn.prepare(
6884        "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6885    )?;
6886    let node_rows = node_stmt.query_map([], |row| {
6887        let properties_json: String = row.get(3)?;
6888        let provenance_json: String = row.get(4)?;
6889        let freshness_json: Option<String> = row.get(5)?;
6890        Ok((
6891            row.get::<_, String>(0)?,
6892            row.get::<_, String>(1)?,
6893            row.get::<_, String>(2)?,
6894            properties_json,
6895            provenance_json,
6896            freshness_json,
6897        ))
6898    })?;
6899    let mut nodes = Vec::new();
6900    for row in node_rows {
6901        let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
6902        nodes.push(ConvexNodeRow {
6903            external_id,
6904            kind,
6905            label,
6906            properties: serde_json::from_str(&properties_json)?,
6907            provenance: serde_json::from_str(&provenance_json)?,
6908            freshness: freshness_json
6909                .map(|value| serde_json::from_str(&value))
6910                .transpose()?,
6911        });
6912    }
6913
6914    let mut edge_stmt = conn.prepare(
6915        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6916    )?;
6917    let edge_rows = edge_stmt.query_map([], |row| {
6918        let properties_json: String = row.get(4)?;
6919        let provenance_json: String = row.get(5)?;
6920        let freshness_json: Option<String> = row.get(6)?;
6921        Ok((
6922            row.get::<_, String>(0)?,
6923            row.get::<_, String>(1)?,
6924            row.get::<_, String>(2)?,
6925            row.get::<_, String>(3)?,
6926            properties_json,
6927            provenance_json,
6928            freshness_json,
6929        ))
6930    })?;
6931    let mut edges = Vec::new();
6932    for row in edge_rows {
6933        let (
6934            edge_key,
6935            from_external_id,
6936            to_external_id,
6937            kind,
6938            properties_json,
6939            provenance_json,
6940            freshness_json,
6941        ) = row?;
6942        edges.push(ConvexEdgeRow {
6943            edge_key,
6944            from_external_id,
6945            to_external_id,
6946            kind,
6947            properties: serde_json::from_str(&properties_json)?,
6948            provenance: serde_json::from_str(&provenance_json)?,
6949            freshness: freshness_json
6950                .map(|value| serde_json::from_str(&value))
6951                .transpose()?,
6952        });
6953    }
6954    Ok(ConvexProjectionRows { nodes, edges })
6955}
6956
6957fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
6958    format!("{}.{}({})", index.table, index.name, index.fields.join(","))
6959}
6960
6961fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
6962    value
6963        .get("indexes")
6964        .or_else(|| value.get("requiredIndexes"))
6965        .or_else(|| {
6966            value
6967                .get("metadata")
6968                .and_then(|metadata| metadata.get("indexes"))
6969        })
6970}
6971
6972fn convex_snapshot_declared_indexes(
6973    value: &serde_json::Value,
6974) -> Result<Option<Vec<ConvexRequiredIndex>>> {
6975    convex_snapshot_index_value(value)
6976        .map(|indexes| {
6977            serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
6978                .context("parsing Convex snapshot index metadata")
6979        })
6980        .transpose()
6981}
6982
6983fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
6984    let required = convex_required_indexes();
6985    let Some(declared) = convex_snapshot_declared_indexes(value)? else {
6986        return Ok(vec![format!(
6987            "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
6988            required
6989                .iter()
6990                .map(convex_required_index_label)
6991                .collect::<Vec<_>>()
6992                .join(", ")
6993        )]);
6994    };
6995    let declared = declared.into_iter().collect::<BTreeSet<_>>();
6996    let missing = required
6997        .iter()
6998        .filter(|index| !declared.contains(*index))
6999        .map(convex_required_index_label)
7000        .collect::<Vec<_>>();
7001    if missing.is_empty() {
7002        Ok(Vec::new())
7003    } else {
7004        Ok(vec![format!(
7005            "Convex snapshot is missing required index metadata: {}",
7006            missing.join(", ")
7007        )])
7008    }
7009}
7010
7011pub(crate) fn load_convex_projection_snapshot_value(
7012    snapshot_path: &Path,
7013) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7014    let content = fs::read_to_string(snapshot_path).with_context(|| {
7015        format!(
7016            "reading Convex projection snapshot {}",
7017            snapshot_path.display()
7018        )
7019    })?;
7020    let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7021        format!(
7022            "parsing Convex projection snapshot {}",
7023            snapshot_path.display()
7024        )
7025    })?;
7026    let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7027        .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7028    Ok((rows, value))
7029}
7030
7031pub(crate) fn append_sqlite_graph_doctor_checks(
7032    report: &mut GraphDbDoctorReport,
7033    root: &Path,
7034    scope: Option<&str>,
7035    graph_db: &Path,
7036) -> Option<substrate::SqliteReadOnlyConnection> {
7037    let rebuild = graph_db_rebuild_command(root, scope);
7038    let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7039    if !graph_db.exists() {
7040        report.push_check(graph_db_doctor_check(
7041            "sqlite_graph_db_exists",
7042            vec![format!("graph.db is missing at {}", graph_db.display())],
7043            vec![rebuild],
7044        ));
7045        return None;
7046    }
7047    report.push_check(graph_db_doctor_check(
7048        "sqlite_graph_db_exists",
7049        Vec::new(),
7050        vec![rebuild.clone()],
7051    ));
7052
7053    let conn = match open_sqlite_graph_db_readonly(graph_db) {
7054        Ok(conn) => conn,
7055        Err(err) => {
7056            report.push_check(graph_db_doctor_check(
7057                "sqlite_graph_db_open",
7058                vec![err.to_string()],
7059                vec![backup_rebuild],
7060            ));
7061            return None;
7062        }
7063    };
7064    report.push_check(graph_db_doctor_check(
7065        "sqlite_graph_db_open",
7066        Vec::new(),
7067        vec![rebuild.clone()],
7068    ));
7069    if let Some(recovery) = conn.recovery() {
7070        report.push_check(GraphDbDoctorCheck {
7071            name: "sqlite_graph_db_read_recovery".to_string(),
7072            status: "recovered".to_string(),
7073            fail_closed: false,
7074            diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7075            repair_commands: Vec::new(),
7076        });
7077    }
7078
7079    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7080        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7081    report.push_check(graph_db_doctor_check(
7082        "sqlite_schema",
7083        schema_diagnostics,
7084        vec![backup_rebuild.clone()],
7085    ));
7086
7087    let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7088        .unwrap_or_else(|err| {
7089            vec![format!(
7090                "graph projection metadata inspection failed: {err}"
7091            )]
7092        });
7093    report.push_check(graph_db_doctor_check(
7094        "sqlite_projection_metadata",
7095        metadata_diagnostics,
7096        vec![rebuild.clone()],
7097    ));
7098
7099    let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7100        .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7101    report.push_check(graph_db_doctor_check(
7102        "sqlite_duplicate_ids",
7103        duplicate_diagnostics,
7104        vec![backup_rebuild.clone()],
7105    ));
7106
7107    let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7108        .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7109    report.push_check(graph_db_doctor_check(
7110        "sqlite_orphan_edges",
7111        orphan_diagnostics,
7112        vec![rebuild.clone()],
7113    ));
7114
7115    let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7116        .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7117    report.push_check(graph_db_doctor_check(
7118        "sqlite_row_json",
7119        json_diagnostics,
7120        vec![backup_rebuild],
7121    ));
7122
7123    let tombstone_diagnostics =
7124        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7125            .unwrap_or_else(|err| {
7126                vec![format!(
7127                    "graph tombstone retention inspection failed: {err}"
7128                )]
7129            });
7130    report.push_check(GraphDbDoctorCheck {
7131        name: "sqlite_tombstone_retention".to_string(),
7132        status: if tombstone_diagnostics.is_empty() {
7133            "ok".to_string()
7134        } else {
7135            "warning".to_string()
7136        },
7137        fail_closed: false,
7138        diagnostics: tombstone_diagnostics,
7139        repair_commands: Vec::new(),
7140    });
7141    let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7142        Ok(counts) => {
7143            let policy = graph_db_compaction_policy(root, scope, &counts, false);
7144            GraphDbDoctorCheck {
7145                name: "sqlite_compaction_policy".to_string(),
7146                status: policy.status.clone(),
7147                fail_closed: false,
7148                diagnostics: policy.proof,
7149                repair_commands: if policy.status == "recommended" {
7150                    policy.recommendations
7151                } else {
7152                    Vec::new()
7153                },
7154            }
7155        }
7156        Err(err) => GraphDbDoctorCheck {
7157            name: "sqlite_compaction_policy".to_string(),
7158            status: "warning".to_string(),
7159            fail_closed: false,
7160            diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7161            repair_commands: Vec::new(),
7162        },
7163    };
7164    report.push_check(compaction_check);
7165
7166    Some(conn)
7167}
7168
7169pub(crate) fn append_convex_snapshot_doctor_checks(
7170    report: &mut GraphDbDoctorReport,
7171    root: &Path,
7172    scope: Option<&str>,
7173    local_rows: Option<&ConvexProjectionRows>,
7174    snapshot_path: Option<&Path>,
7175) {
7176    let repair = convex_refresh_command(root, scope);
7177    let Some(snapshot_path) = snapshot_path else {
7178        report.push_check(graph_db_doctor_check(
7179            "convex_snapshot_present",
7180            vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7181            vec![format!(
7182                "tsift convex-sync {}{} --json > convex-rows.json",
7183                shell_quote(root.to_string_lossy().as_ref()),
7184                graph_db_scope_arg(scope)
7185            )],
7186        ));
7187        return;
7188    };
7189    report.push_check(graph_db_doctor_check(
7190        "convex_snapshot_present",
7191        Vec::new(),
7192        vec![repair.clone()],
7193    ));
7194
7195    let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7196        Ok(snapshot) => snapshot,
7197        Err(err) => {
7198            report.push_check(graph_db_doctor_check(
7199                "convex_snapshot_parse",
7200                vec![err.to_string()],
7201                vec![repair],
7202            ));
7203            return;
7204        }
7205    };
7206    report.push_check(graph_db_doctor_check(
7207        "convex_snapshot_parse",
7208        Vec::new(),
7209        vec![repair.clone()],
7210    ));
7211
7212    let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7213    report.push_check(graph_db_doctor_check(
7214        "convex_snapshot_rows",
7215        row_diagnostics,
7216        vec![repair.clone()],
7217    ));
7218
7219    let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7220        .unwrap_or_else(|err| vec![err.to_string()]);
7221    report.required_indexes = convex_required_indexes();
7222    report.push_check(graph_db_doctor_check(
7223        "convex_required_indexes",
7224        index_diagnostics,
7225        vec![
7226            "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7227                .to_string(),
7228        ],
7229    ));
7230
7231    if let Some(local_rows) = local_rows {
7232        let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7233        report.push_check(graph_db_doctor_check(
7234            "convex_projection_freshness",
7235            freshness.diagnostics,
7236            vec![repair],
7237        ));
7238    } else {
7239        report.push_check(graph_db_doctor_check(
7240            "convex_projection_freshness",
7241            vec![
7242                "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7243                    .to_string(),
7244            ],
7245            vec![graph_db_rebuild_command(root, scope)],
7246        ));
7247    }
7248}
7249
7250fn graph_db_convex_snapshot_doctor_command(
7251    root: &Path,
7252    scope: Option<&str>,
7253    snapshot_path: &Path,
7254) -> String {
7255    format!(
7256        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7257        shell_quote(root.to_string_lossy().as_ref()),
7258        graph_db_scope_arg(scope),
7259        shell_quote(snapshot_path.to_string_lossy().as_ref())
7260    )
7261}
7262
7263fn graph_db_convex_snapshot_read_command(
7264    root: &Path,
7265    scope: Option<&str>,
7266    snapshot_path: &Path,
7267) -> String {
7268    format!(
7269        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7270        shell_quote(root.to_string_lossy().as_ref()),
7271        graph_db_scope_arg(scope),
7272        shell_quote(snapshot_path.to_string_lossy().as_ref())
7273    )
7274}
7275
7276fn convex_sync_snapshot_diff_command(
7277    root: &Path,
7278    scope: Option<&str>,
7279    snapshot_path: &Path,
7280) -> String {
7281    format!(
7282        "tsift convex-sync {}{} --snapshot {} --json",
7283        shell_quote(root.to_string_lossy().as_ref()),
7284        graph_db_scope_arg(scope),
7285        shell_quote(snapshot_path.to_string_lossy().as_ref())
7286    )
7287}
7288
7289pub(crate) struct GraphDbDriftInput<'a> {
7290    root: &'a Path,
7291    scope: Option<&'a str>,
7292    graph_db: &'a Path,
7293    snapshot_path: &'a Path,
7294    local: &'a ConvexProjectionRows,
7295    snapshot: &'a ConvexProjectionRows,
7296    snapshot_value: &'a serde_json::Value,
7297    warnings: Vec<String>,
7298}
7299
7300pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7301    let GraphDbDriftInput {
7302        root,
7303        scope,
7304        graph_db,
7305        snapshot_path,
7306        local,
7307        snapshot,
7308        snapshot_value,
7309        warnings,
7310    } = input;
7311    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7312    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7313        convex_rows_diff(local, Some(snapshot));
7314    let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7315    let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7316        .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7317    let local_hash = freshness.local_hash.clone();
7318    let snapshot_hash = freshness.snapshot_hash.clone();
7319    let stale_nodes = freshness.stale_nodes.clone();
7320    let stale_edges = freshness.stale_edges.clone();
7321
7322    let duplicate_failures = row_diagnostics
7323        .iter()
7324        .filter(|diagnostic| diagnostic.contains("duplicate"))
7325        .count();
7326    let orphan_failures = row_diagnostics
7327        .iter()
7328        .filter(|diagnostic| diagnostic.contains("references missing"))
7329        .count();
7330    let missing_required_indexes = index_diagnostics.len();
7331    let stale_projection_metadata =
7332        usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7333    let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7334    let has_drift = freshness.fail_closed
7335        || !node_upserts.is_empty()
7336        || !edge_upserts.is_empty()
7337        || !node_tombstones.is_empty()
7338        || !edge_tombstones.is_empty();
7339    let status = if hard_failures > 0 {
7340        "fail_closed"
7341    } else if has_drift {
7342        "drift"
7343    } else {
7344        "current"
7345    }
7346    .to_string();
7347
7348    let mut diagnostics = Vec::new();
7349    diagnostics.extend(row_diagnostics);
7350    diagnostics.extend(index_diagnostics);
7351    diagnostics.extend(freshness.diagnostics.clone());
7352    if has_drift {
7353        diagnostics.push(format!(
7354            "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
7355            node_upserts.len(),
7356            edge_upserts.len(),
7357            node_tombstones.len(),
7358            edge_tombstones.len()
7359        ));
7360    }
7361
7362    let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
7363        root,
7364        scope,
7365        snapshot_path,
7366    )];
7367    if status == "current" {
7368        next_commands.push(graph_db_convex_snapshot_read_command(
7369            root,
7370            scope,
7371            snapshot_path,
7372        ));
7373    } else {
7374        next_commands.push(convex_sync_snapshot_diff_command(
7375            root,
7376            scope,
7377            snapshot_path,
7378        ));
7379        next_commands.push(convex_refresh_command(root, scope));
7380    }
7381
7382    GraphDbDriftReport {
7383        root: root.to_string_lossy().to_string(),
7384        scope: scope.map(str::to_string),
7385        graph_db: graph_db.to_string_lossy().to_string(),
7386        convex_snapshot: snapshot_path.to_string_lossy().to_string(),
7387        status: status.clone(),
7388        graph_reads_allowed: status == "current",
7389        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
7390        local_hash,
7391        snapshot_hash,
7392        summary: GraphDbDriftSummary {
7393            node_upserts: node_upserts.len(),
7394            edge_upserts: edge_upserts.len(),
7395            node_tombstones: node_tombstones.len(),
7396            edge_tombstones: edge_tombstones.len(),
7397            stale_nodes: stale_nodes.len(),
7398            stale_edges: stale_edges.len(),
7399            stale_projection_metadata,
7400            duplicate_failures,
7401            orphan_failures,
7402            missing_required_indexes,
7403        },
7404        node_upserts: node_upserts
7405            .into_iter()
7406            .map(|row| row.external_id)
7407            .collect(),
7408        edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
7409        node_tombstones,
7410        edge_tombstones,
7411        stale_nodes,
7412        stale_edges,
7413        diagnostics,
7414        next_commands,
7415        required_indexes: convex_required_indexes(),
7416        warnings,
7417    }
7418}
7419
7420pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
7421    println!(
7422        "graph-db drift status: {} reads_allowed: {}",
7423        report.status, report.graph_reads_allowed
7424    );
7425    println!("graph_db: {}", report.graph_db);
7426    println!("convex_snapshot: {}", report.convex_snapshot);
7427    println!(
7428        "upserts: {} node(s), {} edge(s)",
7429        report.summary.node_upserts, report.summary.edge_upserts
7430    );
7431    println!(
7432        "tombstones: {} node(s), {} edge(s)",
7433        report.summary.node_tombstones, report.summary.edge_tombstones
7434    );
7435    for diagnostic in &report.diagnostics {
7436        println!("diagnostic: {diagnostic}");
7437    }
7438    for command in &report.next_commands {
7439        println!("next: {command}");
7440    }
7441}
7442
7443pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
7444    println!(
7445        "graph-db doctor backend: {} status: {}",
7446        report.backend, report.status
7447    );
7448    println!("graph_db: {}", report.graph_db);
7449    if let Some(snapshot) = &report.convex_snapshot {
7450        println!("convex_snapshot: {snapshot}");
7451    }
7452    for check in &report.checks {
7453        println!("check: {} {}", check.name, check.status);
7454        for diagnostic in &check.diagnostics {
7455            println!("  diagnostic: {diagnostic}");
7456        }
7457    }
7458    for command in &report.repair_commands {
7459        println!("repair: {command}");
7460    }
7461}
7462
7463pub(crate) fn graph_db_operator_report_from_disk(
7464    root: &Path,
7465    scope: Option<&str>,
7466    graph_db: &Path,
7467    operation: &str,
7468    refresh: Option<GraphDbRefreshSummary>,
7469    warnings: Vec<String>,
7470) -> Result<GraphDbOperatorReport> {
7471    if !graph_db.exists() {
7472        let next_commands = graph_db_operator_next_commands(root, scope, true);
7473        let counts = GraphDbOperatorCounts {
7474            nodes: 0,
7475            edges: 0,
7476            tombstones: GraphDbTombstoneCounts {
7477                nodes: 0,
7478                edges: 0,
7479                total: 0,
7480            },
7481            file_size_bytes: None,
7482            freelist_bytes: None,
7483        };
7484        return Ok(GraphDbOperatorReport {
7485            root: root.to_string_lossy().to_string(),
7486            scope: scope.map(str::to_string),
7487            graph_db: graph_db.to_string_lossy().to_string(),
7488            operation: operation.to_string(),
7489            status: "missing".to_string(),
7490            materialized: false,
7491            freshness: GraphDbFreshnessReport {
7492                status: "missing".to_string(),
7493                fail_closed: true,
7494                projection_version: None,
7495                content_hash: None,
7496                source_watermark: None,
7497                diagnostics: vec![
7498                    "graph.db is missing; run graph-db refresh before trusting graph reads"
7499                        .to_string(),
7500                ],
7501            },
7502            readiness: graph_effectiveness_blocked(
7503                "graph_db_missing",
7504                vec![
7505                    "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
7506                ],
7507                next_commands.clone(),
7508            ),
7509            counts: counts.clone(),
7510            refresh,
7511            compaction: graph_db_compaction_policy(root, scope, &counts, false),
7512            recovery: None,
7513            next_commands,
7514            warnings,
7515        });
7516    }
7517
7518    let conn = open_sqlite_graph_db_readonly(graph_db)?;
7519    let recovery = conn.recovery();
7520    let mut warnings = warnings;
7521    if let Some(recovery) = recovery {
7522        warnings.push(graph_db_read_recovery_diagnostic(recovery));
7523    }
7524    let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
7525    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7526        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7527    if !schema_diagnostics.is_empty() {
7528        freshness.diagnostics.extend(schema_diagnostics);
7529        freshness.fail_closed = true;
7530        freshness.status = "stale".to_string();
7531    }
7532    let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
7533    let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
7534    warnings.extend(
7535        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7536            .unwrap_or_else(|err| {
7537                vec![format!(
7538                    "graph tombstone retention inspection failed: {err}"
7539                )]
7540            }),
7541    );
7542    let status = if freshness.fail_closed {
7543        "stale"
7544    } else {
7545        "current"
7546    }
7547    .to_string();
7548
7549    Ok(GraphDbOperatorReport {
7550        root: root.to_string_lossy().to_string(),
7551        scope: scope.map(str::to_string),
7552        graph_db: graph_db.to_string_lossy().to_string(),
7553        operation: operation.to_string(),
7554        status,
7555        materialized: true,
7556        freshness,
7557        readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
7558        compaction: graph_db_compaction_policy(root, scope, &counts, false),
7559        counts,
7560        refresh,
7561        recovery,
7562        next_commands: graph_db_operator_next_commands(root, scope, false),
7563        warnings,
7564    })
7565}
7566
7567fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
7568    println!(
7569        "graph-db {} status: {} materialized: {}",
7570        report.operation, report.status, report.materialized
7571    );
7572    println!("graph_db: {}", report.graph_db);
7573    println!(
7574        "projection: version={} hash={} watermark={}",
7575        report
7576            .freshness
7577            .projection_version
7578            .as_deref()
7579            .unwrap_or("<missing>"),
7580        report
7581            .freshness
7582            .content_hash
7583            .as_deref()
7584            .unwrap_or("<missing>"),
7585        report
7586            .freshness
7587            .source_watermark
7588            .as_deref()
7589            .unwrap_or("<missing>")
7590    );
7591    println!(
7592        "rows: {} node(s), {} edge(s), {} tombstone(s)",
7593        report.counts.nodes, report.counts.edges, report.counts.tombstones.total
7594    );
7595    println!(
7596        "readiness: {} reason: {} fail_closed: {}",
7597        report.readiness.status, report.readiness.reason, report.readiness.fail_closed
7598    );
7599    if let Some(file_size) = report.counts.file_size_bytes {
7600        println!(
7601            "storage: {} byte(s), {} free byte(s)",
7602            file_size,
7603            report.counts.freelist_bytes.unwrap_or(0)
7604        );
7605    }
7606    if let Some(refresh) = &report.refresh {
7607        println!(
7608            "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
7609            refresh.tombstoned_nodes, refresh.tombstoned_edges
7610        );
7611        println!(
7612            "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)",
7613            refresh.upserted_nodes,
7614            refresh.upserted_edges,
7615            refresh.upserted_properties,
7616            refresh.unchanged_nodes,
7617            refresh.unchanged_edges,
7618            refresh.unchanged_properties,
7619            refresh.deleted_properties,
7620            refresh.pruned_tombstones
7621        );
7622    }
7623    println!(
7624        "compaction: {} tombstone_scan_rows={} live_rows={}",
7625        report.compaction.status,
7626        report.compaction.tombstone_scan_rows,
7627        report.compaction.live_rows
7628    );
7629    for proof in &report.compaction.proof {
7630        println!("compaction proof: {proof}");
7631    }
7632    if let Some(recovery) = report.recovery {
7633        println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
7634    }
7635    for diagnostic in &report.freshness.diagnostics {
7636        println!("diagnostic: {diagnostic}");
7637    }
7638    for diagnostic in &report.readiness.diagnostics {
7639        println!("readiness diagnostic: {diagnostic}");
7640    }
7641    for warning in &report.warnings {
7642        println!("warning: {warning}");
7643    }
7644    for command in &report.readiness.next_commands {
7645        println!("readiness next: {command}");
7646    }
7647    for command in &report.next_commands {
7648        println!("next: {command}");
7649    }
7650}
7651
7652pub(crate) fn print_graph_db_operator_report(
7653    report: &GraphDbOperatorReport,
7654    format: OutputFormat,
7655) -> Result<()> {
7656    if format.json_output {
7657        print_json_or_envelope(
7658            report,
7659            &format,
7660            "graph-db",
7661            &report.operation,
7662            ToolEnvelopeSummary {
7663                text: format!(
7664                    "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
7665                    report.operation,
7666                    report.status,
7667                    report.counts.nodes,
7668                    report.counts.edges,
7669                    report.counts.tombstones.total
7670                ),
7671                metrics: vec![
7672                    envelope_metric("operation", &report.operation),
7673                    envelope_metric("status", &report.status),
7674                    envelope_metric("nodes", report.counts.nodes),
7675                    envelope_metric("edges", report.counts.edges),
7676                    envelope_metric("tombstones", report.counts.tombstones.total),
7677                    envelope_metric("compaction", &report.compaction.status),
7678                    envelope_metric("readiness", &report.readiness.status),
7679                ],
7680            },
7681            false,
7682            report.next_commands.clone(),
7683        )
7684    } else {
7685        print_graph_db_operator_human(report);
7686        Ok(())
7687    }
7688}
7689
7690fn status_run_command_without_notes(run: &str) -> &str {
7691    run.split_once("  (")
7692        .map(|(command, _)| command)
7693        .unwrap_or(run)
7694}
7695
7696fn status_summarize_extract_command(run: &str) -> &str {
7697    let run = status_run_command_without_notes(run);
7698    run.split(" && ")
7699        .find(|command| command.contains("summarize --extract"))
7700        .unwrap_or(run)
7701}
7702
7703fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
7704    report
7705        .recommendations
7706        .run
7707        .as_deref()
7708        .filter(|command| command.contains("summarize --extract"))
7709        .map(status_summarize_extract_command)
7710        .unwrap_or("tsift summarize --extract .")
7711        .to_string()
7712}
7713
7714fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
7715    let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7716    readiness.diagnostics.push(format!(
7717        "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
7718    ));
7719    readiness
7720}
7721
7722fn graph_db_semantic_readiness(
7723    root: &Path,
7724    scope: Option<&str>,
7725    semantic_row_count: Option<usize>,
7726) -> GraphEffectivenessReadiness {
7727    if let Some(row_count) = semantic_row_count
7728        && row_count > 0
7729    {
7730        return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
7731    }
7732
7733    let report = match status::check_status(root) {
7734        Ok(report) => report,
7735        Err(err) => {
7736            return graph_effectiveness_blocked(
7737                "status_check_unavailable",
7738                vec![format!(
7739                    "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
7740                )],
7741                vec![graph_db_refresh_command(root, scope)],
7742            );
7743        }
7744    };
7745
7746    match &report.summaries {
7747        status::SummaryStatus::Available {
7748            cached_files,
7749            total_indexed_files,
7750            coverage_pct,
7751            ..
7752        } => {
7753            let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7754            readiness.diagnostics.push(format!(
7755                "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
7756            ));
7757            readiness
7758        }
7759        status::SummaryStatus::None { .. } => {
7760            let summarize = graph_db_status_summarize_command(&report);
7761            let index_command = report
7762                .recommendations
7763                .run
7764                .as_deref()
7765                .filter(|cmd| cmd.contains("index"))
7766                .map(str::to_string);
7767            let mut repair = Vec::new();
7768            if let Some(cmd) = index_command {
7769                repair.push(cmd);
7770            }
7771            repair.push(summarize.clone());
7772            repair.push(graph_db_refresh_command(root, scope));
7773            graph_effectiveness_blocked(
7774                "summary_cache_empty",
7775                vec![format!(
7776                    "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7777                    summarize,
7778                    root.display(),
7779                    graph_db_refresh_command(root, scope)
7780                )],
7781                repair,
7782            )
7783        }
7784        status::SummaryStatus::Unavailable => {
7785            let mut repair: Vec<String> = report.recommendations.run.clone().into_iter().collect();
7786            let summarize = "tsift summarize --extract .".to_string();
7787            repair.push(summarize);
7788            repair.push(graph_db_refresh_command(root, scope));
7789            graph_effectiveness_blocked(
7790                "summary_cache_unavailable",
7791                vec![
7792                    "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(),
7793                ],
7794                repair,
7795            )
7796        }
7797    }
7798}
7799
7800pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
7801    let report = match status::check_status(root) {
7802        Ok(report) => report,
7803        Err(err) => {
7804            return vec![format!(
7805                "status check unavailable after graph-db refresh: {err:#}"
7806            )];
7807        }
7808    };
7809
7810    let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7811        Some(graph_db_status_summarize_command(&report))
7812    } else {
7813        None
7814    };
7815    let mut warnings = report.reminders;
7816    if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7817        let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
7818        warnings.push(format!(
7819            "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7820            run,
7821            root.display(),
7822            graph_db_refresh_command(root, scope)
7823        ));
7824    }
7825    dedupe_preserve_order(warnings)
7826}
7827
7828pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
7829    println!(
7830        "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
7831        report.applied, report.pruned_tombstones, report.reclaimed_bytes
7832    );
7833    println!("graph_db: {}", report.graph_db);
7834    println!(
7835        "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7836        report.counts_before.nodes,
7837        report.counts_before.edges,
7838        report.counts_before.tombstones.total,
7839        report.counts_before.file_size_bytes.unwrap_or(0),
7840        report.counts_before.freelist_bytes.unwrap_or(0)
7841    );
7842    println!(
7843        "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7844        report.counts_after.nodes,
7845        report.counts_after.edges,
7846        report.counts_after.tombstones.total,
7847        report.counts_after.file_size_bytes.unwrap_or(0),
7848        report.counts_after.freelist_bytes.unwrap_or(0)
7849    );
7850    for proof in &report.compaction_after.proof {
7851        println!("proof: {proof}");
7852    }
7853    for warning in &report.warnings {
7854        println!("warning: {warning}");
7855    }
7856    for command in &report.next_commands {
7857        println!("next: {command}");
7858    }
7859}
7860
7861fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
7862    raw.iter()
7863        .map(|value| {
7864            let (key, filter_value) = value
7865                .split_once('=')
7866                .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
7867            let key = key.trim();
7868            let filter_value = filter_value.trim();
7869            if key.is_empty() || filter_value.is_empty() {
7870                bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
7871            }
7872            Ok(GraphDbPropertyFilter {
7873                key: key.to_string(),
7874                value: filter_value.to_string(),
7875            })
7876        })
7877        .collect()
7878}
7879
7880fn graph_db_query_options(
7881    cursor: Option<String>,
7882    limit: Option<usize>,
7883    property_filters: &[String],
7884) -> Result<GraphDbQueryOptions> {
7885    Ok(GraphDbQueryOptions {
7886        cursor,
7887        limit: limit.filter(|limit| *limit > 0),
7888        property_filters: parse_graph_db_property_filters(property_filters)?,
7889    })
7890}
7891
7892fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
7893    GraphQueryOptions {
7894        cursor: options.cursor.clone(),
7895        limit: options.limit,
7896        property_filters: options
7897            .property_filters
7898            .iter()
7899            .map(|filter| GraphPropertyFilter {
7900                key: filter.key.clone(),
7901                value: filter.value.clone(),
7902            })
7903            .collect(),
7904    }
7905}
7906
7907fn graph_db_page_report_from_store(
7908    page: GraphQueryPage,
7909    property_filters: Vec<GraphDbPropertyFilter>,
7910) -> GraphDbPageReport {
7911    GraphDbPageReport {
7912        cursor: page.cursor,
7913        limit: page.limit,
7914        next_cursor: page.next_cursor,
7915        returned_nodes: page.returned_nodes,
7916        returned_edges: page.returned_edges,
7917        truncated: page.truncated,
7918        property_filters,
7919        diagnostics: page.diagnostics,
7920    }
7921}
7922
7923fn graph_db_neighborhood_ranking_gate(
7924    ranked_neighbor_cap: usize,
7925) -> GraphDbNeighborhoodRankingGate {
7926    GraphDbNeighborhoodRankingGate {
7927        status: "held_default_order_unchanged".to_string(),
7928        ranked_output_default: false,
7929        default_order: "stable_node_id".to_string(),
7930        default_change_gate: "community_search_quality_metrics".to_string(),
7931        required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
7932            .iter()
7933            .map(|workload| (*workload).to_string())
7934            .collect(),
7935        required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
7936            .iter()
7937            .map(|metric| (*metric).to_string())
7938            .collect(),
7939        max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
7940        min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
7941        min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
7942        min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
7943        diagnostics: vec![
7944            "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
7945            format!(
7946                "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
7947            ),
7948            "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
7949        ],
7950    }
7951}
7952
7953fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
7954    match limit {
7955        Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
7956        Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
7957    }
7958}
7959
7960fn graph_db_ranked_neighbors(
7961    center_id: &str,
7962    nodes: &[SubstrateGraphNode],
7963    edges: &[SubstrateGraphEdge],
7964    cap: usize,
7965) -> Vec<GraphDbRankedNeighbor> {
7966    resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
7967}
7968
7969fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
7970    center_id: &str,
7971    depth: usize,
7972    edge_kind: Option<&str>,
7973    limit: Option<usize>,
7974    unranked_nodes: &[SubstrateGraphNode],
7975    unranked_edges: &[SubstrateGraphEdge],
7976    store: &S,
7977) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
7978    use std::time::Instant;
7979    let max_nodes = match limit {
7980        Some(0) | None => 200,
7981        Some(n) => n.clamp(10, 500),
7982    };
7983    let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
7984        .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
7985    if let Some(kind) = edge_kind {
7986        options = options.with_edge_kind(kind);
7987    }
7988    let start = Instant::now();
7989    let result = store.ranked_neighborhood(center_id, &options)?;
7990    let latency = start.elapsed().as_micros();
7991    let Some(ranked) = result else {
7992        return Ok(None);
7993    };
7994    let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
7995    let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
7996    let overlap_count = ranked_ids.intersection(&unranked_ids).count();
7997    let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
7998        0.0
7999    } else {
8000        (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8001    };
8002    let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8003        let mut name_count = BTreeMap::<&str, usize>::new();
8004        for n in nodes {
8005            *name_count.entry(&n.label).or_default() += 1;
8006        }
8007        name_count.values().filter(|&&c| c > 1).count()
8008    };
8009    let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8010        if nodes.is_empty() {
8011            return 100.0;
8012        }
8013        let with_handle = nodes
8014            .iter()
8015            .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8016            .count();
8017        (with_handle as f64 / nodes.len() as f64) * 100.0
8018    };
8019    let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8020        if nodes.is_empty() {
8021            return 0.0;
8022        }
8023        let semantic_kinds = [
8024            "semantic_concept",
8025            "semantic_entity",
8026            "symbol",
8027            "file",
8028            "source_handle",
8029        ];
8030        let useful = nodes
8031            .iter()
8032            .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8033            .count();
8034        let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8035        let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8036        (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8037            / nodes.len() as f64
8038    };
8039    let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8040        let edge_pairs: Vec<(String, String)> = ranked
8041            .edges
8042            .iter()
8043            .map(|e| (e.from_id.clone(), e.to_id.clone()))
8044            .collect();
8045        let cr = tsift_graph::detect_communities(&edge_pairs);
8046        let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8047        let mut fully_kept = 0usize;
8048        let mut partially_pruned = 0usize;
8049        let mut fully_pruned = 0usize;
8050        let mut pruned_kinds = BTreeSet::new();
8051        let mut pruned_labels = Vec::new();
8052        for comm in &cr.communities {
8053            let kept_in_comm: Vec<&str> = comm
8054                .members
8055                .iter()
8056                .filter(|m| kept_labels.contains(m.name.as_str()))
8057                .map(|m| m.name.as_str())
8058                .collect();
8059            if kept_in_comm.len() == comm.members.len() {
8060                fully_kept += 1;
8061            } else if kept_in_comm.is_empty() {
8062                fully_pruned += 1;
8063                for m in &comm.members {
8064                    if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8065                        pruned_kinds.insert(n.kind.clone());
8066                    }
8067                    pruned_labels.push(m.name.clone());
8068                }
8069            } else {
8070                partially_pruned += 1;
8071            }
8072        }
8073        pruned_labels.truncate(5);
8074        Some(CommunityTruncationSummary {
8075            total_communities: cr.communities.len(),
8076            fully_kept,
8077            partially_pruned,
8078            fully_pruned,
8079            pruned_community_kinds: pruned_kinds.into_iter().collect(),
8080            pruned_community_top_labels: pruned_labels,
8081        })
8082    } else {
8083        None
8084    };
8085    Ok(Some(GraphDbRankedNeighborhoodComparison {
8086        traversal_nodes: ranked.nodes.len(),
8087        traversal_edges: ranked.edges.len(),
8088        pruned_count: ranked.pruned_count,
8089        total_discovered: ranked.total_discovered,
8090        latency_micros: latency,
8091        overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8092        useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8093            / 1000.0,
8094        useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8095            .round()
8096            / 1000.0,
8097        duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8098        duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8099        handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8100        handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8101            / 100.0,
8102        community_truncation_summary,
8103        diagnostics: vec![
8104            format!(
8105                "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8106                ranked.nodes.len(),
8107                ranked.edges.len(),
8108                ranked.pruned_count,
8109                ranked.total_discovered,
8110                latency
8111            ),
8112            format!(
8113                "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8114                overlap_pct,
8115                overlap_count,
8116                unranked_ids.len(),
8117                ranked_ids.len()
8118            ),
8119            "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8120        ],
8121    }))
8122}
8123
8124struct GraphDbBudgetedSubgraph {
8125    nodes: Vec<SubstrateGraphNode>,
8126    edges: Vec<SubstrateGraphEdge>,
8127    report: GraphDbOutputBudgetReport,
8128    truncated: bool,
8129    next_cursor: Option<String>,
8130}
8131
8132const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8133const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8134const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8135
8136fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8137    match limit {
8138        Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8139        Some(limit) => limit
8140            .saturating_mul(320)
8141            .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8142    }
8143}
8144
8145fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8146    if matches!(limit, Some(0) | None) {
8147        return match kind {
8148            "source_handle" => 10,
8149            "worker_context" | "worker_result" => 8,
8150            "semantic_concept" | "semantic_entity" => 10,
8151            "file" | "symbol" | "route" => 12,
8152            _ => 8,
8153        };
8154    }
8155    let base = limit.unwrap_or(0).max(1);
8156    match kind {
8157        "source_handle" => base.saturating_add(4),
8158        "worker_context" | "worker_result" => base.saturating_add(2),
8159        "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8160        "file" | "symbol" | "route" => base.saturating_add(4),
8161        _ => base.saturating_add(1),
8162    }
8163}
8164
8165fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8166    if matches!(limit, Some(0) | None) {
8167        return match kind {
8168            "mentions" | "mentions_concept" | "mentions_entity" => 24,
8169            "semantic_relation" | "calls" | "defines" => 20,
8170            _ => 16,
8171        };
8172    }
8173    let base = limit.unwrap_or(0).max(1);
8174    match kind {
8175        "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8176        "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8177        _ => base.saturating_add(2),
8178    }
8179}
8180
8181fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8182    serde_json::to_vec(value)
8183        .map(|bytes| bytes.len().div_ceil(4).max(1))
8184        .unwrap_or(1)
8185}
8186
8187fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8188    let mut parts = vec![node.kind.clone(), node.label.clone()];
8189    for key in [
8190        "detail",
8191        "description",
8192        "source_ref",
8193        "path",
8194        "source_file",
8195        "source_symbol",
8196        "text_preview",
8197    ] {
8198        if let Some(value) = node.properties.get(key) {
8199            parts.push(value.clone());
8200        }
8201    }
8202    parts.join(" ")
8203}
8204
8205fn graph_db_semantic_scores_for_query(
8206    query: Option<&str>,
8207    nodes: &[SubstrateGraphNode],
8208) -> BTreeMap<String, f64> {
8209    let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8210        return BTreeMap::new();
8211    };
8212    let query_embedding = semantic_embedding(query);
8213    nodes
8214        .iter()
8215        .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8216        .filter_map(|node| {
8217            let embedding = node
8218                .properties
8219                .get("embedding")
8220                .and_then(|value| parse_semantic_embedding_property(value))?;
8221            Some((
8222                node.id.clone(),
8223                semantic_cosine(&query_embedding, &embedding),
8224            ))
8225        })
8226        .collect()
8227}
8228
8229fn graph_db_depth_by_id(
8230    origin_ids: &[String],
8231    edges: &[SubstrateGraphEdge],
8232) -> BTreeMap<String, usize> {
8233    let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8234    for edge in edges {
8235        adjacency
8236            .entry(edge.from_id.clone())
8237            .or_default()
8238            .push(edge.to_id.clone());
8239        adjacency
8240            .entry(edge.to_id.clone())
8241            .or_default()
8242            .push(edge.from_id.clone());
8243    }
8244
8245    let mut depth_by_id = BTreeMap::<String, usize>::new();
8246    let mut queue = VecDeque::<String>::new();
8247    for origin in origin_ids {
8248        if depth_by_id.insert(origin.clone(), 0).is_none() {
8249            queue.push_back(origin.clone());
8250        }
8251    }
8252    while let Some(current) = queue.pop_front() {
8253        let depth = depth_by_id.get(&current).copied().unwrap_or(0);
8254        for next in adjacency.get(&current).into_iter().flatten() {
8255            if depth_by_id.contains_key(next) {
8256                continue;
8257            }
8258            depth_by_id.insert(next.clone(), depth.saturating_add(1));
8259            queue.push_back(next.clone());
8260        }
8261    }
8262    depth_by_id
8263}
8264
8265fn graph_db_source_covered_ids(
8266    nodes: &[SubstrateGraphNode],
8267    edges: &[SubstrateGraphEdge],
8268) -> BTreeSet<String> {
8269    let source_ids = nodes
8270        .iter()
8271        .filter(|node| node.kind == "source_handle")
8272        .map(|node| node.id.as_str())
8273        .collect::<BTreeSet<_>>();
8274    let mut covered = source_ids
8275        .iter()
8276        .map(|id| (*id).to_string())
8277        .collect::<BTreeSet<_>>();
8278    for edge in edges {
8279        if source_ids.contains(edge.from_id.as_str()) {
8280            covered.insert(edge.to_id.clone());
8281        }
8282        if source_ids.contains(edge.to_id.as_str()) {
8283            covered.insert(edge.from_id.clone());
8284        }
8285    }
8286    covered
8287}
8288
8289fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8290    for key in [
8291        "observed_at_unix",
8292        "completed_at_unix",
8293        "created_at_unix",
8294        "started_at_unix",
8295    ] {
8296        if let Some(value) = node.properties.get(key)
8297            && let Ok(epoch) = value.parse::<i64>()
8298        {
8299            return epoch.div_euclid(86_400).clamp(0, 40_000);
8300        }
8301    }
8302    0
8303}
8304
8305fn graph_db_node_kind_score(kind: &str) -> i64 {
8306    match kind {
8307        "source_handle" => 180,
8308        "worker_context" => 170,
8309        "worker_result" => 160,
8310        "semantic_concept" | "semantic_entity" => 150,
8311        "backlog" | "job_packet" => 130,
8312        "symbol" => 120,
8313        "file" => 110,
8314        "route" => 105,
8315        "session" => 90,
8316        _ => 40,
8317    }
8318}
8319
8320fn graph_db_edge_kind_score(kind: &str) -> i64 {
8321    match kind {
8322        "mentions_concept" | "mentions_entity" => 180,
8323        "semantic_relation" => 170,
8324        "mentions" => 165,
8325        "requests_context" | "scopes_context" | "scopes_source" => 155,
8326        "explains_result" => 150,
8327        "calls" => 145,
8328        "defines" | "handled_by" | "defines_route" => 130,
8329        "contains" | "targets" => 120,
8330        "records_memory_source" | "has_vector_handle" => 115,
8331        _ => 40,
8332    }
8333}
8334
8335fn graph_db_node_usefulness_score(
8336    node: &SubstrateGraphNode,
8337    depth_by_id: &BTreeMap<String, usize>,
8338    semantic_scores: &BTreeMap<String, f64>,
8339    source_covered_ids: &BTreeSet<String>,
8340    origin_ids: &[String],
8341) -> i64 {
8342    if origin_ids.iter().any(|origin| origin == &node.id) {
8343        return 1_000_000;
8344    }
8345    let semantic = semantic_scores
8346        .get(&node.id)
8347        .map(|score| (score.max(0.0) * 1_000.0) as i64)
8348        .unwrap_or(0);
8349    let depth_penalty = depth_by_id
8350        .get(&node.id)
8351        .map(|depth| (*depth as i64).saturating_mul(55))
8352        .unwrap_or(180);
8353    let source_coverage = if source_covered_ids.contains(&node.id)
8354        || node.properties.contains_key("source_ref")
8355        || node.properties.contains_key("path")
8356    {
8357        120
8358    } else {
8359        0
8360    };
8361    graph_db_node_kind_score(&node.kind)
8362        + semantic
8363        + source_coverage
8364        + graph_db_recency_score(node).min(80)
8365        - depth_penalty
8366}
8367
8368fn graph_db_edge_usefulness_score(
8369    edge: &SubstrateGraphEdge,
8370    node_score_by_id: &BTreeMap<String, i64>,
8371    depth_by_id: &BTreeMap<String, usize>,
8372) -> i64 {
8373    let endpoint_score = node_score_by_id
8374        .get(&edge.from_id)
8375        .copied()
8376        .unwrap_or_default()
8377        .max(
8378            node_score_by_id
8379                .get(&edge.to_id)
8380                .copied()
8381                .unwrap_or_default(),
8382        );
8383    let depth_penalty = depth_by_id
8384        .get(&edge.from_id)
8385        .into_iter()
8386        .chain(depth_by_id.get(&edge.to_id))
8387        .min()
8388        .map(|depth| (*depth as i64).saturating_mul(35))
8389        .unwrap_or(140);
8390    graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
8391}
8392
8393fn graph_db_push_drop(
8394    drops: &mut BTreeMap<(String, String, String), usize>,
8395    item: &str,
8396    kind: &str,
8397    reason: &str,
8398) {
8399    *drops
8400        .entry((item.to_string(), kind.to_string(), reason.to_string()))
8401        .or_default() += 1;
8402}
8403
8404fn graph_db_budget_drop_report(
8405    drops: BTreeMap<(String, String, String), usize>,
8406) -> Vec<GraphDbDroppedByBudget> {
8407    drops
8408        .into_iter()
8409        .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
8410            item,
8411            kind,
8412            reason,
8413            dropped,
8414        })
8415        .collect()
8416}
8417
8418fn graph_db_apply_output_budget(
8419    origin_ids: &[String],
8420    semantic_scores: &BTreeMap<String, f64>,
8421    nodes: Vec<SubstrateGraphNode>,
8422    edges: Vec<SubstrateGraphEdge>,
8423    limit: Option<usize>,
8424) -> GraphDbBudgetedSubgraph {
8425    graph_db_apply_output_budget_with_depths_and_cursor(
8426        origin_ids,
8427        semantic_scores,
8428        nodes,
8429        edges,
8430        limit,
8431        None,
8432        None,
8433    )
8434}
8435
8436fn graph_db_apply_output_budget_with_depths_and_cursor(
8437    origin_ids: &[String],
8438    semantic_scores: &BTreeMap<String, f64>,
8439    nodes: Vec<SubstrateGraphNode>,
8440    edges: Vec<SubstrateGraphEdge>,
8441    limit: Option<usize>,
8442    depth_overrides: Option<&BTreeMap<String, usize>>,
8443    cursor: Option<&str>,
8444) -> GraphDbBudgetedSubgraph {
8445    let max_tokens = graph_db_output_token_cap(limit);
8446    let candidate_nodes = nodes.len();
8447    let candidate_edges = edges.len();
8448    let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
8449    if let Some(depth_overrides) = depth_overrides {
8450        for (id, depth) in depth_overrides {
8451            depth_by_id
8452                .entry(id.clone())
8453                .and_modify(|current| *current = (*current).min(*depth))
8454                .or_insert(*depth);
8455        }
8456    }
8457    let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
8458    let node_score_by_id = nodes
8459        .iter()
8460        .map(|node| {
8461            (
8462                node.id.clone(),
8463                graph_db_node_usefulness_score(
8464                    node,
8465                    &depth_by_id,
8466                    semantic_scores,
8467                    &source_covered_ids,
8468                    origin_ids,
8469                ),
8470            )
8471        })
8472        .collect::<BTreeMap<_, _>>();
8473
8474    let mut node_candidates = nodes.iter().collect::<Vec<_>>();
8475    node_candidates.sort_by(|left, right| {
8476        node_score_by_id
8477            .get(&right.id)
8478            .cmp(&node_score_by_id.get(&left.id))
8479            .then_with(|| left.kind.cmp(&right.kind))
8480            .then_with(|| left.label.cmp(&right.label))
8481            .then_with(|| left.id.cmp(&right.id))
8482    });
8483
8484    let cursor_skip = if let Some(cursor) = cursor {
8485        node_candidates
8486            .iter()
8487            .position(|node| node.id == cursor)
8488            .map(|pos| pos.saturating_add(1))
8489            .unwrap_or(0)
8490    } else {
8491        0
8492    };
8493    if cursor_skip > 0 {
8494        node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
8495    }
8496
8497    let mut selected_node_ids = BTreeSet::new();
8498    let mut selected_node_counts = BTreeMap::<String, usize>::new();
8499    let mut estimated_tokens = 0usize;
8500    let mut drops = BTreeMap::<(String, String, String), usize>::new();
8501    for node in &node_candidates {
8502        let kind_count = selected_node_counts
8503            .get(&node.kind)
8504            .copied()
8505            .unwrap_or_default();
8506        if !origin_ids.iter().any(|origin| origin == &node.id)
8507            && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
8508        {
8509            graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
8510            continue;
8511        }
8512        let tokens = graph_db_estimated_tokens(node);
8513        if !origin_ids.iter().any(|origin| origin == &node.id)
8514            && estimated_tokens.saturating_add(tokens) > max_tokens
8515        {
8516            graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
8517            continue;
8518        }
8519        selected_node_ids.insert(node.id.clone());
8520        *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
8521        estimated_tokens = estimated_tokens.saturating_add(tokens);
8522    }
8523
8524    let has_remaining_candidates = node_candidates
8525        .iter()
8526        .any(|node| !selected_node_ids.contains(&node.id));
8527
8528    let mut selected_nodes = nodes
8529        .into_iter()
8530        .filter(|node| selected_node_ids.contains(&node.id))
8531        .collect::<Vec<_>>();
8532
8533    let mut edge_candidates = edges
8534        .iter()
8535        .filter(|edge| {
8536            selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
8537        })
8538        .collect::<Vec<_>>();
8539    let edge_score_by_key = edge_candidates
8540        .iter()
8541        .map(|edge| {
8542            (
8543                graph_db_edge_key(edge),
8544                graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
8545            )
8546        })
8547        .collect::<BTreeMap<_, _>>();
8548    edge_candidates.sort_by(|left, right| {
8549        edge_score_by_key
8550            .get(&graph_db_edge_key(right))
8551            .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
8552            .then_with(|| left.kind.cmp(&right.kind))
8553            .then_with(|| left.from_id.cmp(&right.from_id))
8554            .then_with(|| left.to_id.cmp(&right.to_id))
8555    });
8556
8557    let endpoint_dropped_edges = edges
8558        .iter()
8559        .filter(|edge| {
8560            !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
8561        })
8562        .count();
8563    if endpoint_dropped_edges > 0 {
8564        drops.insert(
8565            (
8566                "edge".to_string(),
8567                "*".to_string(),
8568                "endpoint_node_dropped".to_string(),
8569            ),
8570            endpoint_dropped_edges,
8571        );
8572    }
8573
8574    let mut selected_edge_ids = BTreeSet::new();
8575    let mut selected_edge_counts = BTreeMap::<String, usize>::new();
8576    for edge in edge_candidates {
8577        let kind_count = selected_edge_counts
8578            .get(&edge.kind)
8579            .copied()
8580            .unwrap_or_default();
8581        if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
8582            graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
8583            continue;
8584        }
8585        let tokens = graph_db_estimated_tokens(edge);
8586        if estimated_tokens.saturating_add(tokens) > max_tokens {
8587            graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
8588            continue;
8589        }
8590        selected_edge_ids.insert(graph_db_edge_key(edge));
8591        *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
8592        estimated_tokens = estimated_tokens.saturating_add(tokens);
8593    }
8594
8595    let selected_edges = edges
8596        .into_iter()
8597        .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
8598        .collect::<Vec<_>>();
8599    let dropped_by_budget = graph_db_budget_drop_report(drops);
8600    let truncated = has_remaining_candidates;
8601    let next_cursor = if truncated {
8602        selected_nodes.last().map(|node| node.id.clone())
8603    } else {
8604        None
8605    };
8606    let mut diagnostics = vec![
8607        "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
8608            .to_string(),
8609        format!(
8610            "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
8611            selected_nodes.len(),
8612            candidate_nodes,
8613            selected_edges.len(),
8614            candidate_edges,
8615            max_tokens
8616        ),
8617    ];
8618    if cursor.is_some() {
8619        diagnostics.push(format!(
8620            "cursor skipped {} previously returned candidate(s)",
8621            cursor_skip
8622        ));
8623    }
8624    if next_cursor.is_some() {
8625        diagnostics.push(
8626            "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
8627        );
8628    }
8629    selected_nodes.shrink_to_fit();
8630
8631    GraphDbBudgetedSubgraph {
8632        nodes: selected_nodes,
8633        edges: selected_edges,
8634        report: GraphDbOutputBudgetReport {
8635            max_tokens,
8636            estimated_tokens,
8637            selected_nodes: selected_node_ids.len(),
8638            selected_edges: selected_edge_ids.len(),
8639            candidate_nodes,
8640            candidate_edges,
8641            dropped_by_budget,
8642            diagnostics,
8643        },
8644        truncated,
8645        next_cursor,
8646    }
8647}
8648
8649fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
8650    if edge.id.is_empty() {
8651        substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
8652    } else {
8653        edge.id.clone()
8654    }
8655}
8656
8657fn graph_db_schema() -> GraphDbSchema {
8658    GraphDbSchema {
8659        contract_versions: vec![
8660            GraphDbSchemaContract {
8661                name: "graph_db_evidence",
8662                version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
8663                description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
8664            },
8665            GraphDbSchemaContract {
8666                name: "worker_prompt_packet",
8667                version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
8668                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",
8669            },
8670            GraphDbSchemaContract {
8671                name: "conflict_matrix",
8672                version: CONFLICT_MATRIX_CONTRACT_VERSION,
8673                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",
8674            },
8675            GraphDbSchemaContract {
8676                name: "context_pack_graph_orchestration",
8677                version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
8678                description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
8679            },
8680            GraphDbSchemaContract {
8681                name: "session_review_follow_up",
8682                version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
8683                description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
8684            },
8685            GraphDbSchemaContract {
8686                name: "dispatch_trace",
8687                version: DISPATCH_TRACE_CONTRACT_VERSION,
8688                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",
8689            },
8690            GraphDbSchemaContract {
8691                name: "dependency_dag",
8692                version: DEPENDENCY_DAG_CONTRACT_VERSION,
8693                description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
8694            },
8695        ],
8696        node_fields: vec![
8697            GraphDbSchemaField {
8698                name: "id",
8699                value_type: "string",
8700                description: "Stable provider-neutral node id",
8701            },
8702            GraphDbSchemaField {
8703                name: "kind",
8704                value_type: "string",
8705                description: "Application-defined node family such as file, symbol, or backlog",
8706            },
8707            GraphDbSchemaField {
8708                name: "label",
8709                value_type: "string",
8710                description: "Human-readable label",
8711            },
8712            GraphDbSchemaField {
8713                name: "properties",
8714                value_type: "object<string,string>",
8715                description: "Adapter-specific string properties",
8716            },
8717            GraphDbSchemaField {
8718                name: "provenance",
8719                value_type: "array",
8720                description: "Source system and source reference metadata",
8721            },
8722            GraphDbSchemaField {
8723                name: "freshness",
8724                value_type: "object|null",
8725                description: "Optional content hash and observed timestamp",
8726            },
8727        ],
8728        edge_fields: vec![
8729            GraphDbSchemaField {
8730                name: "id",
8731                value_type: "string",
8732                description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
8733            },
8734            GraphDbSchemaField {
8735                name: "from_id",
8736                value_type: "string",
8737                description: "Source node id",
8738            },
8739            GraphDbSchemaField {
8740                name: "to_id",
8741                value_type: "string",
8742                description: "Target node id",
8743            },
8744            GraphDbSchemaField {
8745                name: "kind",
8746                value_type: "string",
8747                description: "Application-defined edge relation",
8748            },
8749            GraphDbSchemaField {
8750                name: "properties",
8751                value_type: "object<string,string>",
8752                description: "Adapter-specific string properties",
8753            },
8754            GraphDbSchemaField {
8755                name: "provenance",
8756                value_type: "array",
8757                description: "Source system and source reference metadata",
8758            },
8759            GraphDbSchemaField {
8760                name: "freshness",
8761                value_type: "object|null",
8762                description: "Optional content hash and observed timestamp",
8763            },
8764        ],
8765        operations: vec![
8766            GraphDbSchemaOperation {
8767                command: "refresh",
8768                description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
8769            },
8770            GraphDbSchemaOperation {
8771                command: "status",
8772                description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
8773            },
8774            GraphDbSchemaOperation {
8775                command: "doctor",
8776                description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
8777            },
8778            GraphDbSchemaOperation {
8779                command: "drift",
8780                description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
8781            },
8782            GraphDbSchemaOperation {
8783                command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
8784                description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
8785            },
8786            GraphDbSchemaOperation {
8787                command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
8788                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",
8789            },
8790            GraphDbSchemaOperation {
8791                command: "evidence <target> [--depth N] [--limit N]",
8792                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",
8793            },
8794            GraphDbSchemaOperation {
8795                command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
8796                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",
8797            },
8798            GraphDbSchemaOperation {
8799                command: "dispatch-trace [target...] --path <session> [--format json|html]",
8800                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",
8801            },
8802            GraphDbSchemaOperation {
8803                command: "dependency-dag [target...] --path <session>",
8804                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",
8805            },
8806            GraphDbSchemaOperation {
8807                command: "schema",
8808                description: "Return record and operation schemas",
8809            },
8810            GraphDbSchemaOperation {
8811                command: "node <id>",
8812                description: "Return one node by stable id",
8813            },
8814            GraphDbSchemaOperation {
8815                command: "edge <id>",
8816                description: "Return one edge by stable edge id",
8817            },
8818            GraphDbSchemaOperation {
8819                command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8820                description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
8821            },
8822            GraphDbSchemaOperation {
8823                command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8824                description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
8825            },
8826            GraphDbSchemaOperation {
8827                command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
8828                description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
8829            },
8830            GraphDbSchemaOperation {
8831                command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
8832                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",
8833            },
8834            GraphDbSchemaOperation {
8835                command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
8836                description: "Return the shortest directed path by node id, optionally bounded by hop count",
8837            },
8838        ],
8839    }
8840}
8841
8842pub(crate) fn sqlite_graph_freshness(
8843    store: &SqliteGraphStore,
8844    scope: &str,
8845) -> Result<GraphDbFreshnessReport> {
8846    let version = store.projection_version(scope)?;
8847    let Some(version) = version else {
8848        return Ok(GraphDbFreshnessReport {
8849            status: "missing".to_string(),
8850            fail_closed: true,
8851            projection_version: None,
8852            content_hash: None,
8853            source_watermark: None,
8854            diagnostics: vec![
8855                "graph projection metadata is missing; rebuild the graph before trusting reads"
8856                    .to_string(),
8857            ],
8858        });
8859    };
8860    let mut diagnostics = Vec::new();
8861    let fail_closed =
8862        version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
8863    if version.projection_version != GRAPH_PROJECTION_VERSION {
8864        diagnostics.push(format!(
8865            "projection version mismatch: expected {} got {}",
8866            GRAPH_PROJECTION_VERSION, version.projection_version
8867        ));
8868    }
8869    if version.content_hash.is_none() {
8870        diagnostics.push("projection content hash is missing".to_string());
8871    }
8872    Ok(GraphDbFreshnessReport {
8873        status: if fail_closed { "stale" } else { "current" }.to_string(),
8874        fail_closed,
8875        projection_version: Some(version.projection_version),
8876        content_hash: version.content_hash,
8877        source_watermark: version.source_watermark,
8878        diagnostics,
8879    })
8880}
8881
8882pub(crate) fn convex_graph_freshness(
8883    local: &ConvexProjectionRows,
8884    snapshot: &ConvexProjectionRows,
8885    scope: Option<&str>,
8886) -> GraphDbFreshnessReport {
8887    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
8888    GraphDbFreshnessReport {
8889        status: freshness.status,
8890        fail_closed: freshness.fail_closed,
8891        projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
8892        content_hash: freshness.snapshot_hash,
8893        source_watermark: None,
8894        diagnostics: freshness.diagnostics,
8895    }
8896}
8897
8898pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
8899    let (nodes, edges) = store.graph_counts()?;
8900    let files = store.file_count()?;
8901    Ok(GraphDbFreshnessReport {
8902        status: "current".to_string(),
8903        fail_closed: false,
8904        projection_version: Some("tokensave-readonly".to_string()),
8905        content_hash: None,
8906        source_watermark: Some(store.db_path().to_string_lossy().to_string()),
8907        diagnostics: vec![format!(
8908            "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
8909            nodes, edges, files
8910        )],
8911    })
8912}
8913
8914pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
8915    match TokensaveDb::discover(root) {
8916        Ok(Some(store)) => {
8917            report.push_check(GraphDbDoctorCheck {
8918                name: "tokensave_db_open".to_string(),
8919                status: "ok".to_string(),
8920                fail_closed: false,
8921                diagnostics: vec![format!(
8922                    "opened tokensave database at {}",
8923                    store.db_path().display()
8924                )],
8925                repair_commands: Vec::new(),
8926            });
8927            match (store.node_count(), store.edge_count(), store.file_count()) {
8928                (Ok(nodes), Ok(edges), Ok(files)) => {
8929                    report.push_check(GraphDbDoctorCheck {
8930                        name: "tokensave_counts".to_string(),
8931                        status: "ok".to_string(),
8932                        fail_closed: false,
8933                        diagnostics: vec![format!(
8934                            "tokensave contains {} node(s), {} edge(s), {} file(s)",
8935                            nodes, edges, files
8936                        )],
8937                        repair_commands: Vec::new(),
8938                    });
8939                }
8940                (nodes, edges, files) => {
8941                    report.push_check(graph_db_doctor_check(
8942                        "tokensave_counts",
8943                        vec![format!(
8944                            "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
8945                            nodes.err(),
8946                            edges.err(),
8947                            files.err()
8948                        )],
8949                        Vec::new(),
8950                    ));
8951                }
8952            }
8953        }
8954        Ok(None) => report.push_check(graph_db_doctor_check(
8955            "tokensave_db_exists",
8956            vec![format!(
8957                "tokensave database is missing at {}",
8958                root.join(".tokensave").join("tokensave.db").display()
8959            )],
8960            Vec::new(),
8961        )),
8962        Err(err) => report.push_check(graph_db_doctor_check(
8963            "tokensave_db_open",
8964            vec![err.to_string()],
8965            Vec::new(),
8966        )),
8967    }
8968}
8969
8970pub(crate) fn graph_db_resolve_evidence_target(
8971    store: &impl GraphStore,
8972    target: &str,
8973) -> Result<Option<SubstrateGraphNode>> {
8974    store.resolve_evidence_target(
8975        target,
8976        &[
8977            "backlog",
8978            "job_packet",
8979            "worker_result",
8980            "worker_context",
8981            "source_handle",
8982        ],
8983    )
8984}
8985
8986fn graph_db_reachable_nodes_by_kind(
8987    store: &impl GraphStore,
8988    from_id: &str,
8989    kind: &str,
8990    depth: usize,
8991    limit: usize,
8992) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
8993    store.reachable_nodes_by_kind(from_id, kind, depth, limit)
8994}
8995
8996fn graph_db_evidence_completed_queue_drift_warnings(
8997    store: &impl GraphStore,
8998    target: &SubstrateGraphNode,
8999    worker_results: &[SubstrateGraphNode],
9000) -> Result<Vec<String>> {
9001    let ref_id = target.properties.get("ref_id").map(String::as_str);
9002    let has_completed_result = worker_results.iter().any(|node| {
9003        node.properties.get("status").map(String::as_str) == Some("completed")
9004            && node.properties.get("ref_id").map(String::as_str) == ref_id
9005    });
9006    if !has_completed_result {
9007        return Ok(Vec::new());
9008    }
9009    let active_jobs = store
9010        .nodes_by_kind("job_packet")?
9011        .into_iter()
9012        .filter(|node| {
9013            node.properties.get("ref_id").map(String::as_str) == ref_id
9014                && node.label.starts_with("do #")
9015        })
9016        .collect::<Vec<_>>();
9017    if active_jobs.is_empty() {
9018        return Ok(Vec::new());
9019    }
9020    let repair = match (target.properties.get("path"), ref_id) {
9021        (Some(path), Some(id)) => format!(
9022            "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9023            shell_quote(path),
9024            shell_quote(id),
9025            shell_quote(id)
9026        ),
9027        _ => {
9028            "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9029        }
9030    };
9031    Ok(vec![format!(
9032        "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",
9033        target.label,
9034        active_jobs.len()
9035    )])
9036}
9037
9038fn graph_db_evidence_next_commands(
9039    root: &Path,
9040    scope: Option<&str>,
9041    target: &SubstrateGraphNode,
9042    worker_context: &[SubstrateGraphNode],
9043    source_handles: &[SubstrateGraphNode],
9044    worker_results: &[SubstrateGraphNode],
9045    semantic_related: &[SubstrateGraphNode],
9046) -> Vec<String> {
9047    let mut commands = BTreeSet::new();
9048    if let Some(expand) = target.properties.get("expand") {
9049        commands.insert(expand.clone());
9050    }
9051    for worker in worker_context {
9052        if let Some(expand) = worker.properties.get("expand") {
9053            commands.insert(expand.clone());
9054        }
9055    }
9056    for source in source_handles {
9057        if let Some(expand) = source.properties.get("expand") {
9058            commands.insert(expand.clone());
9059        }
9060    }
9061    for result in worker_results {
9062        if let Some(expand) = result.properties.get("expand") {
9063            commands.insert(expand.clone());
9064        }
9065    }
9066    for semantic in semantic_related {
9067        if let Some(expand) = semantic.properties.get("expand") {
9068            commands.insert(expand.clone());
9069        }
9070    }
9071    commands.insert(format!(
9072        "tsift graph-db --path {}{} status --json",
9073        shell_quote(root.to_string_lossy().as_ref()),
9074        graph_db_scope_arg(scope)
9075    ));
9076    commands.insert(format!(
9077        "tsift graph-db --path {}{} doctor --json",
9078        shell_quote(root.to_string_lossy().as_ref()),
9079        graph_db_scope_arg(scope)
9080    ));
9081    commands.into_iter().collect()
9082}
9083
9084fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9085    vec![
9086        format!(
9087            "tsift graph-db --path {}{} refresh --json",
9088            shell_quote(root.to_string_lossy().as_ref()),
9089            graph_db_scope_arg(scope)
9090        ),
9091        format!(
9092            "tsift graph-db --path {}{} doctor --json",
9093            shell_quote(root.to_string_lossy().as_ref()),
9094            graph_db_scope_arg(scope)
9095        ),
9096    ]
9097}
9098
9099fn graph_db_evidence_replay_commands(
9100    root: &Path,
9101    scope: Option<&str>,
9102    target: &str,
9103    depth: usize,
9104    limit: usize,
9105) -> Vec<String> {
9106    vec![
9107        format!(
9108            "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9109            shell_quote(root.to_string_lossy().as_ref()),
9110            graph_db_scope_arg(scope),
9111            shell_quote(target),
9112            depth,
9113            limit
9114        ),
9115        format!(
9116            "tsift conflict-matrix --path {} {} --json",
9117            shell_quote(root.to_string_lossy().as_ref()),
9118            shell_quote(target)
9119        ),
9120    ]
9121}
9122
9123fn graph_db_evidence_packet_id(
9124    target: &str,
9125    target_node: &SubstrateGraphNode,
9126    freshness: &GraphDbFreshnessReport,
9127) -> String {
9128    stable_handle(
9129        "gevd",
9130        &format!(
9131            "{}:{}:{}:{}",
9132            GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9133            target,
9134            target_node.id,
9135            freshness.content_hash.as_deref().unwrap_or("no-hash")
9136        ),
9137    )
9138}
9139
9140pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9141    input: GraphDbEvidenceInput<'_, S>,
9142) -> Result<GraphDbEvidenceReport> {
9143    let GraphDbEvidenceInput {
9144        root,
9145        scope,
9146        backend,
9147        target,
9148        depth,
9149        limit,
9150        cursor,
9151        store,
9152        freshness,
9153        mut warnings,
9154    } = input;
9155    let repair_commands = graph_db_repair_commands(root, scope);
9156    if freshness.fail_closed {
9157        bail!(
9158            "graph database evidence failed closed for {} backend: {}; repair: {}",
9159            backend,
9160            freshness.diagnostics.join("; "),
9161            repair_commands.join("; ")
9162        );
9163    }
9164    let semantic_readiness =
9165        graph_db_semantic_readiness(root, scope, graph_store_semantic_node_count(store).ok());
9166    if semantic_readiness.fail_closed {
9167        warnings.push(format!(
9168            "graph evidence semantic readiness blocked: {} — {}",
9169            semantic_readiness.reason,
9170            semantic_readiness.diagnostics.join("; ")
9171        ));
9172        warnings.push(format!(
9173            "repair: {}",
9174            semantic_readiness.next_commands.join("; then ")
9175        ));
9176    }
9177    let target_node = graph_db_resolve_evidence_target(store, target)?
9178        .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9179    let max_rows = if limit == 0 { usize::MAX } else { limit };
9180    let mut reachable = store.reachable_nodes_by_kinds(
9181        &target_node.id,
9182        &[
9183            "worker_context",
9184            "source_handle",
9185            "worker_result",
9186            "semantic_concept",
9187            "semantic_entity",
9188        ],
9189        depth,
9190        max_rows,
9191    )?;
9192    let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9193    let source_paths = reachable.remove("source_handle").unwrap_or_default();
9194    let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9195    let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9196    semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9197    semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9198        left_path
9199            .hops
9200            .cmp(&right_path.hops)
9201            .then(left_node.kind.cmp(&right_node.kind))
9202            .then(left_node.label.cmp(&right_node.label))
9203            .then(left_node.id.cmp(&right_node.id))
9204    });
9205    if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9206        semantic_paths.truncate(max_rows);
9207    }
9208
9209    let evidence_nodes = worker_paths
9210        .iter()
9211        .chain(source_paths.iter())
9212        .chain(worker_result_paths.iter())
9213        .chain(semantic_paths.iter())
9214        .map(|(node, _)| node.clone())
9215        .collect::<Vec<_>>();
9216    let evidence_depth_by_id = worker_paths
9217        .iter()
9218        .chain(source_paths.iter())
9219        .chain(worker_result_paths.iter())
9220        .chain(semantic_paths.iter())
9221        .map(|(node, path)| (node.id.clone(), path.hops))
9222        .collect::<BTreeMap<_, _>>();
9223    let target_query = graph_db_node_search_text(&target_node);
9224    let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9225    let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9226        std::slice::from_ref(&target_node.id),
9227        &semantic_scores,
9228        evidence_nodes,
9229        Vec::new(),
9230        Some(limit),
9231        Some(&evidence_depth_by_id),
9232        cursor,
9233    );
9234    let output_budget = budgeted.report;
9235    let truncated = budgeted.truncated;
9236    let next_cursor = budgeted.next_cursor;
9237    let retained_evidence_ids = budgeted
9238        .nodes
9239        .iter()
9240        .map(|node| node.id.as_str())
9241        .collect::<BTreeSet<_>>();
9242    let worker_context = worker_paths
9243        .iter()
9244        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9245        .map(|(node, _)| node.clone())
9246        .collect::<Vec<_>>();
9247    let source_handles = source_paths
9248        .iter()
9249        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9250        .map(|(node, _)| node.clone())
9251        .collect::<Vec<_>>();
9252    let worker_results = worker_result_paths
9253        .iter()
9254        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9255        .map(|(node, _)| node.clone())
9256        .collect::<Vec<_>>();
9257    let semantic_related = semantic_paths
9258        .iter()
9259        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9260        .map(|(node, _)| node.clone())
9261        .collect::<Vec<_>>();
9262    warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
9263        store,
9264        &target_node,
9265        &worker_results,
9266    )?);
9267    if worker_context.is_empty()
9268        && source_handles.is_empty()
9269        && worker_results.is_empty()
9270        && semantic_related.is_empty()
9271    {
9272        warnings.push(format!(
9273            "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",
9274            target, target_node.kind
9275        ));
9276    }
9277    let shortest_paths = worker_paths
9278        .iter()
9279        .chain(source_paths.iter())
9280        .chain(worker_result_paths.iter())
9281        .chain(semantic_paths.iter())
9282        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9283        .map(|(node, path)| GraphDbEvidencePath {
9284            to: node.id.clone(),
9285            kind: node.kind.clone(),
9286            label: node.label.clone(),
9287            path: Some(path.clone()),
9288            expand: node.properties.get("expand").cloned(),
9289        })
9290        .collect::<Vec<_>>();
9291    let next_commands = graph_db_evidence_next_commands(
9292        root,
9293        scope,
9294        &target_node,
9295        &worker_context,
9296        &source_handles,
9297        &worker_results,
9298        &semantic_related,
9299    );
9300    let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
9301    let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
9302    let projection_hash = freshness.content_hash.clone();
9303
9304    Ok(GraphDbEvidenceReport {
9305        root: root.to_string_lossy().to_string(),
9306        scope: scope.map(str::to_string),
9307        backend: backend.to_string(),
9308        contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
9309        target: target.to_string(),
9310        packet_id,
9311        projection_hash,
9312        freshness,
9313        target_node: target_node.into(),
9314        worker_context: worker_context.into_iter().map(Into::into).collect(),
9315        source_handles: source_handles.into_iter().map(Into::into).collect(),
9316        worker_results: worker_results.into_iter().map(Into::into).collect(),
9317        semantic_related: semantic_related.into_iter().map(Into::into).collect(),
9318        shortest_paths,
9319        output_budget: Some(output_budget),
9320        truncated,
9321        next_cursor,
9322        next_commands,
9323        replay_commands,
9324        repair_commands,
9325        fixture_coverage: GraphDbFixtureCoverage {
9326            test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
9327                .to_string(),
9328            fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
9329            assertions: vec![
9330                "backlog id and job packet handle resolve to graph nodes".to_string(),
9331                "worker_context rows are reachable from queued work".to_string(),
9332                "source_handle rows are reachable through bounded shortest paths".to_string(),
9333                "worker_result rows are reachable from completed or blocked work".to_string(),
9334            ],
9335        },
9336        warnings,
9337    })
9338}
9339
9340fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
9341    println!(
9342        "graph-db evidence backend: {} target: {} [{}] packet:{}",
9343        report.backend, report.target_node.id, report.target_node.kind, report.packet_id
9344    );
9345    let page_info = if report.truncated {
9346        let cursor = report.next_cursor.as_deref().unwrap_or("?");
9347        format!(" (truncated, next_cursor: {cursor})")
9348    } else {
9349        String::new()
9350    };
9351    println!(
9352        "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
9353        report.worker_context.len(),
9354        report.source_handles.len(),
9355        report.worker_results.len(),
9356        report.semantic_related.len(),
9357        report.shortest_paths.len()
9358    );
9359    for path in &report.shortest_paths {
9360        if let Some(graph_path) = &path.path {
9361            println!(
9362                "path: {} hop(s) {}",
9363                graph_path.hops,
9364                graph_path.nodes.join(" -> ")
9365            );
9366        }
9367    }
9368    for command in &report.next_commands {
9369        println!("next: {command}");
9370    }
9371    for warning in &report.warnings {
9372        println!("warning: {warning}");
9373    }
9374}
9375
9376pub(crate) fn print_graph_db_evidence_report(
9377    report: &GraphDbEvidenceReport,
9378    format: OutputFormat,
9379) -> Result<()> {
9380    if format.json_output {
9381        let page_info = if report.truncated {
9382            let cursor = report.next_cursor.as_deref().unwrap_or("?");
9383            format!(" (truncated, next_cursor: {cursor})")
9384        } else {
9385            String::new()
9386        };
9387        print_json_or_envelope(
9388            report,
9389            &format,
9390            "graph-db",
9391            "evidence",
9392            ToolEnvelopeSummary {
9393                text: format!(
9394                    "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}",
9395                    report.target,
9396                    report.worker_context.len(),
9397                    report.source_handles.len(),
9398                    report.worker_results.len(),
9399                    report.semantic_related.len(),
9400                    report.shortest_paths.len()
9401                ),
9402                metrics: vec![
9403                    envelope_metric("backend", &report.backend),
9404                    envelope_metric("worker_context", report.worker_context.len()),
9405                    envelope_metric("source_handles", report.source_handles.len()),
9406                    envelope_metric("worker_results", report.worker_results.len()),
9407                    envelope_metric("semantic_related", report.semantic_related.len()),
9408                    envelope_metric("paths", report.shortest_paths.len()),
9409                ],
9410            },
9411            report.truncated,
9412            report.next_commands.clone(),
9413        )
9414    } else {
9415        print_graph_db_evidence_human(report);
9416        Ok(())
9417    }
9418}
9419
9420pub(crate) fn graph_db_report_from_store(
9421    root: &Path,
9422    scope: Option<&str>,
9423    backend: &str,
9424    query: GraphDbQuery,
9425    store: &impl GraphStore,
9426    freshness: GraphDbFreshnessReport,
9427    warnings: Vec<String>,
9428) -> Result<GraphDbReport> {
9429    if freshness.fail_closed {
9430        bail!(
9431            "graph database read failed closed for {} backend: {}",
9432            backend,
9433            freshness.diagnostics.join("; ")
9434        );
9435    }
9436    let mut report = GraphDbReport {
9437        root: root.to_string_lossy().to_string(),
9438        scope: scope.map(str::to_string),
9439        backend: backend.to_string(),
9440        query: format!("{query:?}"),
9441        freshness,
9442        readiness: None,
9443        schema: None,
9444        node: None,
9445        edge: None,
9446        nodes: Vec::new(),
9447        edges: Vec::new(),
9448        ranked_neighbors: Vec::new(),
9449        semantic_related: Vec::new(),
9450        neighborhood_ranking_gate: None,
9451        ranked_neighborhood_comparison: None,
9452        knowledge_retrieval: None,
9453        output_budget: None,
9454        path: None,
9455        page: None,
9456        warnings,
9457    };
9458
9459    match query {
9460        GraphDbQuery::Refresh => {
9461            bail!("graph-db refresh must be handled by the refresh command path");
9462        }
9463        GraphDbQuery::Status => {
9464            bail!("graph-db status must be handled by the status command path");
9465        }
9466        GraphDbQuery::Doctor => {
9467            bail!("graph-db doctor must be handled by the doctor command path");
9468        }
9469        GraphDbQuery::Drift => {
9470            bail!("graph-db drift must be handled by the drift command path");
9471        }
9472        GraphDbQuery::Compact { .. } => {
9473            bail!("graph-db compact must be handled by the compact command path");
9474        }
9475        GraphDbQuery::BackendEval { .. } => {
9476            bail!("graph-db backend-eval must be handled by the benchmark command path");
9477        }
9478        GraphDbQuery::Evidence { .. } => {
9479            bail!("graph-db evidence must be handled by the evidence command path");
9480        }
9481        GraphDbQuery::Related {
9482            query,
9483            kind,
9484            depth,
9485            seed_limit,
9486            limit,
9487        } => {
9488            let semantic =
9489                semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
9490            let SemanticRelatedReport {
9491                items,
9492                warnings: semantic_warnings,
9493                ..
9494            } = semantic;
9495            let readiness = graph_db_semantic_readiness(
9496                root,
9497                scope,
9498                (!items.is_empty()).then_some(items.len()),
9499            );
9500            report.warnings.extend(semantic_warnings);
9501            let seed_ids = items
9502                .iter()
9503                .map(|item| item.handle.clone())
9504                .collect::<Vec<_>>();
9505            let semantic_scores = items
9506                .iter()
9507                .map(|item| (item.handle.clone(), item.score))
9508                .collect::<BTreeMap<_, _>>();
9509            let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
9510            let seed_count = seed_ids.len();
9511            let mut diagnostics = subgraph.diagnostics;
9512            let budgeted = graph_db_apply_output_budget(
9513                &seed_ids,
9514                &semantic_scores,
9515                subgraph.nodes,
9516                subgraph.edges,
9517                Some(limit),
9518            );
9519            let budget_report = budgeted.report;
9520            let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
9521            diagnostics.extend(budget_report.diagnostics.clone());
9522            diagnostics.extend(readiness.diagnostics.clone());
9523
9524            report.readiness = Some(readiness);
9525            report.semantic_related = items;
9526            if let Some(seed_id) = seed_ids.first() {
9527                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
9528                report.ranked_neighbors = graph_db_ranked_neighbors(
9529                    seed_id,
9530                    &budgeted.nodes,
9531                    &budgeted.edges,
9532                    ranked_neighbor_cap,
9533                );
9534                report.neighborhood_ranking_gate =
9535                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9536            }
9537            report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9538            report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9539            report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
9540                mode: "semantic_seeded_neighborhood".to_string(),
9541                query,
9542                seed_kind: semantic_related_kind_name(kind).to_string(),
9543                seed_limit,
9544                seed_count,
9545                depth,
9546                limit,
9547                node_count: report.nodes.len(),
9548                edge_count: report.edges.len(),
9549                truncated: subgraph.truncated || dropped_by_budget,
9550                traversal: "incident_plus_outgoing_edges".to_string(),
9551                freshness_boundary:
9552                    "semantic rows must come from refreshed summary or tsift-memory graph records"
9553                        .to_string(),
9554                privacy_boundary:
9555                    "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
9556                        .to_string(),
9557                diagnostics,
9558            });
9559            report.output_budget = Some(budget_report);
9560        }
9561        GraphDbQuery::Schema => {
9562            report.schema = Some(graph_db_schema());
9563        }
9564        GraphDbQuery::Node { id } => {
9565            report.node = store.node(&id)?.map(Into::into);
9566        }
9567        GraphDbQuery::Edge { id } => {
9568            report.edge = store.edge(&id)?.map(Into::into);
9569        }
9570        GraphDbQuery::Edges {
9571            edge_kind,
9572            cursor,
9573            limit,
9574            property_filters,
9575        } => {
9576            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9577            let paged = store.paged_edges(
9578                edge_kind.as_deref(),
9579                graph_db_query_options_for_store(&options),
9580            )?;
9581            report.edges = paged.edges.into_iter().map(Into::into).collect();
9582            report.page = Some(graph_db_page_report_from_store(
9583                paged.page,
9584                options.property_filters,
9585            ));
9586        }
9587        GraphDbQuery::Incident {
9588            id,
9589            edge_kind,
9590            cursor,
9591            limit,
9592            property_filters,
9593        } => {
9594            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9595            let paged = store.paged_incident_edges(
9596                &id,
9597                edge_kind.as_deref(),
9598                graph_db_query_options_for_store(&options),
9599            )?;
9600            report.edges = paged.edges.into_iter().map(Into::into).collect();
9601            report.page = Some(graph_db_page_report_from_store(
9602                paged.page,
9603                options.property_filters,
9604            ));
9605        }
9606        GraphDbQuery::Kind {
9607            kind,
9608            cursor,
9609            limit,
9610            property_filters,
9611        } => {
9612            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9613            let paged =
9614                store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
9615            report.nodes = paged.nodes.into_iter().map(Into::into).collect();
9616            report.edges = paged.edges.into_iter().map(Into::into).collect();
9617            report.page = Some(graph_db_page_report_from_store(
9618                paged.page,
9619                options.property_filters,
9620            ));
9621        }
9622        GraphDbQuery::Neighborhood {
9623            id,
9624            depth,
9625            edge_kind,
9626            cursor,
9627            limit,
9628            property_filters,
9629        } => {
9630            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9631            if let Some(paged) = store.paged_neighborhood(
9632                &id,
9633                depth,
9634                edge_kind.as_deref(),
9635                graph_db_query_options_for_store(&options),
9636            )? {
9637                let budgeted = graph_db_apply_output_budget(
9638                    std::slice::from_ref(&id),
9639                    &BTreeMap::new(),
9640                    paged.nodes,
9641                    paged.edges,
9642                    options.limit,
9643                );
9644                let budget_report = budgeted.report;
9645                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
9646                let ranked_neighbors = graph_db_ranked_neighbors(
9647                    &id,
9648                    &budgeted.nodes,
9649                    &budgeted.edges,
9650                    ranked_neighbor_cap,
9651                );
9652                let comparison = graph_db_ranked_neighborhood_comparison(
9653                    &id,
9654                    depth,
9655                    edge_kind.as_deref(),
9656                    options.limit,
9657                    &budgeted.nodes,
9658                    &budgeted.edges,
9659                    store,
9660                )?;
9661                report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9662                report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9663                report.ranked_neighbors = ranked_neighbors;
9664                report.neighborhood_ranking_gate =
9665                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9666                let mut page =
9667                    graph_db_page_report_from_store(paged.page, options.property_filters);
9668                page.returned_nodes = report.nodes.len();
9669                page.returned_edges = report.edges.len();
9670                page.truncated |= !budget_report.dropped_by_budget.is_empty();
9671                page.diagnostics.extend(budget_report.diagnostics.clone());
9672                report.page = Some(page);
9673                report.output_budget = Some(budget_report);
9674                if let Some(comparison) = comparison {
9675                    report.ranked_neighborhood_comparison = Some(comparison);
9676                }
9677            }
9678        }
9679        GraphDbQuery::Path {
9680            from,
9681            to,
9682            edge_kind,
9683            max_hops,
9684        } => {
9685            report.path =
9686                store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
9687            if let Some(max_hops) = max_hops
9688                && report.path.is_none()
9689            {
9690                report.warnings.push(format!(
9691                    "no directed path found within --max-hops {}",
9692                    max_hops
9693                ));
9694            }
9695        }
9696        GraphDbQuery::Map { .. } => {
9697            bail!("graph-db map must be handled by the map command path");
9698        }
9699    }
9700    Ok(report)
9701}
9702
9703pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
9704    if compact {
9705        println!(
9706            "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
9707            report.backend,
9708            report.query,
9709            report.nodes.len() + usize::from(report.node.is_some()),
9710            report.edges.len() + usize::from(report.edge.is_some()),
9711            report.freshness.status
9712        );
9713        return;
9714    }
9715    println!("graph-db backend: {}", report.backend);
9716    println!("freshness: {}", report.freshness.status);
9717    if let Some(readiness) = &report.readiness {
9718        println!(
9719            "readiness: {} reason: {} fail_closed: {}",
9720            readiness.status, readiness.reason, readiness.fail_closed
9721        );
9722        for diagnostic in &readiness.diagnostics {
9723            println!("readiness diagnostic: {diagnostic}");
9724        }
9725        for command in &readiness.next_commands {
9726            println!("readiness next: {command}");
9727        }
9728    }
9729    if let Some(schema) = &report.schema {
9730        println!(
9731            "schema: {} node fields, {} edge fields, {} operations",
9732            schema.node_fields.len(),
9733            schema.edge_fields.len(),
9734            schema.operations.len()
9735        );
9736    }
9737    if let Some(node) = &report.node {
9738        println!("node: {} [{}] {}", node.id, node.kind, node.label);
9739    }
9740    if let Some(edge) = &report.edge {
9741        let edge_full: SubstrateGraphEdge = edge.into();
9742        println!(
9743            "edge: {} {} -{}-> {}",
9744            graph_db_edge_key(&edge_full),
9745            edge.from_id,
9746            edge.kind,
9747            edge.to_id
9748        );
9749    }
9750    if let Some(knowledge) = &report.knowledge_retrieval {
9751        println!(
9752            "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
9753            knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
9754        );
9755    }
9756    for item in &report.semantic_related {
9757        println!(
9758            "semantic_seed: {:.3} [{}] {} ({})",
9759            item.score, item.kind, item.label, item.handle
9760        );
9761    }
9762    for node in &report.nodes {
9763        println!("node: {} [{}] {}", node.id, node.kind, node.label);
9764    }
9765    for edge in &report.edges {
9766        let edge_full: SubstrateGraphEdge = edge.into();
9767        println!(
9768            "edge: {} {} -{}-> {}",
9769            graph_db_edge_key(&edge_full),
9770            edge.from_id,
9771            edge.kind,
9772            edge.to_id
9773        );
9774    }
9775    for neighbor in &report.ranked_neighbors {
9776        println!(
9777            "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
9778            neighbor.rank,
9779            neighbor.score,
9780            neighbor
9781                .depth
9782                .map(|depth| depth.to_string())
9783                .unwrap_or_else(|| "unknown".to_string()),
9784            neighbor.node_id,
9785            neighbor.kind,
9786            neighbor.label
9787        );
9788    }
9789    if let Some(gate) = &report.neighborhood_ranking_gate {
9790        println!(
9791            "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
9792            gate.status, gate.default_order, gate.ranked_output_default
9793        );
9794    }
9795    if let Some(path) = &report.path {
9796        println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
9797    }
9798    if let Some(page) = &report.page {
9799        if let Some(next_cursor) = &page.next_cursor {
9800            println!("next_cursor: {next_cursor}");
9801        }
9802        for diagnostic in &page.diagnostics {
9803            println!("page: {diagnostic}");
9804        }
9805    }
9806    for warning in &report.warnings {
9807        println!("warning: {warning}");
9808    }
9809}
9810
9811pub(crate) fn graph_db_backend_eval_phase_timing(
9812    name: &str,
9813    duration_micros: u128,
9814    detail: &str,
9815) -> GraphDbBackendEvalPhaseTiming {
9816    GraphDbBackendEvalPhaseTiming {
9817        name: name.to_string(),
9818        duration_micros,
9819        detail: detail.to_string(),
9820    }
9821}
9822
9823pub(crate) fn graph_db_backend_eval_timed_phase<T>(
9824    phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
9825    name: &str,
9826    detail: &str,
9827    run: impl FnOnce() -> Result<T>,
9828) -> Result<T> {
9829    let started = Instant::now();
9830    let result = run();
9831    phases.push(graph_db_backend_eval_phase_timing(
9832        name,
9833        started.elapsed().as_micros(),
9834        detail,
9835    ));
9836    result
9837}
9838
9839pub(crate) fn graph_db_backend_eval_refresh_total_micros(
9840    phases: &[GraphDbBackendEvalPhaseTiming],
9841) -> u128 {
9842    phases
9843        .iter()
9844        .filter(|phase| phase.name != "conflict_matrix_preparation")
9845        .map(|phase| phase.duration_micros)
9846        .sum()
9847}
9848
9849pub(crate) fn graph_db_backend_eval_cached_refresh(
9850    root: &Path,
9851    scope: Option<&str>,
9852    source_watermark: Option<&str>,
9853) -> Result<
9854    Option<(
9855        TraversalGraphBuild,
9856        SqliteProjectionRefresh,
9857        Vec<GraphDbBackendEvalPhaseTiming>,
9858    )>,
9859> {
9860    let Some(source_watermark) = source_watermark else {
9861        return Ok(None);
9862    };
9863    let graph_db = graph_substrate_db_path(root, scope);
9864    if !graph_db.exists() {
9865        return Ok(None);
9866    }
9867
9868    let started = Instant::now();
9869    let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
9870        Ok(store) => store,
9871        Err(_) => return Ok(None),
9872    };
9873    if store.has_user_triggers().unwrap_or(true) {
9874        return Ok(None);
9875    }
9876    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
9877    if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
9878        return Ok(None);
9879    }
9880
9881    let phases = vec![
9882        graph_db_backend_eval_phase_timing(
9883            "source_graph_build",
9884            started.elapsed().as_micros(),
9885            "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
9886        ),
9887        graph_db_backend_eval_phase_timing(
9888            "projection_rows",
9889            0,
9890            "reused cached provider-neutral projection rows from graph.db",
9891        ),
9892        graph_db_backend_eval_phase_timing(
9893            "sqlite_open",
9894            0,
9895            "reused existing graph.db projection without opening a write transaction",
9896        ),
9897    ];
9898    let refresh = SqliteProjectionRefresh {
9899        scope: scope.unwrap_or("root").to_string(),
9900        projection_version: freshness
9901            .projection_version
9902            .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
9903        source_watermark: Some(source_watermark.to_string()),
9904        tombstoned_nodes: Vec::new(),
9905        tombstoned_edges: Vec::new(),
9906        upserted_nodes: 0,
9907        upserted_edges: 0,
9908        unchanged_nodes: 0,
9909        unchanged_edges: 0,
9910        upserted_properties: 0,
9911        unchanged_properties: 0,
9912        deleted_properties: 0,
9913        deleted_nodes: 0,
9914        deleted_edges: 0,
9915        pruned_tombstones: 0,
9916        file_size_bytes_before: None,
9917        file_size_bytes_after: None,
9918        phase_timings: Vec::new(),
9919    };
9920    Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
9921}
9922
9923pub(crate) fn graph_db_backend_eval_reused_cached_projection(
9924    phases: &[GraphDbBackendEvalPhaseTiming],
9925) -> bool {
9926    phases.iter().any(|phase| {
9927        phase.name == "source_graph_build"
9928            && phase.detail.contains("reused current graph.db projection")
9929    })
9930}
9931
9932pub(crate) fn graph_db_backend_eval_update_source_watermark(
9933    root: &Path,
9934    path_hint: &Path,
9935    scope: Option<&str>,
9936) -> Result<()> {
9937    let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
9938        return Ok(());
9939    };
9940    let graph_db = graph_substrate_db_path(root, scope);
9941    let mut store = SqliteGraphStore::open(&graph_db)?;
9942    store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
9943    Ok(())
9944}
9945
9946pub(crate) fn graph_db_backend_eval_refresh_with_profile(
9947    root: &Path,
9948    path_hint: &Path,
9949    scope: Option<&str>,
9950) -> Result<(
9951    TraversalGraphBuild,
9952    SqliteProjectionRefresh,
9953    Vec<GraphDbBackendEvalPhaseTiming>,
9954)> {
9955    let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
9956    if let Some(cached) =
9957        graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
9958    {
9959        return Ok(cached);
9960    }
9961
9962    let mut phases = Vec::new();
9963    let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
9964        "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"
9965    } else {
9966        "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
9967    };
9968    let source_graph = graph_db_backend_eval_timed_phase(
9969        &mut phases,
9970        "source_graph_build",
9971        source_graph_detail,
9972        || build_traversal_graph_source_with_options(root, path_hint, scope, false),
9973    )?;
9974    let projection = graph_db_backend_eval_timed_phase(
9975        &mut phases,
9976        "projection_rows",
9977        "provider-neutral GraphStore node/edge row construction before SQLite persistence",
9978        || traversal_projection_from_graph(root, scope, &source_graph),
9979    )?;
9980    let graph_db = graph_substrate_db_path(root, scope);
9981    let mut store = graph_db_backend_eval_timed_phase(
9982        &mut phases,
9983        "sqlite_open",
9984        "open the local SQLite graph.db with WAL and busy-timeout settings",
9985        || SqliteGraphStore::open(&graph_db),
9986    )?;
9987    let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
9988        .ok()
9989        .flatten();
9990    let refresh = store.replace_projection_with_version(
9991        scope.unwrap_or("root"),
9992        &projection,
9993        Some(GRAPH_PROJECTION_VERSION),
9994        refreshed_source_watermark
9995            .or(source_watermark)
9996            .or_else(|| graph_projection_content_hash(&projection)),
9997    )?;
9998    phases.extend(
9999        refresh
10000            .phase_timings
10001            .iter()
10002            .map(|phase| GraphDbBackendEvalPhaseTiming {
10003                name: phase.name.clone(),
10004                duration_micros: phase.duration_micros,
10005                detail: phase.detail.clone(),
10006            }),
10007    );
10008    Ok((source_graph, refresh, phases))
10009}
10010
10011fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10012    root.join(".tsift/backend-eval-cache")
10013}
10014
10015fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10016    graph_db_backend_eval_disk_cache_dir(root)
10017        .join(kind)
10018        .join(format!("{key}.json.gz"))
10019}
10020
10021fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10022    graph_db_backend_eval_disk_cache_dir(root)
10023        .join(kind)
10024        .join(format!("{key}.json"))
10025}
10026
10027#[derive(Default, Clone)]
10028struct GraphDbBackendEvalDiskCacheReadProfile {
10029    file_read_micros: u128,
10030    gzip_decode_micros: u128,
10031    serde_decode_micros: u128,
10032    legacy: bool,
10033}
10034
10035fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10036    root: &Path,
10037    kind: &str,
10038    key: &str,
10039) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10040    let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10041    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10042    let read_started = Instant::now();
10043    let read_result = fs::read(&path);
10044    profile.file_read_micros = read_started.elapsed().as_micros();
10045    if let Ok(bytes) = read_result {
10046        let decode_started = Instant::now();
10047        let mut decoder = GzDecoder::new(bytes.as_slice());
10048        let mut decoded = Vec::new();
10049        let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10050        profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10051        if decode_ok {
10052            let serde_started = Instant::now();
10053            let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10054            profile.serde_decode_micros = serde_started.elapsed().as_micros();
10055            if let Some(value) = parsed {
10056                return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10057            }
10058        }
10059    }
10060
10061    let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10062    let legacy_started = Instant::now();
10063    let bytes = fs::read(legacy_path).ok()?;
10064    profile.file_read_micros = profile
10065        .file_read_micros
10066        .saturating_add(legacy_started.elapsed().as_micros());
10067    let serde_started = Instant::now();
10068    let value = serde_json::from_slice(&bytes).ok()?;
10069    profile.serde_decode_micros = profile
10070        .serde_decode_micros
10071        .saturating_add(serde_started.elapsed().as_micros());
10072    profile.legacy = true;
10073    Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10074}
10075
10076#[derive(Default, Clone)]
10077struct GraphDbBackendEvalDiskCacheWriteProfile {
10078    serde_encode_micros: u128,
10079    gzip_encode_micros: u128,
10080    file_write_micros: u128,
10081}
10082
10083fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10084    root: &Path,
10085    kind: &str,
10086    key: &str,
10087    value: &T,
10088) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10089    let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10090    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10091    let parent = path.parent()?;
10092    if fs::create_dir_all(parent).is_err() {
10093        return None;
10094    }
10095    let serde_started = Instant::now();
10096    let bytes = serde_json::to_vec(value).ok()?;
10097    profile.serde_encode_micros = serde_started.elapsed().as_micros();
10098    let gzip_started = Instant::now();
10099    let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10100    if encoder.write_all(&bytes).is_err() {
10101        return None;
10102    }
10103    let encoded = encoder.finish().ok()?;
10104    profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10105    let write_started = Instant::now();
10106    if fs::write(&path, &encoded).is_err() {
10107        return None;
10108    }
10109    profile.file_write_micros = write_started.elapsed().as_micros();
10110    Some((encoded.len() as u64, bytes.len() as u64, profile))
10111}
10112
10113fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10114    let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10115    let Ok(entries) = fs::read_dir(dir) else {
10116        return (0, 0);
10117    };
10118    let keep_name = format!("{keep_key}.json.gz");
10119    let mut pruned_files = 0usize;
10120    let mut pruned_bytes = 0u64;
10121    for entry in entries.flatten() {
10122        let path = entry.path();
10123        if !path.is_file() {
10124            continue;
10125        }
10126        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10127            continue;
10128        };
10129        if name == keep_name {
10130            continue;
10131        }
10132        let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10133        if !is_backend_eval_cache {
10134            continue;
10135        }
10136        let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10137        if fs::remove_file(&path).is_ok() {
10138            pruned_files += 1;
10139            pruned_bytes += bytes;
10140        }
10141    }
10142    (pruned_files, pruned_bytes)
10143}
10144
10145fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10146    root: &Path,
10147    source_root: &Path,
10148) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10149    let mut rows = Vec::new();
10150    let mut entries = walk::walk_files(source_root)?;
10151    entries.sort_by(|left, right| left.path.cmp(&right.path));
10152    for entry in entries {
10153        if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10154            continue;
10155        }
10156        if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10157            continue;
10158        }
10159        let bytes = fs::read(&entry.path)
10160            .with_context(|| format!("reading source input {}", entry.path.display()))?;
10161        rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10162            path: traversal_watermark_path(root, &entry.path),
10163            bytes: bytes.len() as u64,
10164            content_hash: content_hash(&bytes)?,
10165        });
10166    }
10167    Ok(rows)
10168}
10169
10170fn graph_db_backend_eval_full_projection_source_watermark(
10171    root: &Path,
10172    scope: Option<&str>,
10173) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10174    let path_hint = root;
10175    let mut detail_parts = Vec::new();
10176    let mut parts = vec![
10177        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10178        format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10179        "watermark_kind:stable_full_projection_inputs".to_string(),
10180        format!("scope:{}", scope.unwrap_or("root")),
10181        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10182    ];
10183
10184    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10185    match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10186        Some(db_path) => {
10187            let db = index::IndexDb::open_read_only_resilient(db_path)?;
10188            parts.push("index_mode:indexed".to_string());
10189            detail_parts.push("mode=indexed".to_string());
10190            parts.push(format!(
10191                "index_source_root:{}",
10192                traversal_watermark_path(root, &gate.source_root)
10193            ));
10194
10195            let symbols = db
10196                .all_symbols()?
10197                .into_iter()
10198                .filter(|symbol| {
10199                    !traversal_path_is_generated_artifact(
10200                        root,
10201                        &gate.source_root,
10202                        Path::new(&symbol.file),
10203                    ) && !traversal_path_is_session_markdown(
10204                        root,
10205                        &gate.source_root,
10206                        Path::new(&symbol.file),
10207                    )
10208                })
10209                .collect::<Vec<_>>();
10210            let symbols_hash = content_hash(&symbols)?;
10211            detail_parts.push(format!("symbols={symbols_hash}"));
10212            parts.push(format!("index_symbols:{symbols_hash}"));
10213
10214            let edges = db
10215                .all_stored_edges()?
10216                .into_iter()
10217                .filter(|edge| {
10218                    !traversal_path_is_generated_artifact(
10219                        root,
10220                        &gate.source_root,
10221                        Path::new(&edge.caller_file),
10222                    ) && !traversal_path_is_session_markdown(
10223                        root,
10224                        &gate.source_root,
10225                        Path::new(&edge.caller_file),
10226                    )
10227                })
10228                .collect::<Vec<_>>();
10229            let edges_hash = content_hash(&edges)?;
10230            detail_parts.push(format!("call_edges={edges_hash}"));
10231            parts.push(format!("index_call_edges:{edges_hash}"));
10232
10233            let routes = db
10234                .all_routes()?
10235                .into_iter()
10236                .filter(|route| {
10237                    !traversal_path_is_generated_artifact(
10238                        root,
10239                        &gate.source_root,
10240                        Path::new(&route.file),
10241                    ) && !traversal_path_is_session_markdown(
10242                        root,
10243                        &gate.source_root,
10244                        Path::new(&route.file),
10245                    )
10246                })
10247                .collect::<Vec<_>>();
10248            let routes_hash = content_hash(&routes)?;
10249            detail_parts.push(format!("routes={routes_hash}"));
10250            parts.push(format!("index_routes:{routes_hash}"));
10251        }
10252        None => {
10253            parts.push("index_mode:raw_fallback".to_string());
10254            detail_parts.push("mode=raw_fallback".to_string());
10255            parts.push(format!(
10256                "raw_source_root:{}",
10257                traversal_watermark_path(root, &gate.source_root)
10258            ));
10259            let raw_rows =
10260                graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
10261            let raw_hash = content_hash(&raw_rows)?;
10262            detail_parts.push(format!("raw_source_files={raw_hash}"));
10263            parts.push(format!("raw_source_files:{raw_hash}"));
10264        }
10265    }
10266
10267    parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
10268    detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
10269    let summaries_start = parts.len();
10270    push_traversal_summaries_watermark_part(root, &mut parts)?;
10271    let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
10272    detail_parts.push(format!("summaries={summaries_hash}"));
10273    let value = content_hash(&parts)?;
10274    detail_parts.push(format!("watermark={value}"));
10275    Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
10276        value,
10277        detail: detail_parts.join(" "),
10278    })
10279}
10280
10281fn graph_db_backend_eval_full_projection_cache_key(
10282    root: &Path,
10283    scope: Option<&str>,
10284) -> Result<(String, String, String)> {
10285    let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
10286    let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10287        root,
10288        scope,
10289        &source_watermark.value,
10290    )?;
10291    Ok((source_watermark.value, key, source_watermark.detail))
10292}
10293
10294fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
10295    root: &Path,
10296    scope: Option<&str>,
10297    source_watermark: &str,
10298) -> Result<String> {
10299    content_hash(&serde_json::json!({
10300    "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
10301    "root": root.display().to_string(),
10302    "scope": scope.unwrap_or("root"),
10303    "source_watermark": source_watermark,
10304    }))
10305}
10306
10307pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
10308    root: &Path,
10309    scope: Option<&str>,
10310) -> Result<(
10311    GraphProjection,
10312    Vec<String>,
10313    Vec<GraphDbBackendEvalPhaseTiming>,
10314    GraphDbBackendEvalFullProjectionCacheStats,
10315)> {
10316    let (source_watermark, key, source_watermark_detail) =
10317        graph_db_backend_eval_full_projection_cache_key(root, scope)?;
10318    let lookup_started = Instant::now();
10319    if let Some((cached, disk_bytes, json_bytes, read_profile)) =
10320        graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
10321            root,
10322            "full_projection",
10323            &key,
10324        )
10325        && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
10326        && cached.key == key
10327        && cached.source_watermark == source_watermark
10328    {
10329        let lookup_overhead_micros = lookup_started
10330            .elapsed()
10331            .as_micros()
10332            .saturating_sub(read_profile.file_read_micros)
10333            .saturating_sub(read_profile.gzip_decode_micros)
10334            .saturating_sub(read_profile.serde_decode_micros);
10335        let prune_started = Instant::now();
10336        let (pruned_files, pruned_bytes) =
10337            graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
10338        let prune_micros = prune_started.elapsed().as_micros();
10339        let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
10340            hit: true,
10341            disk_bytes,
10342            json_bytes,
10343            pruned_files,
10344            pruned_bytes,
10345        };
10346        let read_detail_suffix = if read_profile.legacy {
10347            " (legacy uncompressed cache path)"
10348        } else {
10349            ""
10350        };
10351        return Ok((
10352            cached.projection,
10353            cached.warnings,
10354            vec![
10355                graph_db_backend_eval_phase_timing(
10356                    "full_projection.cache_lookup",
10357                    lookup_overhead_micros,
10358                    &format!(
10359                        "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
10360                    ),
10361                ),
10362                graph_db_backend_eval_phase_timing(
10363                    "full_projection.cache.file_read",
10364                    read_profile.file_read_micros,
10365                    &format!(
10366                        "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
10367                    ),
10368                ),
10369                graph_db_backend_eval_phase_timing(
10370                    "full_projection.cache.gzip_decode",
10371                    read_profile.gzip_decode_micros,
10372                    "gunzip the compressed projection cache bytes",
10373                ),
10374                graph_db_backend_eval_phase_timing(
10375                    "full_projection.cache.serde_decode",
10376                    read_profile.serde_decode_micros,
10377                    "serde_json deserialize the decoded projection cache payload",
10378                ),
10379                graph_db_backend_eval_phase_timing(
10380                    "full_projection.cache.prune",
10381                    prune_micros,
10382                    "prune sibling cache files older than the current key",
10383                ),
10384                graph_db_backend_eval_phase_timing(
10385                    "full_projection.source_graph_build",
10386                    0,
10387                    "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10388                ),
10389                graph_db_backend_eval_phase_timing(
10390                    "full_projection.projection_rows",
10391                    0,
10392                    "reused cached provider-neutral full-project projection rows",
10393                ),
10394            ],
10395            cache_stats,
10396        ));
10397    }
10398
10399    let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
10400    let mut phases = vec![graph_db_backend_eval_phase_timing(
10401        "full_projection.cache_lookup",
10402        lookup_started.elapsed().as_micros(),
10403        &format!(
10404            "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
10405        ),
10406    )];
10407    let full_source = graph_db_backend_eval_timed_phase(
10408        &mut phases,
10409        "full_projection.source_graph_build",
10410        "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
10411        || build_traversal_graph_source_with_options(root, root, scope, false),
10412    )?;
10413    let projection = graph_db_backend_eval_timed_phase(
10414        &mut phases,
10415        "full_projection.projection_rows",
10416        "provider-neutral row construction for the opt-in full-project projection dataset",
10417        || traversal_projection_from_graph(root, scope, &full_source),
10418    )?;
10419    let warnings = full_source.warnings;
10420    let refreshed_source_watermark =
10421        graph_db_backend_eval_full_projection_source_watermark(root, scope)
10422            .map(|watermark| watermark.value)
10423            .unwrap_or_else(|_| source_watermark.clone());
10424    let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10425        root,
10426        scope,
10427        &refreshed_source_watermark,
10428    )?;
10429    let cache = GraphDbBackendEvalFullProjectionCache {
10430        version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
10431        key: write_key.clone(),
10432        source_watermark: refreshed_source_watermark,
10433        projection: projection.clone(),
10434        warnings: warnings.clone(),
10435    };
10436    if let Some((disk_bytes, json_bytes, write_profile)) =
10437        graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
10438    {
10439        cache_stats.disk_bytes = disk_bytes;
10440        cache_stats.json_bytes = json_bytes;
10441        phases.push(graph_db_backend_eval_phase_timing(
10442            "full_projection.cache.serde_encode",
10443            write_profile.serde_encode_micros,
10444            "serde_json serialize the projection cache payload before compression",
10445        ));
10446        phases.push(graph_db_backend_eval_phase_timing(
10447            "full_projection.cache.gzip_encode",
10448            write_profile.gzip_encode_micros,
10449            "gzip-compress the serialized projection cache payload",
10450        ));
10451        phases.push(graph_db_backend_eval_phase_timing(
10452            "full_projection.cache.file_write",
10453            write_profile.file_write_micros,
10454            "write the compressed projection cache bytes to .tsift/backend-eval-cache",
10455        ));
10456    }
10457    let prune_started = Instant::now();
10458    let (pruned_files, pruned_bytes) =
10459        graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
10460    phases.push(graph_db_backend_eval_phase_timing(
10461        "full_projection.cache.prune",
10462        prune_started.elapsed().as_micros(),
10463        "prune sibling cache files older than the current key",
10464    ));
10465    cache_stats.pruned_files = pruned_files;
10466    cache_stats.pruned_bytes = pruned_bytes;
10467    Ok((projection, warnings, phases, cache_stats))
10468}
10469
10470fn graph_db_backend_eval_timed(
10471    name: &str,
10472    run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
10473) -> (
10474    GraphDbBackendEvalOperation,
10475    Option<GraphDbBackendEvalSignature>,
10476) {
10477    let started = Instant::now();
10478    match run() {
10479        Ok((rows, value)) => (
10480            GraphDbBackendEvalOperation {
10481                name: name.to_string(),
10482                supported: true,
10483                status: "ok".to_string(),
10484                duration_micros: started.elapsed().as_micros(),
10485                rows,
10486                error: None,
10487            },
10488            Some(GraphDbBackendEvalSignature {
10489                operation: name.to_string(),
10490                value,
10491            }),
10492        ),
10493        Err(err) => (
10494            GraphDbBackendEvalOperation {
10495                name: name.to_string(),
10496                supported: false,
10497                status: "error".to_string(),
10498                duration_micros: started.elapsed().as_micros(),
10499                rows: None,
10500                error: Some(format!("{err:#}")),
10501            },
10502            None,
10503        ),
10504    }
10505}
10506
10507fn graph_db_backend_eval_parity(
10508    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10509    candidate_signatures: &[GraphDbBackendEvalSignature],
10510) -> GraphDbBackendEvalParity {
10511    let Some(sqlite_signatures) = sqlite_signatures else {
10512        return GraphDbBackendEvalParity {
10513            matches_sqlite: true,
10514            diagnostics: Vec::new(),
10515        };
10516    };
10517    let sqlite = sqlite_signatures
10518        .iter()
10519        .map(|signature| (signature.operation.as_str(), &signature.value))
10520        .collect::<BTreeMap<_, _>>();
10521    let candidate = candidate_signatures
10522        .iter()
10523        .map(|signature| (signature.operation.as_str(), &signature.value))
10524        .collect::<BTreeMap<_, _>>();
10525    let mut diagnostics = Vec::new();
10526    for (operation, sqlite_value) in sqlite {
10527        match candidate.get(operation) {
10528            Some(candidate_value) if *candidate_value == sqlite_value => {}
10529            Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
10530            None => diagnostics.push(format!(
10531                "{operation} did not complete for candidate backend"
10532            )),
10533        }
10534    }
10535    GraphDbBackendEvalParity {
10536        matches_sqlite: diagnostics.is_empty(),
10537        diagnostics,
10538    }
10539}
10540
10541pub(crate) fn graph_db_backend_eval_targets(
10542    store: &impl GraphStore,
10543    requested: &[String],
10544) -> Result<Vec<String>> {
10545    let requested = requested
10546        .iter()
10547        .filter_map(|target| normalize_conflict_target(target))
10548        .collect::<Vec<_>>();
10549    if !requested.is_empty() {
10550        return Ok(requested);
10551    }
10552
10553    for kind in ["backlog", "job_packet"] {
10554        let nodes = store.nodes_by_kind(kind)?;
10555        if let Some(node) = nodes.first() {
10556            if let Some(ref_id) = node.properties.get("ref_id") {
10557                return Ok(vec![ref_id.clone()]);
10558            }
10559            return Ok(vec![node.id.clone()]);
10560        }
10561    }
10562    Ok(Vec::new())
10563}
10564
10565fn graph_db_backend_eval_path_targets(
10566    store: &impl GraphStore,
10567    max_hops: usize,
10568) -> Result<Option<(String, String, usize)>> {
10569    let synthetic_from = "gsym-synthetic-0000";
10570    let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
10571    if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
10572        let outgoing = store.outgoing_edges(synthetic_from, None)?;
10573        if outgoing.len() > 1
10574            && let Some(edge) = outgoing.first()
10575        {
10576            return Ok(Some((
10577                edge.from_id.clone(),
10578                edge.to_id.clone(),
10579                GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10580            )));
10581        }
10582        return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
10583    }
10584
10585    Ok(store.sample_edge(None)?.map(|edge| {
10586        (
10587            edge.from_id,
10588            edge.to_id,
10589            GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10590        )
10591    }))
10592}
10593
10594fn graph_db_backend_eval_path_operation<S: GraphStore>(
10595    store: &S,
10596    configured_max_hops: usize,
10597) -> (
10598    GraphDbBackendEvalOperation,
10599    Option<GraphDbBackendEvalSignature>,
10600) {
10601    let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10602        "path_max_hops".to_string()
10603    } else {
10604        format!("path_max_hops_{configured_max_hops}")
10605    };
10606    graph_db_backend_eval_timed(&operation_name, || {
10607        let (from, to, effective_max_hops) =
10608            graph_db_backend_eval_path_targets(store, configured_max_hops)?
10609                .context("backend-eval path probe requires at least one traversable edge")?;
10610        let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
10611        let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10612            Some(format!(
10613                "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
10614                GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
10615            ))
10616        } else if path.is_none() && effective_max_hops == configured_max_hops {
10617            Some(format!(
10618                "path probe truncated at {configured_max_hops} hops before a route was found"
10619            ))
10620        } else {
10621            None
10622        };
10623        Ok((
10624            path.as_ref().map(|path| path.nodes.len()),
10625            serde_json::json!({
10626                "from": from,
10627                "to": to,
10628                "configured_max_hops": configured_max_hops,
10629                "effective_max_hops": effective_max_hops,
10630                "hops": path.as_ref().map(|path| path.hops),
10631                "nodes": path.as_ref().map(|path| &path.nodes),
10632                "found": path.is_some(),
10633                "warning": warning,
10634            }),
10635        ))
10636    })
10637}
10638
10639fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
10640    store: &S,
10641    depth: usize,
10642    limit: usize,
10643) -> (
10644    GraphDbBackendEvalOperation,
10645    Option<GraphDbBackendEvalSignature>,
10646) {
10647    graph_db_backend_eval_timed("neighborhood", || {
10648        let edge = match store.sample_edge(Some("calls"))? {
10649            Some(edge) => edge,
10650            None => store.sample_edge(None)?.context(
10651                "backend-eval neighborhood probe requires at least one traversable edge",
10652            )?,
10653        };
10654        let page = store
10655            .paged_neighborhood(
10656                &edge.from_id,
10657                depth,
10658                Some(&edge.kind),
10659                GraphQueryOptions {
10660                    limit: Some(limit.max(1)),
10661                    ..GraphQueryOptions::default()
10662                },
10663            )?
10664            .with_context(|| {
10665                format!(
10666                    "backend-eval neighborhood target not found: {}",
10667                    edge.from_id
10668                )
10669            })?;
10670        Ok((
10671            Some(page.nodes.len() + page.edges.len()),
10672            serde_json::json!({
10673                "center": edge.from_id,
10674                "kind": edge.kind,
10675                "depth": depth,
10676                "limit": limit.max(1),
10677                "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10678                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10679                "truncated": page.page.truncated,
10680            }),
10681        ))
10682    })
10683}
10684
10685fn graph_db_backend_eval_related_operation<S: GraphStore>(
10686    root: &Path,
10687    scope: Option<&str>,
10688    store: &S,
10689    depth: usize,
10690    limit: usize,
10691) -> (
10692    GraphDbBackendEvalOperation,
10693    Option<GraphDbBackendEvalSignature>,
10694) {
10695    graph_db_backend_eval_timed("related", || {
10696        let query = "backend evaluation";
10697        let semantic = semantic_related_report_from_store(
10698            root,
10699            scope,
10700            query,
10701            3,
10702            SemanticRelatedKind::All,
10703            store,
10704        )?;
10705        let seed_ids = semantic
10706            .items
10707            .iter()
10708            .map(|item| item.handle.clone())
10709            .collect::<Vec<_>>();
10710        let subgraph =
10711            graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
10712        Ok((
10713            Some(subgraph.nodes.len() + subgraph.edges.len()),
10714            serde_json::json!({
10715                "query": query,
10716                "seed_ids": seed_ids,
10717                "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10718                "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10719                "truncated": subgraph.truncated,
10720                "warnings": semantic.warnings,
10721                "diagnostics": subgraph.diagnostics,
10722            }),
10723        ))
10724    })
10725}
10726
10727fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
10728    serde_json::json!({
10729        "target": report.target,
10730        "target_node_id": report.target_node.id,
10731        "target_kind": report.target_node.kind,
10732        "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
10733        "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
10734        "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
10735        "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
10736        "path_count": report.shortest_paths.len(),
10737    })
10738}
10739
10740fn graph_db_backend_eval_target_resolution_signature(
10741    resolved: &[(String, SubstrateGraphNode)],
10742) -> serde_json::Value {
10743    serde_json::json!({
10744        "targets": resolved.iter().map(|(target, node)| {
10745            serde_json::json!({
10746                "target": target,
10747                "target_node_id": node.id,
10748                "target_kind": node.kind,
10749                "target_label": node.label,
10750            })
10751        }).collect::<Vec<_>>(),
10752    })
10753}
10754
10755fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
10756    serde_json::json!({
10757        "targets": report.targets,
10758        "can_parallel": report.can_parallel,
10759        "fail_closed": report.fail_closed,
10760        "cross_target_parallel_safe": report.cross_target_parallel_safe,
10761        "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
10762        "candidates": report.candidates.iter().map(|candidate| {
10763            serde_json::json!({
10764                "target": candidate.target,
10765                "risk": conflict_risk_label(candidate.risk),
10766                "owned_files": candidate.owned_files,
10767                "owned_symbols": candidate.owned_symbols,
10768                "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
10769                "previously_completed": candidate.previously_completed,
10770                "parallel_safe": candidate.parallel_safe,
10771            })
10772        }).collect::<Vec<_>>(),
10773        "conflicts": report.conflicts.iter().map(|pair| {
10774            serde_json::json!({
10775                "left": pair.left,
10776                "right": pair.right,
10777                "risk": conflict_risk_label(pair.risk),
10778            })
10779        }).collect::<Vec<_>>(),
10780    })
10781}
10782
10783fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
10784    serde_json::json!({
10785        "targets": report.targets,
10786        "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10787        "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
10788        "evidence_packet_ids": report.evidence_packet_ids,
10789        "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
10790        "truncated": report.truncated,
10791    })
10792}
10793
10794fn graph_db_backend_eval_edge_scan_probe(
10795    store: &impl GraphStore,
10796) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
10797    if let Some((edge, filter)) = store.sample_edge_with_property()? {
10798        return Ok((edge, vec![filter]));
10799    }
10800    let edge = store
10801        .sample_edge(None)?
10802        .context("backend-eval edge scan requires at least one edge")?;
10803    Ok((edge, Vec::new()))
10804}
10805
10806#[allow(clippy::too_many_arguments)]
10807fn graph_db_backend_eval_report_for_store<S: GraphStore>(
10808    backend: &str,
10809    adapter: &str,
10810    read_only: bool,
10811    root: &Path,
10812    path: &Path,
10813    scope: Option<&str>,
10814    targets: &[String],
10815    depth: usize,
10816    limit: usize,
10817    impact_limit: usize,
10818    store: &S,
10819    freshness: GraphDbFreshnessReport,
10820    refresh_operation: GraphDbBackendEvalOperation,
10821    refresh_signature: Option<GraphDbBackendEvalSignature>,
10822    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10823    extra_warnings: Vec<String>,
10824    prepared: &ConflictMatrixPreparedInputs,
10825    projection_load: &str,
10826    lock_behavior: &str,
10827    install_portability: &str,
10828) -> (
10829    GraphDbBackendEvalBackendReport,
10830    Vec<GraphDbBackendEvalSignature>,
10831) {
10832    let mut operations = vec![refresh_operation];
10833    let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
10834
10835    let (operation, signature) = graph_db_backend_eval_timed("status", || {
10836        let (nodes, edges) = store.graph_counts()?;
10837        Ok((
10838            Some(nodes + edges),
10839            serde_json::json!({
10840                "freshness": freshness.status,
10841                "nodes": nodes,
10842                "edges": edges,
10843            }),
10844        ))
10845    });
10846    operations.push(operation);
10847    signatures.extend(signature);
10848
10849    let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
10850        let edge = store
10851            .sample_edge(None)?
10852            .context("backend-eval edge lookup requires at least one edge")?;
10853        let edge_id = graph_db_edge_key(&edge);
10854        let found = store
10855            .edge(&edge_id)?
10856            .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
10857        Ok((
10858            Some(1),
10859            serde_json::json!({
10860                "edge_id": edge_id,
10861                "from_id": found.from_id,
10862                "to_id": found.to_id,
10863                "kind": found.kind,
10864            }),
10865        ))
10866    });
10867    operations.push(operation);
10868    signatures.extend(signature);
10869
10870    let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
10871        let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
10872        let page = store.paged_edges(
10873            Some(&edge.kind),
10874            GraphQueryOptions {
10875                limit: Some(limit.max(1)),
10876                property_filters: filters.clone(),
10877                ..GraphQueryOptions::default()
10878            },
10879        )?;
10880        Ok((
10881            Some(page.edges.len()),
10882            serde_json::json!({
10883                "kind": edge.kind,
10884                "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
10885                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10886                "truncated": page.page.truncated,
10887            }),
10888        ))
10889    });
10890    operations.push(operation);
10891    signatures.extend(signature);
10892
10893    let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
10894        let edge = store
10895            .sample_edge(None)?
10896            .context("backend-eval incident edge scan requires at least one edge")?;
10897        let page = store.paged_incident_edges(
10898            &edge.from_id,
10899            Some(&edge.kind),
10900            GraphQueryOptions {
10901                limit: Some(limit.max(1)),
10902                ..GraphQueryOptions::default()
10903            },
10904        )?;
10905        Ok((
10906            Some(page.edges.len()),
10907            serde_json::json!({
10908                "node_id": edge.from_id,
10909                "kind": edge.kind,
10910                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10911                "truncated": page.page.truncated,
10912            }),
10913        ))
10914    });
10915    operations.push(operation);
10916    signatures.extend(signature);
10917
10918    let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
10919    operations.push(operation);
10920    signatures.extend(signature);
10921
10922    let (operation, signature) =
10923        graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
10924    operations.push(operation);
10925    signatures.extend(signature);
10926
10927    for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
10928        .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
10929    {
10930        let (operation, signature) =
10931            graph_db_backend_eval_path_operation(store, configured_max_hops);
10932        operations.push(operation);
10933        signatures.extend(signature);
10934    }
10935
10936    let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
10937        let resolved = targets
10938            .iter()
10939            .map(|target| {
10940                let node = graph_db_resolve_evidence_target(store, target)?
10941                    .with_context(|| format!("backend-eval target not found: {target}"))?;
10942                Ok((target.clone(), node))
10943            })
10944            .collect::<Result<Vec<_>>>()?;
10945        let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
10946        Ok((Some(resolved.len()), signature))
10947    });
10948    operations.push(operation);
10949    signatures.extend(signature);
10950
10951    let mut evidence_for_report = None;
10952    let mut graph_snapshot_for_trace = None;
10953    let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
10954        let resolved_targets =
10955            resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
10956        let evidence = collect_conflict_matrix_evidence_packets(
10957            root,
10958            scope,
10959            backend,
10960            &resolved_targets,
10961            depth,
10962            limit,
10963            store,
10964            freshness.clone(),
10965        )?;
10966        let report = &evidence
10967            .first()
10968            .context("backend-eval evidence requires at least one target")?
10969            .report;
10970        let rows = evidence
10971            .iter()
10972            .map(|entry| {
10973                entry.report.worker_context.len()
10974                    + entry.report.source_handles.len()
10975                    + entry.report.worker_results.len()
10976                    + entry.report.semantic_related.len()
10977            })
10978            .sum();
10979        let signature = graph_db_backend_eval_evidence_signature(report);
10980        evidence_for_report = Some((resolved_targets, evidence));
10981        Ok((Some(rows), signature))
10982    });
10983    operations.push(operation);
10984    signatures.extend(signature);
10985
10986    let mut conflict_for_trace = None;
10987    let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
10988        let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
10989            let graph =
10990                conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
10991            let shared_preparation =
10992                conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
10993            ConflictMatrixGraphPreparedInputs {
10994                targets,
10995                graph,
10996                evidence,
10997                shared_preparation,
10998            }
10999        } else {
11000            prepare_conflict_matrix_graph_orchestration(
11001                root,
11002                scope,
11003                backend,
11004                targets,
11005                prepared,
11006                depth,
11007                limit,
11008                store,
11009                freshness.clone(),
11010            )?
11011        };
11012        let report = build_conflict_matrix_report_from_prepared_graph(
11013            root,
11014            path,
11015            scope,
11016            depth,
11017            limit,
11018            impact_limit,
11019            freshness.clone(),
11020            extra_warnings.clone(),
11021            prepared,
11022            &graph_prepared,
11023        )?;
11024        let signature = graph_db_backend_eval_conflict_signature(&report);
11025        let rows = report.candidates.len() + report.conflicts.len();
11026        conflict_for_trace = Some(report);
11027        graph_snapshot_for_trace = Some(graph_prepared.graph);
11028        Ok((Some(rows), signature))
11029    });
11030    operations.push(operation);
11031    signatures.extend(signature);
11032
11033    let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11034        let conflict = conflict_for_trace
11035            .take()
11036            .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11037        let graph = graph_snapshot_for_trace
11038            .take()
11039            .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11040        let report = build_dispatch_trace_report_from_conflict_snapshot(
11041            root,
11042            scope,
11043            conflict,
11044            graph.nodes,
11045            graph.edges,
11046            depth,
11047            limit,
11048            Vec::new(),
11049        )?;
11050        Ok((
11051            Some(report.nodes.len() + report.edges.len()),
11052            graph_db_backend_eval_dispatch_signature(&report),
11053        ))
11054    });
11055    operations.push(operation);
11056    signatures.extend(signature);
11057
11058    let total_micros = operations
11059        .iter()
11060        .map(|operation| operation.duration_micros)
11061        .sum();
11062    let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11063    (
11064        GraphDbBackendEvalBackendReport {
11065            backend: backend.to_string(),
11066            adapter: adapter.to_string(),
11067            read_only,
11068            projection_load: projection_load.to_string(),
11069            operations,
11070            total_micros,
11071            parity,
11072            lock_behavior: lock_behavior.to_string(),
11073            install_portability: install_portability.to_string(),
11074        },
11075        signatures,
11076    )
11077}
11078
11079pub(crate) fn graph_db_backend_eval_refresh_operation(
11080    duration_micros: u128,
11081    rows: usize,
11082    value: serde_json::Value,
11083) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11084    (
11085        GraphDbBackendEvalOperation {
11086            name: "refresh".to_string(),
11087            supported: true,
11088            status: "ok".to_string(),
11089            duration_micros,
11090            rows: Some(rows),
11091            error: None,
11092        },
11093        GraphDbBackendEvalSignature {
11094            operation: "refresh".to_string(),
11095            value,
11096        },
11097    )
11098}
11099
11100pub(crate) fn graph_db_backend_eval_synthetic_projection(
11101    nodes: usize,
11102    fanout: usize,
11103) -> GraphProjection {
11104    let nodes = nodes.max(12);
11105    let symbol_count = nodes.saturating_sub(9).max(1);
11106    let source = GraphProvenance::new("backend-eval", "synthetic");
11107    let mut projection_nodes = vec![
11108        SubstrateGraphNode::new(
11109            "projection:tsift-traversal:synthetic",
11110            GRAPH_PROJECTION_META_KIND,
11111            "synthetic projection",
11112        )
11113        .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11114        .with_property(
11115            "content_hash",
11116            format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11117        )
11118        .with_provenance(source.clone()),
11119        SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11120            .with_property("ref_id", "synthetic-session"),
11121        SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11122            .with_property("ref_id", "synthetic")
11123            .with_property("path", "tasks/software/synthetic.md")
11124            .with_property("line", "1")
11125            .with_property(
11126                "expand",
11127                "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11128            ),
11129        SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11130            .with_property("ref_id", "synthetic"),
11131        SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11132            .with_property("target", "synthetic")
11133            .with_property("summary", "Synthetic worker owns synthetic.rs")
11134            .with_property(
11135                "expand",
11136                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11137            ),
11138        SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11139            .with_property("file", "synthetic.rs")
11140            .with_property("start", "1")
11141            .with_property("end", "80")
11142            .with_property(
11143                "expand",
11144                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11145            ),
11146        SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11147            .with_property("path", "synthetic.rs"),
11148        SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11149            .with_property("handle", "gsem-synthetic")
11150            .with_property("label", "backend evaluation")
11151            .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11152            .with_property(
11153                "embedding",
11154                semantic_embedding_property("backend evaluation"),
11155            ),
11156        SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11157            .with_property("ref_id", "synthetic")
11158            .with_property("status", "completed")
11159            .with_property("touched_files", "synthetic.rs")
11160            .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11161    ];
11162    for idx in 0..symbol_count {
11163        projection_nodes.push(
11164            SubstrateGraphNode::new(
11165                format!("gsym-synthetic-{idx:04}"),
11166                "symbol",
11167                format!("synthetic_symbol_{idx:04}"),
11168            )
11169            .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11170            .with_property("path", "synthetic.rs")
11171            .with_property("line", (idx + 1).to_string()),
11172        );
11173    }
11174
11175    let mut projection_edges = vec![
11176        SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11177        SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11178        SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11179        SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11180        SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11181        SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11182        SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11183        SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11184        SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11185    ];
11186    for idx in 0..symbol_count {
11187        let from = format!("gsym-synthetic-{idx:04}");
11188        for offset in 1..=fanout.max(1).min(symbol_count) {
11189            let to_idx = (idx + offset) % symbol_count;
11190            if to_idx != idx {
11191                projection_edges.push(SubstrateGraphEdge::new(
11192                    from.clone(),
11193                    format!("gsym-synthetic-{to_idx:04}"),
11194                    "calls",
11195                ));
11196            }
11197        }
11198    }
11199
11200    GraphProjection {
11201        nodes: projection_nodes,
11202        edges: projection_edges
11203            .into_iter()
11204            .map(|edge| {
11205                edge.with_property("dataset", "synthetic")
11206                    .with_provenance(source.clone())
11207            })
11208            .collect(),
11209    }
11210}
11211
11212pub(crate) fn graph_db_backend_eval_promotion(
11213    datasets: &[GraphDbBackendEvalDataset],
11214    candidates: &[GraphDbExperimentalBackend],
11215) -> Vec<GraphDbBackendPromotionDecision> {
11216    let mut decisions = Vec::new();
11217    for candidate in candidates {
11218        let mut reasons = Vec::new();
11219        let mut faster_everywhere = true;
11220        let mut parity_everywhere = true;
11221        for dataset in datasets {
11222            let Some(sqlite_report) = dataset
11223                .backends
11224                .iter()
11225                .find(|backend| backend.backend == "sqlite")
11226            else {
11227                parity_everywhere = false;
11228                faster_everywhere = false;
11229                reasons.push(format!(
11230                    "{} dataset is missing SQLite baseline",
11231                    dataset.name
11232                ));
11233                continue;
11234            };
11235            let sqlite_total = sqlite_report.total_micros;
11236            let Some(candidate_report) = dataset
11237                .backends
11238                .iter()
11239                .find(|backend| backend.backend == candidate.name())
11240            else {
11241                parity_everywhere = false;
11242                reasons.push(format!("{} dataset did not run", dataset.name));
11243                continue;
11244            };
11245            if !candidate_report.parity.matches_sqlite {
11246                parity_everywhere = false;
11247                reasons.push(format!("{} parity differed from SQLite", dataset.name));
11248            }
11249            if candidate_report.total_micros >= sqlite_total {
11250                faster_everywhere = false;
11251                reasons.push(format!(
11252                    "{} total {}us did not beat SQLite {}us",
11253                    dataset.name, candidate_report.total_micros, sqlite_total
11254                ));
11255            }
11256            let sqlite_operations = sqlite_report
11257                .operations
11258                .iter()
11259                .map(|operation| (operation.name.as_str(), operation.duration_micros))
11260                .collect::<BTreeMap<_, _>>();
11261            for operation in &candidate_report.operations {
11262                if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
11263                    && operation.duration_micros >= *sqlite_duration
11264                {
11265                    faster_everywhere = false;
11266                    reasons.push(format!(
11267                        "{} {} operation {}us did not beat SQLite {}us",
11268                        dataset.name, operation.name, operation.duration_micros, sqlite_duration
11269                    ));
11270                }
11271            }
11272            if candidate_report
11273                .operations
11274                .iter()
11275                .any(|operation| operation.status != "ok")
11276            {
11277                parity_everywhere = false;
11278                reasons.push(format!("{} has failed benchmark operations", dataset.name));
11279            }
11280        }
11281        let decision = if let Some(reason) = candidate.prototype_hold_reason() {
11282            reasons.push(reason.to_string());
11283            reasons.push(
11284                "current bounded prototype timings are benchmark evidence, not a backend switch approval"
11285                    .to_string(),
11286            );
11287            "hold"
11288        } else if parity_everywhere && faster_everywhere {
11289            reasons.push(
11290                "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
11291                    .to_string(),
11292            );
11293            "eligible"
11294        } else {
11295            reasons.push(
11296                "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
11297                    .to_string(),
11298            );
11299            "hold"
11300        };
11301        decisions.push(GraphDbBackendPromotionDecision {
11302            backend: candidate.name().to_string(),
11303            decision: decision.to_string(),
11304            reasons: dedupe_preserve_order(reasons),
11305            gate: candidate.promotion_gate(),
11306        });
11307    }
11308    decisions
11309}
11310
11311pub(crate) fn graph_db_backend_eval_metrics(
11312    datasets: &[GraphDbBackendEvalDataset],
11313) -> BTreeMap<String, f64> {
11314    let mut metrics = BTreeMap::new();
11315    for dataset in datasets {
11316        let graph_rows = graph_db_backend_eval_graph_rows(dataset);
11317        metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
11318        metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
11319        metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
11320        for backend in &dataset.backends {
11321            let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
11322            metrics.insert(
11323                format!("{prefix}.total_duration_micros"),
11324                backend.total_micros as f64,
11325            );
11326            append_graph_db_backend_eval_normalized_duration_metric(
11327                &mut metrics,
11328                &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
11329                backend.total_micros,
11330                graph_rows,
11331            );
11332            for operation in &backend.operations {
11333                metrics.insert(
11334                    format!("{prefix}.{}.duration_micros", operation.name),
11335                    operation.duration_micros as f64,
11336                );
11337                append_graph_db_backend_eval_normalized_duration_metric(
11338                    &mut metrics,
11339                    &format!(
11340                        "{prefix}.{}.duration_micros_per_1k_graph_rows",
11341                        operation.name
11342                    ),
11343                    operation.duration_micros,
11344                    graph_rows,
11345                );
11346                if let Some(rows) = operation.rows {
11347                    metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
11348                }
11349            }
11350        }
11351    }
11352    metrics
11353}
11354
11355pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
11356    dataset.nodes + dataset.edges
11357}
11358
11359pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
11360    metrics: &mut BTreeMap<String, f64>,
11361    key: &str,
11362    duration_micros: u128,
11363    graph_rows: usize,
11364) {
11365    if graph_rows == 0 {
11366        return;
11367    }
11368    metrics.insert(
11369        key.to_string(),
11370        duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
11371    );
11372}
11373
11374pub(crate) fn append_graph_db_backend_eval_phase_metrics(
11375    metrics: &mut BTreeMap<String, f64>,
11376    dataset: &str,
11377    graph_rows: usize,
11378    phases: &[GraphDbBackendEvalPhaseTiming],
11379) {
11380    for phase in phases {
11381        metrics.insert(
11382            format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
11383            phase.duration_micros as f64,
11384        );
11385        append_graph_db_backend_eval_normalized_duration_metric(
11386            metrics,
11387            &format!(
11388                "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
11389                phase.name
11390            ),
11391            phase.duration_micros,
11392            graph_rows,
11393        );
11394    }
11395}
11396
11397fn graph_db_backend_eval_base_command(
11398    root: &Path,
11399    scope: Option<&str>,
11400    full_projection: bool,
11401) -> String {
11402    let full_projection_arg = if full_projection {
11403        " --full-projection"
11404    } else {
11405        ""
11406    };
11407    format!(
11408        "tsift graph-db --path {}{} --json backend-eval{}",
11409        shell_quote(root.to_string_lossy().as_ref()),
11410        graph_db_scope_arg(scope),
11411        full_projection_arg
11412    )
11413}
11414
11415pub(crate) fn graph_db_backend_eval_metric_digest_command(
11416    root: &Path,
11417    scope: Option<&str>,
11418    full_projection: bool,
11419) -> String {
11420    format!(
11421        "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11422        graph_db_backend_eval_base_command(root, scope, full_projection)
11423    )
11424}
11425
11426fn graph_db_backend_eval_repeated_sample_command(
11427    root: &Path,
11428    scope: Option<&str>,
11429    full_projection: bool,
11430) -> String {
11431    format!(
11432        "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11433        graph_db_backend_eval_base_command(root, scope, full_projection)
11434    )
11435}
11436
11437fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
11438    let mut required_metrics = Vec::new();
11439    for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
11440        required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
11441        required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
11442        for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
11443            required_metrics.push(format!(
11444                "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
11445            ));
11446            required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
11447        }
11448    }
11449    GraphDbHopCapPromotionGate {
11450        status: "hold_64_default_until_gate_passes".to_string(),
11451        current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
11452        candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
11453        required_backend: perf_gate::BASELINE_BACKEND.to_string(),
11454        required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
11455            .iter()
11456            .map(|workload| (*workload).to_string())
11457            .collect(),
11458        required_metrics,
11459        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11460        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11461        decision_rule:
11462            "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"
11463                .to_string(),
11464    }
11465}
11466
11467fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
11468    let candidate_backends = [
11469        GraphDbExperimentalBackend::Falkordb,
11470        GraphDbExperimentalBackend::Kuzu,
11471        GraphDbExperimentalBackend::Surrealdb,
11472    ]
11473    .into_iter()
11474    .map(|backend| GraphDbBackendAdapterSpikeCandidate {
11475        backend: backend.name().to_string(),
11476        adapter_label: backend.adapter_label().to_string(),
11477        projection_load: backend.projection_load().to_string(),
11478        lock_behavior: backend.lock_behavior().to_string(),
11479        install_portability: backend.install_portability().to_string(),
11480    })
11481    .collect();
11482
11483    GraphDbBackendAdapterSpikeGate {
11484        status: "hold_real_optional_adapter_required".to_string(),
11485        candidate_backends,
11486        required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
11487            .iter()
11488            .map(|workload| (*workload).to_string())
11489            .collect(),
11490        required_checks: vec![
11491            "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
11492            "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
11493            "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
11494            "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
11495            "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
11496                .to_string(),
11497            "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
11498            "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
11499        ],
11500        decision_rule:
11501            "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"
11502                .to_string(),
11503        evidence_plan: "plans/gback-evidence.md".to_string(),
11504    }
11505}
11506
11507pub(crate) fn graph_db_backend_eval_performance_gate(
11508    root: &Path,
11509    scope: Option<&str>,
11510    full_projection: bool,
11511) -> GraphDbBackendEvalPerformanceGate {
11512    let mut required_metrics = vec![
11513        "real.sqlite.refresh.duration_micros".to_string(),
11514        "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
11515        "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
11516        "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
11517        "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11518        "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11519        "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
11520        "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
11521        "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11522        "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
11523        "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
11524        "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
11525        "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
11526        "real.sqlite.conflict_matrix.duration_micros".to_string(),
11527        "real.sqlite.dispatch_trace.duration_micros".to_string(),
11528        "real.sqlite.path_max_hops.duration_micros".to_string(),
11529        "real.sqlite.path_max_hops_128.duration_micros".to_string(),
11530        "real.sqlite.path_max_hops_256.duration_micros".to_string(),
11531        "real.sqlite.path_max_hops_512.duration_micros".to_string(),
11532        "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
11533        "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
11534        "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
11535        "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
11536        "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11537        "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11538        "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
11539            .to_string(),
11540        "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11541            .to_string(),
11542        "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11543        "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11544        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
11545        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
11546        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
11547        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
11548        "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11549            .to_string(),
11550        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
11551        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
11552            .to_string(),
11553        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
11554            .to_string(),
11555        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
11556            .to_string(),
11557    ];
11558    if full_projection {
11559        required_metrics.extend([
11560            "full_projection.cache.hit".to_string(),
11561            "full_projection.cache.disk_bytes".to_string(),
11562            "full_projection.cache.compression_ratio".to_string(),
11563            "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
11564            "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11565            "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
11566                .to_string(),
11567            "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
11568                .to_string(),
11569            "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
11570            "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
11571            "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
11572            "full_projection.sqlite.neighborhood.duration_micros".to_string(),
11573            "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
11574            "full_projection.sqlite.evidence.duration_micros".to_string(),
11575            "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
11576            "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
11577            "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
11578            "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
11579            "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
11580            "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
11581        ]);
11582    }
11583    GraphDbBackendEvalPerformanceGate {
11584        baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
11585        ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
11586            .to_string(),
11587        opt_in_real_profile:
11588            "pass --full-projection to add the full-project dataset when checking for large projection regressions"
11589                .to_string(),
11590        full_projection_cache_hit_gate: if full_projection {
11591            "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"
11592                .to_string()
11593        } else {
11594            "not evaluated until --full-projection is enabled".to_string()
11595        },
11596        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11597        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11598        normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
11599        required_metrics,
11600        digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
11601        repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
11602            root,
11603            scope,
11604            full_projection,
11605        ),
11606        hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
11607        backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
11608    }
11609}
11610
11611#[cfg(feature = "backend-surrealdb")]
11612fn graph_db_backend_eval_path_segment(value: &str) -> String {
11613    value
11614        .chars()
11615        .map(|ch| {
11616            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
11617                ch
11618            } else {
11619                '_'
11620            }
11621        })
11622        .collect()
11623}
11624
11625#[cfg(feature = "backend-surrealdb")]
11626fn graph_db_backend_eval_surrealdb_store_path(
11627    root: &Path,
11628    scope: Option<&str>,
11629    dataset: &str,
11630) -> PathBuf {
11631    root.join(".tsift/backend-eval-cache/surrealdb")
11632        .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
11633        .join(graph_db_backend_eval_path_segment(dataset))
11634        .join("surrealkv")
11635}
11636
11637pub(crate) struct GraphDbBackendEvalOptions<'a> {
11638    path: &'a Path,
11639    scope: Option<&'a str>,
11640    candidates: &'a [String],
11641    targets: &'a [String],
11642    full_projection: bool,
11643}
11644
11645#[allow(clippy::too_many_arguments)]
11646pub(crate) fn graph_db_backend_eval_dataset(
11647    name: &str,
11648    root: &Path,
11649    path: &Path,
11650    scope: Option<&str>,
11651    targets: &[String],
11652    depth: usize,
11653    limit: usize,
11654    impact_limit: usize,
11655    candidates: &[GraphDbExperimentalBackend],
11656    sqlite_store: &SqliteGraphStore,
11657    sqlite_freshness: GraphDbFreshnessReport,
11658    sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
11659    sqlite_rows: ConvexProjectionRows,
11660    extra_warnings: Vec<String>,
11661    prepared: &ConflictMatrixPreparedInputs,
11662) -> Result<GraphDbBackendEvalDataset> {
11663    let (nodes, edges) = sqlite_store.graph_counts()?;
11664    let (sqlite_operation, sqlite_signature) = sqlite_refresh;
11665    let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
11666        "sqlite",
11667        "SQLite GraphStore correctness baseline",
11668        false,
11669        root,
11670        path,
11671        scope,
11672        targets,
11673        depth,
11674        limit,
11675        impact_limit,
11676        sqlite_store,
11677        sqlite_freshness,
11678        sqlite_operation,
11679        Some(sqlite_signature),
11680        None,
11681        extra_warnings.clone(),
11682        prepared,
11683        "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
11684        "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
11685        "bundled rusqlite baseline; no external service or runtime required",
11686    );
11687
11688    let mut backends = vec![sqlite_report];
11689    for candidate in candidates {
11690        #[cfg(feature = "backend-surrealdb")]
11691        if *candidate == GraphDbExperimentalBackend::Surrealdb {
11692            let started = Instant::now();
11693            let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
11694            let (store, warm_start) =
11695                SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
11696            let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11697            let rows = candidate_nodes + candidate_edges;
11698            let mut refresh_meta = serde_json::json!({
11699                "nodes": candidate_nodes,
11700                "edges": candidate_edges,
11701            });
11702            if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
11703                refresh_meta["warm_start"] = serde_json::json!("cache_hit");
11704            }
11705            let refresh = graph_db_backend_eval_refresh_operation(
11706                started.elapsed().as_micros(),
11707                rows,
11708                refresh_meta,
11709            );
11710            let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11711            let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11712                candidate.name(),
11713                "SurrealDB SurrealKV optional adapter spike",
11714                false,
11715                root,
11716                path,
11717                scope,
11718                targets,
11719                depth,
11720                limit,
11721                impact_limit,
11722                &store,
11723                freshness,
11724                refresh.0,
11725                Some(refresh.1),
11726                Some(&sqlite_signatures),
11727                extra_warnings.clone(),
11728                prepared,
11729                "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",
11730                "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
11731                "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
11732            );
11733            backends.push(candidate_report);
11734            continue;
11735        }
11736        let started = Instant::now();
11737        let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
11738        let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11739        let rows = candidate_nodes + candidate_edges;
11740        let refresh = graph_db_backend_eval_refresh_operation(
11741            started.elapsed().as_micros(),
11742            rows,
11743            serde_json::json!({
11744                "nodes": candidate_nodes,
11745                "edges": candidate_edges,
11746            }),
11747        );
11748        let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11749        let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11750            candidate.name(),
11751            candidate.adapter_label(),
11752            true,
11753            root,
11754            path,
11755            scope,
11756            targets,
11757            depth,
11758            limit,
11759            impact_limit,
11760            &store,
11761            freshness,
11762            refresh.0,
11763            Some(refresh.1),
11764            Some(&sqlite_signatures),
11765            extra_warnings.clone(),
11766            prepared,
11767            candidate.projection_load(),
11768            candidate.lock_behavior(),
11769            candidate.install_portability(),
11770        );
11771        backends.push(candidate_report);
11772    }
11773
11774    Ok(GraphDbBackendEvalDataset {
11775        name: name.to_string(),
11776        target_count: targets.len(),
11777        nodes,
11778        edges,
11779        backends,
11780    })
11781}
11782
11783pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
11784    println!(
11785        "graph-db backend-eval baseline:{} candidates:{}",
11786        report.baseline_backend,
11787        report.candidates.join(", ")
11788    );
11789    for phase in &report.phase_timings {
11790        println!(
11791            "phase:{} {}us {}",
11792            phase.name, phase.duration_micros, phase.detail
11793        );
11794    }
11795    for dataset in &report.datasets {
11796        println!(
11797            "dataset:{} targets:{} rows:{}",
11798            dataset.name,
11799            dataset.target_count,
11800            dataset.nodes + dataset.edges
11801        );
11802        for backend in &dataset.backends {
11803            println!(
11804                "  backend:{} total:{}us parity:{}",
11805                backend.backend, backend.total_micros, backend.parity.matches_sqlite
11806            );
11807            println!("    projection-load: {}", backend.projection_load);
11808            println!("    lock-behavior: {}", backend.lock_behavior);
11809            println!("    install-portability: {}", backend.install_portability);
11810            for operation in &backend.operations {
11811                println!(
11812                    "    {} {} {}us",
11813                    operation.name, operation.status, operation.duration_micros
11814                );
11815            }
11816            for diagnostic in &backend.parity.diagnostics {
11817                println!("    parity: {diagnostic}");
11818            }
11819        }
11820    }
11821    for decision in &report.promotion {
11822        println!("promotion {}: {}", decision.backend, decision.decision);
11823        println!("  gate: {}", decision.gate.status);
11824        for reason in &decision.reasons {
11825            println!("  reason: {reason}");
11826        }
11827        for check in &decision.gate.required_checks {
11828            println!("  check: {check}");
11829        }
11830    }
11831    println!("metric-digest: {}", report.metric_digest_command);
11832    println!(
11833        "repeat-samples: {}",
11834        report.performance_gate.repeated_sample_command
11835    );
11836}
11837
11838fn traversal_expand_command(root: &Path, handle: &str) -> String {
11839    format!(
11840        "tsift traverse {} --path {} --depth 1 --limit 50",
11841        shell_quote(handle),
11842        shell_quote(root.to_string_lossy().as_ref())
11843    )
11844}
11845
11846fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
11847    let display = relativize(file, root);
11848    let handle = stable_handle("gfil", &format!("file:{display}"));
11849    TraversalNode {
11850        handle: handle.clone(),
11851        kind: "file".to_string(),
11852        label: display.clone(),
11853        ref_id: Some(display.clone()),
11854        path: Some(display),
11855        line: None,
11856        detail: None,
11857        properties: BTreeMap::new(),
11858        expand: traversal_expand_command(root, &handle),
11859    }
11860}
11861
11862fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
11863    let mut node = traversal_file_node(root, file);
11864    if let Some(path) = node.path.clone() {
11865        node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
11866        node.expand = source_read_command(root, &path, 1, 80);
11867    }
11868    node
11869}
11870
11871fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
11872    let file = relativize(&symbol.file, root);
11873    let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
11874    let handle = stable_handle("gsym", &key);
11875    TraversalNode {
11876        handle: handle.clone(),
11877        kind: "symbol".to_string(),
11878        label: symbol.name.clone(),
11879        ref_id: Some(symbol.name.clone()),
11880        path: Some(file),
11881        line: Some(symbol.line),
11882        detail: Some(format!("{} {}", symbol.language, symbol.kind)),
11883        properties: BTreeMap::new(),
11884        expand: traversal_expand_command(root, &handle),
11885    }
11886}
11887
11888fn traversal_ast_span_expand_command(
11889    root: &Path,
11890    file: &str,
11891    symbol: &index::StoredSymbol,
11892    span: &AstSpanPreview,
11893) -> String {
11894    if symbol.language == "markdown" {
11895        markdown_ast_command(root, file, Some(&span.handle))
11896    } else {
11897        let line_count = span
11898            .end_line
11899            .saturating_sub(span.start_line)
11900            .saturating_add(1)
11901            .max(1);
11902        source_read_command(root, file, span.start_line, line_count)
11903    }
11904}
11905
11906fn traversal_ast_span_node(
11907    root: &Path,
11908    symbol: &index::StoredSymbol,
11909    source: &[u8],
11910    symbols: &[index::StoredSymbol],
11911) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
11912    let span = stored_symbol_ast_span(symbol, source, symbols, usize::MAX)?;
11913    let file = relativize(&symbol.file, root);
11914    let mut properties = BTreeMap::new();
11915    properties.insert("layer".to_string(), "ast_navigation".to_string());
11916    properties.insert("language".to_string(), symbol.language.clone());
11917    properties.insert("symbol_kind".to_string(), symbol.kind.clone());
11918    properties.insert("node_kind".to_string(), span.node_kind.clone());
11919    properties.insert("start_byte".to_string(), span.start_byte.to_string());
11920    properties.insert("end_byte".to_string(), span.end_byte.to_string());
11921    properties.insert("end_line".to_string(), span.end_line.to_string());
11922    if let Some(body_start_byte) = span.body_start_byte {
11923        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
11924    }
11925    if let Some(body_end_byte) = span.body_end_byte {
11926        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
11927    }
11928    if let Some(body_start_line) = span.body_start_line {
11929        properties.insert("body_start_line".to_string(), body_start_line.to_string());
11930    }
11931    if let Some(body_end_line) = span.body_end_line {
11932        properties.insert("body_end_line".to_string(), body_end_line.to_string());
11933    }
11934    if let Some(parent_handle) = &span.parent_handle {
11935        properties.insert("parent_handle".to_string(), parent_handle.clone());
11936    }
11937    if !span.child_handles.is_empty() {
11938        properties.insert("child_handles".to_string(), span.child_handles.join(","));
11939    }
11940    if let Some(parent_module) = &symbol.parent_module {
11941        properties.insert("parent_module".to_string(), parent_module.clone());
11942    }
11943    if let Some(markdown) = &span.markdown {
11944        properties.insert(
11945            "markdown_block_kind".to_string(),
11946            markdown_ast_block_kind(&symbol.kind),
11947        );
11948        if let Some(heading_level) = markdown.heading_level {
11949            properties.insert("heading_level".to_string(), heading_level.to_string());
11950        }
11951        if !markdown.section_path.is_empty() {
11952            properties.insert(
11953                "section_path".to_string(),
11954                markdown.section_path.join(" > "),
11955            );
11956        }
11957        if let Some(section_handle) = &markdown.section_handle {
11958            properties.insert("section_handle".to_string(), section_handle.clone());
11959        }
11960        if let Some(list_depth) = markdown.list_depth {
11961            properties.insert("list_depth".to_string(), list_depth.to_string());
11962        }
11963        if let Some(fence_language) = &markdown.fence_language {
11964            properties.insert("fence_language".to_string(), fence_language.clone());
11965        }
11966    }
11967
11968    let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
11969    let node = TraversalNode {
11970        handle: span.handle.clone(),
11971        kind: "ast_span".to_string(),
11972        label: symbol.name.clone(),
11973        ref_id: Some(symbol.name.clone()),
11974        path: Some(file.clone()),
11975        line: Some(line),
11976        detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
11977        properties,
11978        expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
11979    };
11980    let entry = TraversalAstSpanIndexEntry {
11981        handle: span.handle,
11982        symbol_handle: String::new(),
11983        file_handle: None,
11984        file,
11985        name: symbol.name.clone(),
11986        kind: symbol.kind.clone(),
11987        language: symbol.language.clone(),
11988        node_kind: span.node_kind,
11989        start_byte: span.start_byte,
11990        end_byte: span.end_byte,
11991        parent_module: symbol.parent_module.clone(),
11992        markdown: span.markdown,
11993    };
11994    Some((node, entry))
11995}
11996
11997fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
11998    let handle = stable_handle("gsym", &format!("symbol:{name}"));
11999    TraversalNode {
12000        handle: handle.clone(),
12001        kind: "symbol".to_string(),
12002        label: name.to_string(),
12003        ref_id: Some(name.to_string()),
12004        path: None,
12005        line: None,
12006        detail: Some("unresolved call target".to_string()),
12007        properties: BTreeMap::new(),
12008        expand: traversal_expand_command(root, &handle),
12009    }
12010}
12011
12012fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12013    let file = relativize(&route.file, root);
12014    let method = route.method.as_deref().unwrap_or("any");
12015    let key = format!(
12016        "route:{file}:{}:{}:{}",
12017        route.line, method, route.route_path
12018    );
12019    let handle = stable_handle("grte", &key);
12020    TraversalNode {
12021        handle: handle.clone(),
12022        kind: "route".to_string(),
12023        label: format!("{} {}", method.to_uppercase(), route.route_path),
12024        ref_id: Some(route.route_path.clone()),
12025        path: Some(file),
12026        line: Some(route.line),
12027        detail: Some(format!(
12028            "{} route handled by {}",
12029            route.framework, route.handler_name
12030        )),
12031        properties: BTreeMap::new(),
12032        expand: traversal_expand_command(root, &handle),
12033    }
12034}
12035
12036fn traversal_cargo_workspace_node(
12037    root: &Path,
12038    workspace: &multiplicity::CargoWorkspaceInfo,
12039) -> TraversalNode {
12040    let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12041        .to_string_lossy()
12042        .replace('\\', "/");
12043    let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12044        .to_string_lossy()
12045        .replace('\\', "/");
12046    let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12047    let mut properties = BTreeMap::new();
12048    properties.insert("layer".to_string(), "cargo_workspace".to_string());
12049    properties.insert("workspace_root".to_string(), workspace_root.clone());
12050    properties.insert("members".to_string(), workspace.members.join(","));
12051    properties.insert(
12052        "default_members".to_string(),
12053        workspace.default_members.join(","),
12054    );
12055    TraversalNode {
12056        handle: handle.clone(),
12057        kind: "cargo_workspace".to_string(),
12058        label: if workspace_root.is_empty() {
12059            "root cargo workspace".to_string()
12060        } else {
12061            workspace_root
12062        },
12063        ref_id: Some(workspace.id.clone()),
12064        path: Some(manifest),
12065        line: None,
12066        detail: Some("Cargo workspace manifest".to_string()),
12067        properties,
12068        expand: traversal_expand_command(root, &handle),
12069    }
12070}
12071
12072fn traversal_cargo_package_node(
12073    root: &Path,
12074    package: &multiplicity::CargoPackageInfo,
12075) -> TraversalNode {
12076    let manifest = relativize_pathbuf(&package.manifest_path, root)
12077        .to_string_lossy()
12078        .replace('\\', "/");
12079    let package_root = relativize_pathbuf(&package.package_root, root)
12080        .to_string_lossy()
12081        .replace('\\', "/");
12082    let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12083        .to_string_lossy()
12084        .replace('\\', "/");
12085    let handle = stable_handle(
12086        "gcpk",
12087        &format!("cargo-package:{manifest}:{}", package.name),
12088    );
12089    let mut properties = BTreeMap::new();
12090    properties.insert("layer".to_string(), "cargo_package".to_string());
12091    properties.insert("package_name".to_string(), package.name.clone());
12092    properties.insert(
12093        "normalized_name".to_string(),
12094        package.normalized_name.clone(),
12095    );
12096    properties.insert("package_root".to_string(), package_root.clone());
12097    properties.insert("workspace_root".to_string(), workspace_root);
12098    properties.insert("features".to_string(), package.features.join(","));
12099    properties.insert("targets".to_string(), package.targets.join(","));
12100    properties.insert(
12101        "dependencies".to_string(),
12102        package
12103            .dependencies
12104            .iter()
12105            .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12106            .collect::<Vec<_>>()
12107            .join(","),
12108    );
12109    TraversalNode {
12110        handle: handle.clone(),
12111        kind: "cargo_package".to_string(),
12112        label: package.name.clone(),
12113        ref_id: Some(package.scope_id.clone()),
12114        path: Some(manifest),
12115        line: None,
12116        detail: Some(format!(
12117            "Cargo package in {}",
12118            if package_root.is_empty() {
12119                "."
12120            } else {
12121                package_root.as_str()
12122            }
12123        )),
12124        properties,
12125        expand: traversal_expand_command(root, &handle),
12126    }
12127}
12128
12129fn traversal_session_node(
12130    root: &Path,
12131    markdown_path: &Path,
12132    session_id: Option<&str>,
12133) -> TraversalNode {
12134    let display = relativize_pathbuf(markdown_path, root)
12135        .to_string_lossy()
12136        .replace('\\', "/");
12137    let handle = stable_handle("gses", &format!("session:{display}"));
12138    TraversalNode {
12139        handle: handle.clone(),
12140        kind: "session".to_string(),
12141        label: session_id.unwrap_or(&display).to_string(),
12142        ref_id: session_id.map(str::to_string),
12143        path: Some(display),
12144        line: None,
12145        detail: Some("agent-doc session artifact".to_string()),
12146        properties: BTreeMap::new(),
12147        expand: traversal_expand_command(root, &handle),
12148    }
12149}
12150
12151fn traversal_backlog_node(
12152    root: &Path,
12153    markdown_path: &Path,
12154    id: &str,
12155    text: &str,
12156    line: i64,
12157) -> TraversalNode {
12158    let display = relativize_pathbuf(markdown_path, root)
12159        .to_string_lossy()
12160        .replace('\\', "/");
12161    let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12162    TraversalNode {
12163        handle: handle.clone(),
12164        kind: "backlog".to_string(),
12165        label: format!("#{id}"),
12166        ref_id: Some(id.to_string()),
12167        path: Some(display),
12168        line: Some(line),
12169        detail: Some(text.to_string()),
12170        properties: BTreeMap::new(),
12171        expand: traversal_expand_command(root, &handle),
12172    }
12173}
12174
12175fn traversal_job_packet_node(
12176    root: &Path,
12177    markdown_path: &Path,
12178    label: &str,
12179    ref_id: Option<&str>,
12180    detail: &str,
12181    line: i64,
12182) -> TraversalNode {
12183    let display = relativize_pathbuf(markdown_path, root)
12184        .to_string_lossy()
12185        .replace('\\', "/");
12186    let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12187    TraversalNode {
12188        handle: handle.clone(),
12189        kind: "job_packet".to_string(),
12190        label: label.to_string(),
12191        ref_id: ref_id.map(str::to_string),
12192        path: Some(display),
12193        line: Some(line),
12194        detail: Some(detail.to_string()),
12195        properties: BTreeMap::new(),
12196        expand: traversal_expand_command(root, &handle),
12197    }
12198}
12199
12200#[derive(Clone, Debug)]
12201struct ParsedWorkerResult {
12202    id: String,
12203    status: String,
12204    touched_files: Vec<String>,
12205    tests: Vec<String>,
12206    follow_up_ids: Vec<String>,
12207}
12208
12209fn traversal_worker_result_node(
12210    root: &Path,
12211    markdown_path: &Path,
12212    parsed: &ParsedWorkerResult,
12213    line_text: &str,
12214    line: i64,
12215) -> TraversalNode {
12216    let display = relativize_pathbuf(markdown_path, root)
12217        .to_string_lossy()
12218        .replace('\\', "/");
12219    let handle = stable_handle(
12220        "wres",
12221        &format!(
12222            "worker-result:{display}:{}:{}:{}",
12223            parsed.id, parsed.status, line
12224        ),
12225    );
12226    let mut properties = BTreeMap::new();
12227    properties.insert("status".to_string(), parsed.status.clone());
12228    if !parsed.touched_files.is_empty() {
12229        properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12230    }
12231    if !parsed.tests.is_empty() {
12232        properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12233    }
12234    if !parsed.follow_up_ids.is_empty() {
12235        properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12236    }
12237    TraversalNode {
12238        handle: handle.clone(),
12239        kind: "worker_result".to_string(),
12240        label: format!("{} #{}", parsed.status, parsed.id),
12241        ref_id: Some(parsed.id.clone()),
12242        path: Some(display),
12243        line: Some(line),
12244        detail: Some(line_text.trim().to_string()),
12245        properties,
12246        expand: traversal_expand_command(root, &handle),
12247    }
12248}
12249
12250fn traversal_tokens(input: &str) -> BTreeSet<String> {
12251    input
12252        .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
12253        .flat_map(|part| part.split(['_', '-']))
12254        .map(str::trim)
12255        .filter(|part| part.len() >= 3)
12256        .map(|part| part.to_ascii_lowercase())
12257        .collect()
12258}
12259
12260fn traversal_ast_span_contains(
12261    parent: &TraversalAstSpanIndexEntry,
12262    child: &TraversalAstSpanIndexEntry,
12263) -> bool {
12264    parent.handle != child.handle
12265        && parent.file == child.file
12266        && parent.start_byte <= child.start_byte
12267        && parent.end_byte >= child.end_byte
12268}
12269
12270fn traversal_ast_parent_handle<'a>(
12271    entry: &TraversalAstSpanIndexEntry,
12272    entries: &'a [TraversalAstSpanIndexEntry],
12273) -> Option<&'a str> {
12274    entries
12275        .iter()
12276        .filter(|candidate| traversal_ast_span_contains(candidate, entry))
12277        .min_by_key(|candidate| {
12278            (
12279                candidate.end_byte.saturating_sub(candidate.start_byte),
12280                candidate.start_byte,
12281                candidate.end_byte,
12282                candidate.kind.as_str(),
12283                candidate.name.as_str(),
12284                candidate.node_kind.as_str(),
12285            )
12286        })
12287        .map(|candidate| candidate.handle.as_str())
12288}
12289
12290fn traversal_ast_enclosing_module_handle<'a>(
12291    entry: &TraversalAstSpanIndexEntry,
12292    entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
12293    parent_by_handle: &BTreeMap<String, String>,
12294) -> Option<&'a str> {
12295    let mut current = parent_by_handle.get(&entry.handle);
12296    while let Some(handle) = current {
12297        let Some(parent) = entries_by_handle.get(handle) else {
12298            break;
12299        };
12300        if matches!(parent.kind.as_str(), "module" | "mod")
12301            || entry
12302                .parent_module
12303                .as_deref()
12304                .is_some_and(|module| module == parent.name)
12305        {
12306            return Some(parent.handle.as_str());
12307        }
12308        current = parent_by_handle.get(&parent.handle);
12309    }
12310    None
12311}
12312
12313fn link_ast_navigation_edges(
12314    graph: &mut TraversalGraphBuild,
12315    entries: &[TraversalAstSpanIndexEntry],
12316) {
12317    let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
12318    let entries_by_handle = entries
12319        .iter()
12320        .map(|entry| (entry.handle.clone(), entry.clone()))
12321        .collect::<BTreeMap<_, _>>();
12322    let mut parent_by_handle = BTreeMap::<String, String>::new();
12323    let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
12324
12325    for entry in entries {
12326        entries_by_file
12327            .entry(entry.file.clone())
12328            .or_default()
12329            .push(entry.clone());
12330    }
12331
12332    for file_entries in entries_by_file.values() {
12333        for entry in file_entries {
12334            let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
12335            if let Some(parent) = &parent {
12336                parent_by_handle.insert(entry.handle.clone(), parent.clone());
12337            }
12338            let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
12339            children_by_parent
12340                .entry(sibling_key)
12341                .or_default()
12342                .push(entry.clone());
12343        }
12344    }
12345
12346    for entry in entries {
12347        let parent = parent_by_handle.get(&entry.handle);
12348        if let Some(parent) = parent {
12349            graph.add_edge(
12350                parent,
12351                &entry.handle,
12352                "contains",
12353                Some("AST parent contains child span".to_string()),
12354                1,
12355            );
12356            graph.add_edge(
12357                parent,
12358                &entry.handle,
12359                "child",
12360                Some("AST child span".to_string()),
12361                1,
12362            );
12363            graph.add_edge(
12364                &entry.handle,
12365                parent,
12366                "parent",
12367                Some("AST parent span".to_string()),
12368                1,
12369            );
12370        } else if let Some(file_handle) = &entry.file_handle {
12371            graph.add_edge(
12372                file_handle,
12373                &entry.handle,
12374                "contains",
12375                Some("file contains top-level AST span".to_string()),
12376                1,
12377            );
12378        }
12379
12380        if let Some(module_handle) =
12381            traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
12382        {
12383            graph.add_edge(
12384                &entry.handle,
12385                module_handle,
12386                "enclosing_module",
12387                Some("nearest enclosing module AST span".to_string()),
12388                1,
12389            );
12390        }
12391
12392        if entry.language == "markdown"
12393            && let Some(markdown) = &entry.markdown
12394            && let Some(section_handle) = &markdown.section_handle
12395            && section_handle != &entry.handle
12396        {
12397            graph.add_edge(
12398                section_handle,
12399                &entry.handle,
12400                "contains_markdown_block",
12401                Some("Markdown section contains block".to_string()),
12402                1,
12403            );
12404            graph.add_edge(
12405                &entry.handle,
12406                section_handle,
12407                "enclosing_section",
12408                Some("Markdown enclosing section".to_string()),
12409                1,
12410            );
12411        }
12412    }
12413
12414    for siblings in children_by_parent.values_mut() {
12415        siblings.sort_by(|left, right| {
12416            left.start_byte
12417                .cmp(&right.start_byte)
12418                .then(left.end_byte.cmp(&right.end_byte))
12419                .then(left.kind.cmp(&right.kind))
12420                .then(left.name.cmp(&right.name))
12421                .then(left.node_kind.cmp(&right.node_kind))
12422                .then(left.handle.cmp(&right.handle))
12423        });
12424        for pair in siblings.windows(2) {
12425            let previous = &pair[0];
12426            let next = &pair[1];
12427            graph.add_edge(
12428                &previous.handle,
12429                &next.handle,
12430                "next_sibling",
12431                Some("next AST sibling span".to_string()),
12432                1,
12433            );
12434            graph.add_edge(
12435                &next.handle,
12436                &previous.handle,
12437                "previous_sibling",
12438                Some("previous AST sibling span".to_string()),
12439                1,
12440            );
12441        }
12442    }
12443}
12444
12445fn traversal_markdown_embedded_symbol_node(
12446    root: &Path,
12447    entry: &TraversalAstSpanIndexEntry,
12448    markdown: &MarkdownSpanMetadata,
12449    embedded: &MarkdownEmbeddedSymbol,
12450) -> TraversalNode {
12451    let mut properties = BTreeMap::new();
12452    properties.insert("layer".to_string(), "embedded_code".to_string());
12453    properties.insert("embedded".to_string(), "true".to_string());
12454    properties.insert("language".to_string(), embedded.language.clone());
12455    properties.insert("symbol_kind".to_string(), embedded.kind.clone());
12456    properties.insert("node_kind".to_string(), embedded.node_kind.clone());
12457    properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
12458    properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
12459    properties.insert("end_line".to_string(), embedded.end_line.to_string());
12460    properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
12461    properties.insert(
12462        "markdown_block_kind".to_string(),
12463        markdown_ast_block_kind(&entry.kind),
12464    );
12465    if let Some(body_start_byte) = embedded.body_start_byte {
12466        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12467    }
12468    if let Some(body_end_byte) = embedded.body_end_byte {
12469        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12470    }
12471    if let Some(body_start_line) = embedded.body_start_line {
12472        properties.insert("body_start_line".to_string(), body_start_line.to_string());
12473    }
12474    if let Some(body_end_line) = embedded.body_end_line {
12475        properties.insert("body_end_line".to_string(), body_end_line.to_string());
12476    }
12477    if let Some(fence_language) = &markdown.fence_language {
12478        properties.insert("fence_language".to_string(), fence_language.clone());
12479    }
12480    if !markdown.section_path.is_empty() {
12481        properties.insert(
12482            "section_path".to_string(),
12483            markdown.section_path.join(" > "),
12484        );
12485    }
12486    if let Some(section_handle) = &markdown.section_handle {
12487        properties.insert("section_handle".to_string(), section_handle.clone());
12488    }
12489    let line_count = embedded
12490        .end_line
12491        .saturating_sub(embedded.start_line)
12492        .saturating_add(1)
12493        .max(1);
12494    TraversalNode {
12495        handle: embedded.handle.clone(),
12496        kind: "ast_span".to_string(),
12497        label: embedded.name.clone(),
12498        ref_id: Some(embedded.name.clone()),
12499        path: Some(entry.file.clone()),
12500        line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
12501        detail: Some(format!(
12502            "{} {} embedded in Markdown fence",
12503            embedded.language, embedded.kind
12504        )),
12505        properties,
12506        expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
12507    }
12508}
12509
12510fn link_markdown_embedded_code_edges(
12511    graph: &mut TraversalGraphBuild,
12512    root: &Path,
12513    entries: &[TraversalAstSpanIndexEntry],
12514) {
12515    for entry in entries {
12516        let Some(markdown) = &entry.markdown else {
12517            continue;
12518        };
12519        for embedded in &markdown.embedded_symbols {
12520            let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
12521            graph.add_node(node);
12522            graph.add_edge(
12523                &entry.handle,
12524                &embedded.handle,
12525                "contains",
12526                Some("Markdown fence contains embedded AST symbol".to_string()),
12527                1,
12528            );
12529            graph.add_edge(
12530                &entry.handle,
12531                &embedded.handle,
12532                "child",
12533                Some("embedded code symbol".to_string()),
12534                1,
12535            );
12536            graph.add_edge(
12537                &entry.handle,
12538                &embedded.handle,
12539                "contains_embedded_symbol",
12540                Some("Markdown fence contains embedded code symbol".to_string()),
12541                1,
12542            );
12543            graph.add_edge(
12544                &embedded.handle,
12545                &entry.handle,
12546                "parent",
12547                Some("Markdown fence parent span".to_string()),
12548                1,
12549            );
12550            graph.add_edge(
12551                &embedded.handle,
12552                &entry.handle,
12553                "embedded_in_fence",
12554                Some("embedded code symbol belongs to Markdown fence".to_string()),
12555                1,
12556            );
12557            if let Some(section_handle) = &markdown.section_handle
12558                && section_handle != &entry.handle
12559            {
12560                graph.add_edge(
12561                    section_handle,
12562                    &embedded.handle,
12563                    "contains_embedded_code",
12564                    Some("Markdown section contains embedded code symbol".to_string()),
12565                    1,
12566                );
12567                graph.add_edge(
12568                    &embedded.handle,
12569                    section_handle,
12570                    "enclosing_section",
12571                    Some("Markdown enclosing section".to_string()),
12572                    1,
12573                );
12574            }
12575        }
12576    }
12577}
12578
12579fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
12580    let mut tokens = traversal_tokens(&node.label);
12581    if let Some(ref_id) = &node.ref_id {
12582        tokens.extend(traversal_tokens(ref_id));
12583    }
12584    if let Some(path) = &node.path {
12585        tokens.extend(traversal_tokens(path));
12586    }
12587    if let Some(detail) = &node.detail {
12588        tokens.extend(traversal_tokens(detail));
12589    }
12590    tokens
12591}
12592
12593fn parse_agent_doc_session_id(content: &str) -> Option<String> {
12594    content.lines().find_map(|line| {
12595        let trimmed = line.trim();
12596        trimmed
12597            .strip_prefix("agent_doc_session:")
12598            .map(str::trim)
12599            .filter(|value| !value.is_empty())
12600            .map(str::to_string)
12601    })
12602}
12603
12604fn parse_backlog_line(line: &str) -> Option<(String, String)> {
12605    let trimmed = line.trim();
12606    if !trimmed.starts_with("- [") {
12607        return None;
12608    }
12609    let start = trimmed.find("[#")?;
12610    let after_start = start + 2;
12611    let rest = &trimmed[after_start..];
12612    let end = rest.find(']')?;
12613    let id = rest[..end].trim();
12614    if id.is_empty() {
12615        return None;
12616    }
12617    let text = rest[end + 1..].trim().to_string();
12618    Some((id.to_string(), text))
12619}
12620
12621fn parse_queue_dispatch_line(line: &str) -> Option<String> {
12622    let trimmed = line.trim();
12623    ["dispatch ", "preset "].iter().find_map(|prefix| {
12624        trimmed
12625            .strip_prefix(prefix)
12626            .map(str::trim)
12627            .filter(|value| !value.is_empty())
12628            .map(str::to_string)
12629    })
12630}
12631
12632fn parse_queue_do_line(line: &str) -> Option<String> {
12633    let trimmed = line.trim();
12634    let rest = trimmed.strip_prefix("- do [#")?;
12635    let end = rest.find(']')?;
12636    let id = rest[..end].trim();
12637    (!id.is_empty()).then(|| id.to_string())
12638}
12639
12640fn markdown_code_spans(input: &str) -> Vec<String> {
12641    input
12642        .split('`')
12643        .enumerate()
12644        .filter(|(idx, _)| idx % 2 == 1)
12645        .map(|(_, part)| part.trim().to_string())
12646        .filter(|part| !part.is_empty())
12647        .collect()
12648}
12649
12650fn push_traversal_token_index(
12651    index: &mut HashMap<String, Vec<usize>>,
12652    tokens: &BTreeSet<String>,
12653    entry_index: usize,
12654) {
12655    for token in tokens {
12656        index.entry(token.clone()).or_default().push(entry_index);
12657    }
12658}
12659
12660impl<'a> TraversalCodeLookup<'a> {
12661    fn new(
12662        symbols: &'a [TraversalSymbolIndexEntry],
12663        files: &'a [TraversalFileIndexEntry],
12664        routes: &'a [TraversalRouteIndexEntry],
12665        multiplicities: &'a [TraversalMultiplicityIndexEntry],
12666    ) -> Self {
12667        let mut symbol_index = HashMap::new();
12668        for (idx, entry) in symbols.iter().enumerate() {
12669            push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
12670        }
12671        let mut file_index = HashMap::new();
12672        let mut file_path_index = HashMap::new();
12673        for (idx, entry) in files.iter().enumerate() {
12674            push_traversal_token_index(&mut file_index, &entry.tokens, idx);
12675            if let Some(path) = entry.node.path.as_ref() {
12676                file_path_index.insert(path.clone(), path.clone());
12677            }
12678        }
12679        let mut route_index = HashMap::new();
12680        for (idx, entry) in routes.iter().enumerate() {
12681            push_traversal_token_index(&mut route_index, &entry.tokens, idx);
12682        }
12683        let mut multiplicity_index = HashMap::new();
12684        for (idx, entry) in multiplicities.iter().enumerate() {
12685            push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
12686        }
12687        Self {
12688            symbols,
12689            files,
12690            routes,
12691            multiplicities,
12692            symbol_index,
12693            file_index,
12694            route_index,
12695            multiplicity_index,
12696            file_path_index,
12697        }
12698    }
12699
12700    fn touched_files_for_line(&self, line: &str) -> Vec<String> {
12701        let mut touched_files = BTreeSet::new();
12702        for candidate in markdown_code_spans(line)
12703            .into_iter()
12704            .chain(line.split_whitespace().map(str::to_string))
12705        {
12706            for path in traversal_path_candidates(&candidate) {
12707                if let Some(file) = self.file_path_index.get(&path) {
12708                    touched_files.insert(file.clone());
12709                }
12710            }
12711        }
12712        touched_files.into_iter().collect()
12713    }
12714}
12715
12716fn traversal_path_candidates(candidate: &str) -> Vec<String> {
12717    let trimmed = candidate.trim_matches(|ch: char| {
12718        matches!(
12719            ch,
12720            '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
12721        )
12722    });
12723    if trimmed.is_empty() {
12724        return Vec::new();
12725    }
12726    let mut candidates = vec![trimmed.to_string()];
12727    if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
12728        && !path.is_empty()
12729        && line_suffix.chars().all(|ch| ch.is_ascii_digit())
12730    {
12731        candidates.push(path.to_string());
12732    }
12733    candidates
12734}
12735
12736fn parse_worker_result_line(
12737    line: &str,
12738    lookup: &TraversalCodeLookup<'_>,
12739) -> Vec<ParsedWorkerResult> {
12740    if line.trim_start().starts_with("- [") {
12741        return Vec::new();
12742    }
12743    let lower = line.to_ascii_lowercase();
12744    let status =
12745        if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
12746        {
12747            "completed"
12748        } else if lower.contains("blocked") || lower.contains("externally blocked") {
12749            "blocked"
12750        } else {
12751            return Vec::new();
12752        };
12753    let result_prefix_end = ["follow-up", "follow up", "next:"]
12754        .iter()
12755        .filter_map(|marker| lower.find(marker))
12756        .min()
12757        .unwrap_or(line.len());
12758    let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
12759    if ids.is_empty() {
12760        return Vec::new();
12761    }
12762    let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
12763    let all_ids = extract_conflict_target_refs(line);
12764
12765    let touched_files = lookup.touched_files_for_line(line);
12766    let tests = markdown_code_spans(line)
12767        .into_iter()
12768        .filter(|span| span.to_ascii_lowercase().contains("test"))
12769        .collect::<Vec<_>>();
12770
12771    ids.iter()
12772        .map(|id| ParsedWorkerResult {
12773            id: id.clone(),
12774            status: status.to_string(),
12775            touched_files: touched_files.clone(),
12776            tests: tests.clone(),
12777            follow_up_ids: all_ids
12778                .iter()
12779                .filter(|other| *other != id && !result_ids.contains(*other))
12780                .cloned()
12781                .collect(),
12782        })
12783        .collect()
12784}
12785
12786fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
12787    let hinted_path = if path_hint.is_absolute() {
12788        path_hint.to_path_buf()
12789    } else {
12790        root.join(path_hint)
12791    };
12792    if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
12793        return Some(hinted_path);
12794    }
12795    None
12796}
12797
12798fn traversal_markdown_content_looks_like_session(content: &str) -> bool {
12799    parse_agent_doc_session_id(content).is_some()
12800        || content.contains("<!-- agent:exchange")
12801        || content.contains("<!-- agent:backlog")
12802        || content.contains("## Backlog")
12803}
12804
12805fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
12806    let candidate = if path.is_absolute() {
12807        path.to_path_buf()
12808    } else {
12809        source_root.join(path)
12810    };
12811    if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
12812        return false;
12813    }
12814    if !matches!(
12815        candidate.extension().and_then(|ext| ext.to_str()),
12816        Some("md" | "mdx")
12817    ) {
12818        return false;
12819    }
12820    fs::read_to_string(&candidate)
12821        .map(|content| traversal_markdown_content_looks_like_session(&content))
12822        .unwrap_or(false)
12823}
12824
12825fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
12826    if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
12827        return Ok(vec![hinted_path]);
12828    }
12829    let mut files = Vec::new();
12830    let walker = ignore::WalkBuilder::new(root)
12831        .hidden(true)
12832        .git_ignore(true)
12833        .git_global(true)
12834        .git_exclude(true)
12835        .build();
12836    for result in walker {
12837        let entry =
12838            result.with_context(|| format!("walking markdown files under {}", root.display()))?;
12839        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
12840            continue;
12841        }
12842        if traversal_path_is_generated_artifact(root, root, entry.path()) {
12843            continue;
12844        }
12845        if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
12846            files.push(entry.path().to_path_buf());
12847        }
12848    }
12849    files.sort();
12850    Ok(files)
12851}
12852
12853fn traversal_watermark_path(root: &Path, path: &Path) -> String {
12854    path.strip_prefix(root)
12855        .unwrap_or(path)
12856        .to_string_lossy()
12857        .replace('\\', "/")
12858}
12859
12860fn push_traversal_metadata_watermark_part(
12861    root: &Path,
12862    path: &Path,
12863    label: &str,
12864    parts: &mut Vec<String>,
12865) {
12866    let display = traversal_watermark_path(root, path);
12867    match fs::metadata(path) {
12868        Ok(metadata) => {
12869            let (secs, nanos) = metadata
12870                .modified()
12871                .ok()
12872                .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
12873                .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
12874                .unwrap_or((0, 0));
12875            parts.push(format!(
12876                "{label}:{display}:len={}:mtime={secs}.{nanos}",
12877                metadata.len()
12878            ));
12879        }
12880        Err(_) => parts.push(format!("{label}:{display}:missing")),
12881    }
12882}
12883
12884#[derive(Serialize)]
12885struct TraversalSummaryWatermarkRow<'a> {
12886    symbol_name: &'a str,
12887    file_path: &'a str,
12888    entities: &'a Option<Vec<summarize::Entity>>,
12889    relationships: &'a Option<Vec<summarize::Relationship>>,
12890    concept_labels: &'a Option<Vec<String>>,
12891}
12892
12893fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
12894    let summaries_db = root.join(".tsift/summaries.db");
12895    if !summaries_db.exists() {
12896        parts.push("summaries_db:absent".to_string());
12897        return Ok(());
12898    }
12899
12900    match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
12901        .and_then(|summary_db| summary_db.all())
12902    {
12903        Ok(summaries) => {
12904            let rows = summaries
12905                .iter()
12906                .map(|summary| TraversalSummaryWatermarkRow {
12907                    symbol_name: &summary.symbol_name,
12908                    file_path: &summary.file_path,
12909                    entities: &summary.entities,
12910                    relationships: &summary.relationships,
12911                    concept_labels: &summary.concept_labels,
12912                })
12913                .collect::<Vec<_>>();
12914            parts.push(format!(
12915                "summaries_db:rows={}:semantic_hash={}",
12916                rows.len(),
12917                content_hash(&rows)?
12918            ));
12919        }
12920        Err(_) => {
12921            push_traversal_metadata_watermark_part(
12922                root,
12923                &summaries_db,
12924                "summaries_db_unreadable",
12925                parts,
12926            );
12927        }
12928    }
12929    Ok(())
12930}
12931
12932#[cfg(test)]
12933fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
12934    resolution::relative_path_is_generated_artifact(relative)
12935}
12936
12937fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
12938    resolution::path_is_generated_artifact(root, source_root, path)
12939}
12940
12941fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
12942    resolution::index_snapshot_part_is_generated(root, source_root, part)
12943}
12944
12945pub(crate) fn traversal_source_watermark(
12946    root: &Path,
12947    path_hint: &Path,
12948    scope: Option<&str>,
12949    session_only: bool,
12950) -> Result<Option<String>> {
12951    let mut parts = vec![
12952        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
12953        format!("scope:{}", scope.unwrap_or("root")),
12954        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
12955        format!("session_only:{session_only}"),
12956    ];
12957
12958    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
12959        let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
12960            Ok(targets) => targets,
12961            Err(_) => return Ok(None),
12962        };
12963        let Some(target) = targets.into_iter().next() else {
12964            return Ok(None);
12965        };
12966        let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
12967            Ok(db) => db,
12968            Err(_) => return Ok(None),
12969        };
12970        parts.push(format!("index_label:{}", target.label));
12971        parts.push(format!(
12972            "index_scope:{}",
12973            target.scope_name.as_deref().unwrap_or("root")
12974        ));
12975        parts.push(format!(
12976            "index_source_root:{}",
12977            traversal_watermark_path(root, &target.source_root)
12978        ));
12979        let mut snapshot_rows = 0usize;
12980        for part in db.source_snapshot_parts()? {
12981            if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
12982                continue;
12983            }
12984            snapshot_rows += 1;
12985            parts.push(format!("index_snapshot:{part}"));
12986        }
12987        parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
12988    }
12989
12990    let markdown_files = markdown_files_for_traversal(root, path_hint)?;
12991    parts.push(format!("markdown_count:{}", markdown_files.len()));
12992    for markdown_path in markdown_files {
12993        push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
12994    }
12995
12996    push_traversal_summaries_watermark_part(root, &mut parts)?;
12997
12998    Ok(Some(content_hash(&parts)?))
12999}
13000
13001fn ranked_symbol_matches<'a>(
13002    query_tokens: &BTreeSet<String>,
13003    entries: &'a [TraversalSymbolIndexEntry],
13004    index: &HashMap<String, Vec<usize>>,
13005) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13006    let mut scores = BTreeMap::<usize, usize>::new();
13007    for token in query_tokens {
13008        if let Some(indices) = index.get(token) {
13009            for idx in indices {
13010                *scores.entry(*idx).or_default() += 1;
13011            }
13012        }
13013    }
13014    let mut matches = scores
13015        .into_iter()
13016        .map(|(idx, score)| (score, &entries[idx]))
13017        .collect::<Vec<_>>();
13018    matches.sort_by(|(left_score, left), (right_score, right)| {
13019        right_score
13020            .cmp(left_score)
13021            .then_with(|| left.node.label.cmp(&right.node.label))
13022            .then_with(|| left.handle.cmp(&right.handle))
13023    });
13024    matches
13025}
13026
13027fn ranked_file_matches<'a>(
13028    query_tokens: &BTreeSet<String>,
13029    entries: &'a [TraversalFileIndexEntry],
13030    index: &HashMap<String, Vec<usize>>,
13031) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13032    let mut scores = BTreeMap::<usize, usize>::new();
13033    for token in query_tokens {
13034        if let Some(indices) = index.get(token) {
13035            for idx in indices {
13036                *scores.entry(*idx).or_default() += 1;
13037            }
13038        }
13039    }
13040    let mut matches = scores
13041        .into_iter()
13042        .map(|(idx, score)| (score, &entries[idx]))
13043        .collect::<Vec<_>>();
13044    matches.sort_by(|(left_score, left), (right_score, right)| {
13045        right_score
13046            .cmp(left_score)
13047            .then_with(|| left.node.label.cmp(&right.node.label))
13048            .then_with(|| left.handle.cmp(&right.handle))
13049    });
13050    matches
13051}
13052
13053fn ranked_route_matches<'a>(
13054    query_tokens: &BTreeSet<String>,
13055    entries: &'a [TraversalRouteIndexEntry],
13056    index: &HashMap<String, Vec<usize>>,
13057) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13058    let mut scores = BTreeMap::<usize, usize>::new();
13059    for token in query_tokens {
13060        if let Some(indices) = index.get(token) {
13061            for idx in indices {
13062                *scores.entry(*idx).or_default() += 1;
13063            }
13064        }
13065    }
13066    let mut matches = scores
13067        .into_iter()
13068        .map(|(idx, score)| (score, &entries[idx]))
13069        .collect::<Vec<_>>();
13070    matches.sort_by(|(left_score, left), (right_score, right)| {
13071        right_score
13072            .cmp(left_score)
13073            .then_with(|| left.node.label.cmp(&right.node.label))
13074            .then_with(|| left.handle.cmp(&right.handle))
13075    });
13076    matches
13077}
13078
13079fn ranked_multiplicity_matches<'a>(
13080    query_tokens: &BTreeSet<String>,
13081    entries: &'a [TraversalMultiplicityIndexEntry],
13082    index: &HashMap<String, Vec<usize>>,
13083) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13084    let mut scores = BTreeMap::<usize, usize>::new();
13085    for token in query_tokens {
13086        if let Some(indices) = index.get(token) {
13087            for idx in indices {
13088                *scores.entry(*idx).or_default() += 1;
13089            }
13090        }
13091    }
13092    let mut matches = scores
13093        .into_iter()
13094        .map(|(idx, score)| (score, &entries[idx]))
13095        .collect::<Vec<_>>();
13096    matches.sort_by(|(left_score, left), (right_score, right)| {
13097        right_score
13098            .cmp(left_score)
13099            .then_with(|| left.node.kind.cmp(&right.node.kind))
13100            .then_with(|| left.node.label.cmp(&right.node.label))
13101            .then_with(|| left.handle.cmp(&right.handle))
13102    });
13103    matches
13104}
13105
13106fn link_backlog_to_code_nodes(
13107    graph: &mut TraversalGraphBuild,
13108    backlog: &TraversalNode,
13109    text: &str,
13110    lookup: &TraversalCodeLookup<'_>,
13111    limit: usize,
13112) {
13113    let mut query_tokens = traversal_tokens(text);
13114    if let Some(ref_id) = &backlog.ref_id {
13115        query_tokens.extend(traversal_tokens(ref_id));
13116    }
13117    if query_tokens.is_empty() {
13118        return;
13119    }
13120
13121    for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13122        .into_iter()
13123        .take(limit)
13124    {
13125        graph.add_edge(
13126            &backlog.handle,
13127            &entry.handle,
13128            "mentions",
13129            Some("backlog text matches symbol tokens".to_string()),
13130            score,
13131        );
13132    }
13133
13134    for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13135        .into_iter()
13136        .take(limit.min(5))
13137    {
13138        graph.add_edge(
13139            &backlog.handle,
13140            &entry.handle,
13141            "mentions",
13142            Some("backlog text matches file tokens".to_string()),
13143            score,
13144        );
13145    }
13146
13147    for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13148        .into_iter()
13149        .take(limit.min(5))
13150    {
13151        graph.add_edge(
13152            &backlog.handle,
13153            &entry.handle,
13154            "mentions",
13155            Some("backlog text matches route tokens".to_string()),
13156            score,
13157        );
13158    }
13159
13160    for (score, entry) in ranked_multiplicity_matches(
13161        &query_tokens,
13162        lookup.multiplicities,
13163        &lookup.multiplicity_index,
13164    )
13165    .into_iter()
13166    .take(limit.min(5))
13167    {
13168        graph.add_edge(
13169            &backlog.handle,
13170            &entry.handle,
13171            "mentions",
13172            Some("backlog text matches multiplicity tokens".to_string()),
13173            score,
13174        );
13175    }
13176}
13177
13178fn load_agent_doc_traversal_nodes(
13179    root: &Path,
13180    path_hint: &Path,
13181    graph: &mut TraversalGraphBuild,
13182    lookup: &TraversalCodeLookup<'_>,
13183) -> Result<()> {
13184    for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13185        let content = match fs::read_to_string(&markdown_path) {
13186            Ok(content) => content,
13187            Err(err) => {
13188                graph.warnings.push(format!(
13189                    "session artifact unavailable: {}: {err}",
13190                    markdown_path.display()
13191                ));
13192                continue;
13193            }
13194        };
13195        if !traversal_markdown_content_looks_like_session(&content) {
13196            continue;
13197        }
13198
13199        let session_id = parse_agent_doc_session_id(&content);
13200        let session = traversal_session_node(root, &markdown_path, session_id.as_deref());
13201        graph.add_node(session.clone());
13202        let lines = content.lines().collect::<Vec<_>>();
13203        let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13204        for (idx, line) in lines.iter().enumerate() {
13205            let Some((id, text)) = parse_backlog_line(line) else {
13206                continue;
13207            };
13208            let backlog = traversal_backlog_node(root, &markdown_path, &id, &text, idx as i64 + 1);
13209            graph.add_node(backlog.clone());
13210            backlog_by_id.insert(id.clone(), backlog.clone());
13211            graph.add_edge(
13212                &session.handle,
13213                &backlog.handle,
13214                "contains",
13215                Some("session backlog item".to_string()),
13216                1,
13217            );
13218            link_backlog_to_code_nodes(graph, &backlog, &text, lookup, 8);
13219        }
13220
13221        let mut in_queue = false;
13222        let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13223        for (idx, line) in lines.iter().enumerate() {
13224            let trimmed = line.trim();
13225            if trimmed.starts_with("<!-- agent:queue") {
13226                in_queue = true;
13227                continue;
13228            }
13229            if trimmed.starts_with("<!-- /agent:queue") {
13230                in_queue = false;
13231                continue;
13232            }
13233            if !in_queue {
13234                continue;
13235            }
13236            if let Some(dispatch) = parse_queue_dispatch_line(line) {
13237                let dispatch_ref = dispatch.strip_prefix('#').unwrap_or(dispatch.as_str());
13238                let node = traversal_job_packet_node(
13239                    root,
13240                    &markdown_path,
13241                    &format!("dispatch {dispatch}"),
13242                    Some(dispatch_ref),
13243                    "agent-doc dispatch preset",
13244                    idx as i64 + 1,
13245                );
13246                graph.add_node(node.clone());
13247                graph.add_edge(
13248                    &session.handle,
13249                    &node.handle,
13250                    "contains",
13251                    Some("session queued dispatch".to_string()),
13252                    1,
13253                );
13254                continue;
13255            }
13256            if let Some(id) = parse_queue_do_line(line) {
13257                let detail = backlog_by_id
13258                    .get(&id)
13259                    .and_then(|node| node.detail.clone())
13260                    .unwrap_or_else(|| "queued backlog item".to_string());
13261                let node = traversal_job_packet_node(
13262                    root,
13263                    &markdown_path,
13264                    &format!("do #{id}"),
13265                    Some(&id),
13266                    &detail,
13267                    idx as i64 + 1,
13268                );
13269                graph.add_node(node.clone());
13270                graph.add_edge(
13271                    &session.handle,
13272                    &node.handle,
13273                    "contains",
13274                    Some("session queued job packet".to_string()),
13275                    1,
13276                );
13277                if let Some(backlog) = backlog_by_id.get(&id) {
13278                    graph.add_edge(
13279                        &node.handle,
13280                        &backlog.handle,
13281                        "targets",
13282                        Some("queued backlog item".to_string()),
13283                        1,
13284                    );
13285                }
13286                job_by_id.insert(id, node);
13287            }
13288        }
13289
13290        let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13291        for (idx, line) in lines.iter().enumerate() {
13292            for parsed in parse_worker_result_line(line, lookup) {
13293                let line_no = idx as i64 + 1;
13294                if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13295                    continue;
13296                }
13297                let result =
13298                    traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13299                graph.add_node(result.clone());
13300                graph.add_edge(
13301                    &session.handle,
13302                    &result.handle,
13303                    "contains",
13304                    Some("session worker result".to_string()),
13305                    1,
13306                );
13307                if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13308                    graph.add_edge(
13309                        &backlog.handle,
13310                        &result.handle,
13311                        "has_result",
13312                        Some(format!("worker result {}", parsed.status)),
13313                        1,
13314                    );
13315                }
13316                if let Some(job) = job_by_id.get(&parsed.id) {
13317                    graph.add_edge(
13318                        &job.handle,
13319                        &result.handle,
13320                        "has_result",
13321                        Some(format!("queued worker result {}", parsed.status)),
13322                        1,
13323                    );
13324                }
13325                let mut result_text = line.to_string();
13326                if !parsed.touched_files.is_empty() {
13327                    result_text.push(' ');
13328                    result_text.push_str(&parsed.touched_files.join(" "));
13329                }
13330                link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
13331            }
13332        }
13333    }
13334    Ok(())
13335}
13336
13337#[derive(Debug, Clone)]
13338struct AgentDocIndexGate {
13339    db_path: Option<PathBuf>,
13340    source_root: PathBuf,
13341    diagnostics: Vec<String>,
13342}
13343
13344#[derive(Clone, Hash, PartialEq, Eq)]
13345struct AgentDocIndexGateCacheKey {
13346    root: PathBuf,
13347    path_hint: PathBuf,
13348    scope: Option<String>,
13349    packet_label: String,
13350}
13351
13352fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
13353    std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
13354> {
13355    static CACHE: std::sync::OnceLock<
13356        std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
13357    > = std::sync::OnceLock::new();
13358    CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
13359}
13360
13361fn prepare_agent_doc_index_gate_cached(
13362    root: &Path,
13363    path_hint: &Path,
13364    scope: Option<&str>,
13365    packet_label: &str,
13366) -> (AgentDocIndexGate, String) {
13367    let key = AgentDocIndexGateCacheKey {
13368        root: root.to_path_buf(),
13369        path_hint: path_hint.to_path_buf(),
13370        scope: scope.map(str::to_string),
13371        packet_label: packet_label.to_string(),
13372    };
13373    if let Ok(cache) = agent_doc_index_gate_cache().lock()
13374        && let Some(cached) = cache.get(&key)
13375    {
13376        return (
13377            cached.clone(),
13378            "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
13379        );
13380    }
13381    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
13382    if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
13383        cache.insert(key, gate.clone());
13384    }
13385    (
13386        gate,
13387        "fresh inspection/refresh — cache miss on this preparation key".to_string(),
13388    )
13389}
13390
13391fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
13392    match state {
13393        SearchIndexState::Fresh => None,
13394        SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
13395        SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
13396    }
13397}
13398
13399fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
13400    rebuild_search_target_detail(&RebuildSearchTarget {
13401        label: target.label.clone(),
13402        reason,
13403        reindex_cmd: target.reindex_cmd.clone(),
13404    })
13405}
13406
13407fn index_refresh_diagnostic(
13408    target: &SearchIndexTarget,
13409    reason: RebuildSearchReason,
13410    summary: &index::IndexSummary,
13411    packet_label: &str,
13412) -> String {
13413    let changed = summary.new + summary.modified + summary.deleted;
13414    format!(
13415        "index refreshed: {}; updated {} changed file{} before {}",
13416        index_reason_detail(target, reason),
13417        changed,
13418        if changed == 1 { "" } else { "s" },
13419        packet_label
13420    )
13421}
13422
13423fn index_refresh_fallback_diagnostic(
13424    target: &SearchIndexTarget,
13425    reason: RebuildSearchReason,
13426    err: &anyhow::Error,
13427    packet_label: &str,
13428) -> String {
13429    format!(
13430        "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
13431        index_reason_detail(target, reason),
13432        packet_label
13433    )
13434}
13435
13436fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
13437    if let Some(scope_name) = scope
13438        && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
13439    {
13440        return scope.source_root;
13441    }
13442    if let Some(scope_name) = scope
13443        && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
13444    {
13445        return package.package_root;
13446    }
13447    if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
13448        return scope.source_root;
13449    }
13450    if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
13451        return package.package_root;
13452    }
13453    if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
13454        return scope.source_root;
13455    }
13456    root.to_path_buf()
13457}
13458
13459fn prepare_agent_doc_index_gate(
13460    root: &Path,
13461    path_hint: &Path,
13462    scope: Option<&str>,
13463    packet_label: &str,
13464) -> AgentDocIndexGate {
13465    let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
13466    let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13467        Ok(targets) => targets,
13468        Err(err) => {
13469            return AgentDocIndexGate {
13470                db_path: None,
13471                source_root: fallback_source_root,
13472                diagnostics: vec![format!(
13473                    "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13474                )],
13475            };
13476        }
13477    };
13478    let Some(target) = targets.into_iter().next() else {
13479        return AgentDocIndexGate {
13480            db_path: None,
13481            source_root: fallback_source_root,
13482            diagnostics: vec![format!(
13483                "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
13484            )],
13485        };
13486    };
13487
13488    let state = match inspect_search_index(&target) {
13489        Ok(state) => state,
13490        Err(err) => {
13491            return AgentDocIndexGate {
13492                db_path: None,
13493                source_root: target.source_root,
13494                diagnostics: vec![format!(
13495                    "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13496                )],
13497            };
13498        }
13499    };
13500
13501    let Some(reason) = index_reason_for_state(state) else {
13502        return AgentDocIndexGate {
13503            db_path: Some(target.db_path),
13504            source_root: target.source_root,
13505            diagnostics: Vec::new(),
13506        };
13507    };
13508
13509    match apply_search_index_update(root, &target) {
13510        Ok(summary) => {
13511            // #gdbgatecold: the index was just rewritten, so any cached
13512            // pre-refresh inspection result for this scope (held by the
13513            // active lazily-backed `InspectScopeGuard`) is stale. Invalidate
13514            // the scope epoch so the next `inspect_read_only` re-reads the
13515            // fresh index.
13516            index::inspect_scope_invalidate_all();
13517            let diagnostics = vec![index_refresh_diagnostic(
13518                &target,
13519                reason,
13520                &summary,
13521                packet_label,
13522            )];
13523            AgentDocIndexGate {
13524                db_path: Some(target.db_path),
13525                source_root: target.source_root,
13526                diagnostics,
13527            }
13528        }
13529        Err(err) => {
13530            let diagnostics = vec![index_refresh_fallback_diagnostic(
13531                &target,
13532                reason,
13533                &err,
13534                packet_label,
13535            )];
13536            AgentDocIndexGate {
13537                db_path: None,
13538                source_root: target.source_root,
13539                diagnostics,
13540            }
13541        }
13542    }
13543}
13544
13545fn add_raw_source_file_nodes(
13546    root: &Path,
13547    source_root: &Path,
13548    graph: &mut TraversalGraphBuild,
13549    file_entries: &mut Vec<TraversalFileIndexEntry>,
13550) -> Result<()> {
13551    let mut entries = walk::walk_files(source_root)?;
13552    entries.sort_by(|left, right| left.path.cmp(&right.path));
13553    for entry in entries {
13554        let file = entry.path.to_string_lossy();
13555        let node = traversal_raw_source_file_node(root, file.as_ref());
13556        let entry = TraversalFileIndexEntry {
13557            handle: node.handle.clone(),
13558            tokens: traversal_node_tokens(&node),
13559            node: node.clone(),
13560        };
13561        graph.add_node(node);
13562        file_entries.push(entry);
13563    }
13564    Ok(())
13565}
13566
13567fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
13568    if scope_root.is_empty() {
13569        return true;
13570    }
13571    path == scope_root || path.starts_with(&format!("{scope_root}/"))
13572}
13573
13574fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
13575    let path = Path::new(file);
13576    if path.is_absolute() {
13577        return path.to_path_buf();
13578    }
13579    let source_candidate = source_root.join(path);
13580    if source_candidate.exists() {
13581        source_candidate
13582    } else {
13583        root.join(path)
13584    }
13585}
13586
13587fn cargo_import_alias_from_line(line: &str) -> Option<String> {
13588    let trimmed = line.trim();
13589    let rest = trimmed
13590        .strip_prefix("pub use ")
13591        .or_else(|| trimmed.strip_prefix("use "))
13592        .or_else(|| trimmed.strip_prefix("extern crate "))?;
13593    let alias = rest
13594        .split([':', ';', ' ', '\t'])
13595        .next()
13596        .unwrap_or_default()
13597        .trim();
13598    (!alias.is_empty()).then(|| alias.to_string())
13599}
13600
13601fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
13602    let mut aliases = BTreeSet::new();
13603    for entry in walk::walk_files(&package.package_root)? {
13604        if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
13605            continue;
13606        }
13607        let content = fs::read_to_string(&entry.path)
13608            .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
13609        aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
13610    }
13611    Ok(aliases)
13612}
13613
13614fn load_multiplicity_traversal_nodes(
13615    root: &Path,
13616    source_root: &Path,
13617    graph: &mut TraversalGraphBuild,
13618    file_handle_by_path: &HashMap<String, String>,
13619    multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
13620) -> Result<()> {
13621    let inventory = multiplicity::discover_cargo_inventory(source_root)?;
13622    let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
13623    for workspace in &inventory.workspaces {
13624        let node = traversal_cargo_workspace_node(root, workspace);
13625        workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
13626        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13627            handle: node.handle.clone(),
13628            tokens: traversal_node_tokens(&node),
13629            node: node.clone(),
13630        });
13631        graph.add_node(node);
13632    }
13633
13634    let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
13635    let mut package_nodes = Vec::new();
13636    for package in &inventory.packages {
13637        let node = traversal_cargo_package_node(root, package);
13638        package_handle_by_name
13639            .entry(package.name.clone())
13640            .or_default()
13641            .push(node.handle.clone());
13642        package_handle_by_name
13643            .entry(package.normalized_name.clone())
13644            .or_default()
13645            .push(node.handle.clone());
13646        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13647            handle: node.handle.clone(),
13648            tokens: traversal_node_tokens(&node),
13649            node: node.clone(),
13650        });
13651        graph.add_node(node.clone());
13652        package_nodes.push((package, node));
13653    }
13654
13655    for (package, node) in &package_nodes {
13656        if let Some(workspace_handle) =
13657            workspace_handle_by_root.get(&package.relative_workspace_root)
13658        {
13659            graph.add_edge(
13660                workspace_handle,
13661                &node.handle,
13662                "contains_package",
13663                Some("Cargo workspace member package".to_string()),
13664                1,
13665            );
13666        }
13667        let package_root = relativize_pathbuf(&package.package_root, root)
13668            .to_string_lossy()
13669            .replace('\\', "/");
13670        for (file, handle) in file_handle_by_path {
13671            if relative_path_inside_scope(file, &package_root) {
13672                graph.add_edge(
13673                    &node.handle,
13674                    handle,
13675                    "owns_file",
13676                    Some("Cargo package owns source file".to_string()),
13677                    1,
13678                );
13679            }
13680        }
13681        for dependency in &package.dependencies {
13682            if let Some(handles) = package_handle_by_name.get(&dependency.name)
13683                && handles.len() == 1
13684            {
13685                graph.add_edge(
13686                    &node.handle,
13687                    &handles[0],
13688                    "declares_dependency",
13689                    Some(format!("{} Cargo dependency", dependency.kind)),
13690                    1,
13691                );
13692            }
13693        }
13694        for alias in cargo_import_aliases(package)? {
13695            if let Some(handles) = package_handle_by_name.get(&alias)
13696                && handles.len() == 1
13697                && handles[0] != node.handle
13698            {
13699                graph.add_edge(
13700                    &node.handle,
13701                    &handles[0],
13702                    "uses_crate",
13703                    Some("Rust use/extern crate reference".to_string()),
13704                    1,
13705                );
13706                graph.add_edge(
13707                    &node.handle,
13708                    &handles[0],
13709                    "imports",
13710                    Some("Rust use/extern crate import".to_string()),
13711                    1,
13712                );
13713            }
13714        }
13715    }
13716
13717    Ok(())
13718}
13719
13720fn build_traversal_graph_source_with_options(
13721    root: &Path,
13722    path_hint: &Path,
13723    scope: Option<&str>,
13724    session_only: bool,
13725) -> Result<TraversalGraphBuild> {
13726    let mut graph = TraversalGraphBuild::default();
13727    let mut symbol_entries = Vec::new();
13728    let mut file_entries = Vec::new();
13729    let mut route_entries = Vec::new();
13730    let mut multiplicity_entries = Vec::new();
13731    let mut file_handle_by_path = HashMap::<String, String>::new();
13732    let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
13733    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13734        let (gate, _cache_detail) =
13735            prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
13736        graph.warnings.extend(gate.diagnostics);
13737        let gate_source_root = gate.source_root.clone();
13738
13739        match gate.db_path {
13740            Some(db_path) if db_path.exists() => {
13741                let db = index::IndexDb::open_read_only_resilient(&db_path)?;
13742                let file_paths = db.file_paths()?;
13743                for file in file_paths {
13744                    if traversal_path_is_generated_artifact(
13745                        root,
13746                        &gate_source_root,
13747                        Path::new(&file),
13748                    ) {
13749                        continue;
13750                    }
13751                    let node = traversal_file_node(root, &file);
13752                    let entry = TraversalFileIndexEntry {
13753                        handle: node.handle.clone(),
13754                        tokens: traversal_node_tokens(&node),
13755                        node: node.clone(),
13756                    };
13757                    if let Some(path) = entry.node.path.as_ref() {
13758                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
13759                    }
13760                    graph.add_node(node);
13761                    file_entries.push(entry);
13762                }
13763
13764                let symbols = db.all_symbols()?;
13765                let mut symbol_by_file_name_line = HashMap::new();
13766                let mut span_by_file_name_line = HashMap::new();
13767                let mut first_symbol_by_name = BTreeMap::<String, String>::new();
13768                let mut first_span_by_name = BTreeMap::<String, String>::new();
13769                let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
13770                let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
13771                for symbol in symbols.iter().filter(|symbol| {
13772                    !traversal_path_is_generated_artifact(
13773                        root,
13774                        &gate_source_root,
13775                        Path::new(&symbol.file),
13776                    )
13777                }) {
13778                    let node = traversal_symbol_node(root, symbol);
13779                    let file = relativize(&symbol.file, root);
13780                    symbol_by_file_name_line.insert(
13781                        format!("{file}:{}:{}", symbol.line, symbol.name),
13782                        node.handle.clone(),
13783                    );
13784                    first_symbol_by_name
13785                        .entry(symbol.name.clone())
13786                        .or_insert_with(|| node.handle.clone());
13787                    let entry = TraversalSymbolIndexEntry {
13788                        handle: node.handle.clone(),
13789                        tokens: traversal_node_tokens(&node),
13790                        node: node.clone(),
13791                    };
13792                    graph.add_node(node.clone());
13793                    if let Some(file_handle) = file_handle_by_path.get(&file) {
13794                        graph.add_edge(
13795                            file_handle,
13796                            &node.handle,
13797                            "defines",
13798                            Some("file defines symbol".to_string()),
13799                            1,
13800                        );
13801                    }
13802                    if !source_by_file.contains_key(&symbol.file) {
13803                        let source_path =
13804                            traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
13805                        source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
13806                    }
13807                    if let Some(Some(source)) = source_by_file.get(&symbol.file)
13808                        && let Some((ast_node, mut ast_entry)) =
13809                            traversal_ast_span_node(root, symbol, source, &symbols)
13810                    {
13811                        ast_entry.symbol_handle = node.handle.clone();
13812                        ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
13813                        span_by_file_name_line.insert(
13814                            format!("{file}:{}:{}", symbol.line, symbol.name),
13815                            ast_node.handle.clone(),
13816                        );
13817                        first_span_by_name
13818                            .entry(symbol.name.clone())
13819                            .or_insert_with(|| ast_node.handle.clone());
13820                        graph.add_node(ast_node.clone());
13821                        graph.add_edge(
13822                            &node.handle,
13823                            &ast_node.handle,
13824                            "has_ast_span",
13825                            Some("symbol projects to indexed AST span".to_string()),
13826                            1,
13827                        );
13828                        graph.add_edge(
13829                            &ast_node.handle,
13830                            &node.handle,
13831                            "represents_symbol",
13832                            Some("AST span represents indexed symbol".to_string()),
13833                            1,
13834                        );
13835                        ast_entries.push(ast_entry);
13836                    }
13837                    symbol_entries.push(entry);
13838                }
13839                link_ast_navigation_edges(&mut graph, &ast_entries);
13840                link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
13841
13842                if !bounded_session_projection {
13843                    for edge in db.all_stored_edges()? {
13844                        if traversal_path_is_generated_artifact(
13845                            root,
13846                            &gate_source_root,
13847                            Path::new(&edge.caller_file),
13848                        ) {
13849                            continue;
13850                        }
13851                        let caller_file = relativize(&edge.caller_file, root);
13852                        let caller_key =
13853                            format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
13854                        let Some(caller_handle) =
13855                            symbol_by_file_name_line.get(&caller_key).cloned()
13856                        else {
13857                            continue;
13858                        };
13859                        let callee_handle = if let Some(handle) =
13860                            first_symbol_by_name.get(&edge.callee_name)
13861                        {
13862                            handle.clone()
13863                        } else {
13864                            let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
13865                            let handle = node.handle.clone();
13866                            graph.add_node(node);
13867                            handle
13868                        };
13869                        graph.add_edge(
13870                            &caller_handle,
13871                            &callee_handle,
13872                            "calls",
13873                            Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
13874                            1,
13875                        );
13876                        if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
13877                            && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
13878                        {
13879                            graph.add_edge(
13880                                caller_span,
13881                                callee_span,
13882                                "calls",
13883                                Some(format!(
13884                                    "AST call site {}:{}",
13885                                    caller_file, edge.call_site_line
13886                                )),
13887                                1,
13888                            );
13889                        }
13890                    }
13891                }
13892
13893                for route in db.all_routes()? {
13894                    if traversal_path_is_generated_artifact(
13895                        root,
13896                        &gate_source_root,
13897                        Path::new(&route.file),
13898                    ) {
13899                        continue;
13900                    }
13901                    let node = traversal_route_node(root, &route);
13902                    let entry = TraversalRouteIndexEntry {
13903                        handle: node.handle.clone(),
13904                        tokens: traversal_node_tokens(&node),
13905                        node: node.clone(),
13906                    };
13907                    graph.add_node(node.clone());
13908                    if let Some(path) = node.path.as_ref()
13909                        && let Some(file_handle) = file_handle_by_path.get(path)
13910                    {
13911                        graph.add_edge(
13912                            file_handle,
13913                            &node.handle,
13914                            "defines_route",
13915                            Some("file declares route".to_string()),
13916                            1,
13917                        );
13918                    }
13919                    let handler_handle =
13920                        if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
13921                            handle.clone()
13922                        } else {
13923                            let node = traversal_unresolved_symbol_node(root, &route.handler_name);
13924                            let handle = node.handle.clone();
13925                            graph.add_node(node);
13926                            handle
13927                        };
13928                    graph.add_edge(
13929                        &entry.handle,
13930                        &handler_handle,
13931                        "handled_by",
13932                        Some("route handler reference".to_string()),
13933                        1,
13934                    );
13935                    if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
13936                        graph.add_edge(
13937                            &entry.handle,
13938                            handler_span,
13939                            "handled_by",
13940                            Some("route handler AST span".to_string()),
13941                            1,
13942                        );
13943                        graph.add_edge(
13944                            handler_span,
13945                            &entry.handle,
13946                            "handles_route",
13947                            Some("AST span handles route".to_string()),
13948                            1,
13949                        );
13950                    }
13951                    route_entries.push(entry);
13952                }
13953            }
13954            _ => {
13955                add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
13956                    .with_context(|| {
13957                    format!(
13958                        "loading raw source fallback nodes from {}",
13959                        gate_source_root.display()
13960                    )
13961                })?;
13962                for entry in &file_entries {
13963                    if let Some(path) = entry.node.path.as_ref() {
13964                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
13965                    }
13966                }
13967            }
13968        }
13969        load_multiplicity_traversal_nodes(
13970            root,
13971            &gate_source_root,
13972            &mut graph,
13973            &file_handle_by_path,
13974            &mut multiplicity_entries,
13975        )?;
13976    }
13977
13978    let code_lookup = TraversalCodeLookup::new(
13979        &symbol_entries,
13980        &file_entries,
13981        &route_entries,
13982        &multiplicity_entries,
13983    );
13984    load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
13985    Ok(graph)
13986}
13987
13988#[cfg(test)]
13989fn build_traversal_graph_source(
13990    root: &Path,
13991    path_hint: &Path,
13992    scope: Option<&str>,
13993) -> Result<TraversalGraphBuild> {
13994    build_traversal_graph_source_with_options(root, path_hint, scope, false)
13995}
13996
13997pub(crate) fn write_traversal_graph_store_with_options(
13998    root: &Path,
13999    path_hint: &Path,
14000    scope: Option<&str>,
14001    session_only: bool,
14002) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14003    let source_graph =
14004        build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14005    let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14006    let graph_db = graph_substrate_db_path(root, scope);
14007    let mut store = SqliteGraphStore::open(&graph_db)?;
14008    let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14009        .ok()
14010        .flatten()
14011        .or_else(|| graph_projection_content_hash(&projection));
14012    let refresh = store.replace_projection_with_version(
14013        scope.unwrap_or("root"),
14014        &projection,
14015        Some(GRAPH_PROJECTION_VERSION),
14016        source_watermark,
14017    )?;
14018    Ok((source_graph, refresh))
14019}
14020
14021pub(crate) fn write_traversal_graph_store(
14022    root: &Path,
14023    path_hint: &Path,
14024    scope: Option<&str>,
14025) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14026    write_traversal_graph_store_with_options(root, path_hint, scope, false)
14027}
14028
14029fn refresh_traversal_graph_store_with_options(
14030    root: &Path,
14031    path_hint: &Path,
14032    scope: Option<&str>,
14033    session_only: bool,
14034) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14035    let (source_graph, refresh) =
14036        write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14037    let graph_db = graph_substrate_db_path(root, scope);
14038    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14039    let mut graph = traversal_graph_from_store(root, &store)?;
14040    graph.warnings = source_graph.warnings;
14041    Ok((graph, refresh))
14042}
14043
14044fn refresh_traversal_graph_store(
14045    root: &Path,
14046    path_hint: &Path,
14047    scope: Option<&str>,
14048) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14049    refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14050}
14051
14052pub(crate) fn build_traversal_graph(
14053    root: &Path,
14054    path_hint: &Path,
14055    scope: Option<&str>,
14056) -> Result<TraversalGraphBuild> {
14057    let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14058    Ok(graph)
14059}
14060
14061fn traversal_query_kind_priority(kind: &str) -> usize {
14062    match kind {
14063        "backlog" => 0,
14064        "job_packet" => 1,
14065        "worker_result" => 2,
14066        "symbol" => 3,
14067        "ast_span" => 4,
14068        "file" => 5,
14069        "route" => 6,
14070        "cargo_package" => 7,
14071        "cargo_workspace" => 8,
14072        "session" => 9,
14073        "semantic_concept" => 10,
14074        "semantic_entity" => 11,
14075        _ => 12,
14076    }
14077}
14078
14079fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14080    let trimmed = query.trim();
14081    if trimmed.is_empty() {
14082        return None;
14083    }
14084    let kind_priority = traversal_query_kind_priority(&node.kind);
14085    if node.handle == trimmed {
14086        return Some((0, kind_priority, node.handle.clone()));
14087    }
14088    if node.path.as_deref() == Some(trimmed) {
14089        let path_priority = if node.kind == "file" {
14090            0
14091        } else {
14092            kind_priority.saturating_add(1)
14093        };
14094        return Some((1, path_priority, node.handle.clone()));
14095    }
14096    let normalized_backlog = trimmed.trim_start_matches('#');
14097    if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14098    {
14099        return Some((2, kind_priority, node.handle.clone()));
14100    }
14101    if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14102        return Some((3, kind_priority, node.handle.clone()));
14103    }
14104    None
14105}
14106
14107fn resolve_traversal_node<'a>(
14108    graph: &'a TraversalGraphBuild,
14109    query: &str,
14110) -> Option<&'a TraversalNode> {
14111    graph
14112        .nodes
14113        .values()
14114        .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14115        .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14116        .map(|(_, node)| node)
14117}
14118
14119fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14120    let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14121    for edge in edges {
14122        adj.entry(edge.from.clone())
14123            .or_default()
14124            .insert(edge.to.clone());
14125        adj.entry(edge.to.clone())
14126            .or_default()
14127            .insert(edge.from.clone());
14128    }
14129    adj.into_iter()
14130        .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14131        .collect()
14132}
14133
14134fn traversal_shortest_handles(
14135    edges: &[TraversalEdge],
14136    from: &str,
14137    to: &str,
14138) -> Option<Vec<String>> {
14139    if from == to {
14140        return Some(vec![from.to_string()]);
14141    }
14142    let adj = traversal_adjacency(edges);
14143    if !adj.contains_key(from) || !adj.contains_key(to) {
14144        return None;
14145    }
14146    let mut visited = BTreeSet::new();
14147    let mut queue = VecDeque::new();
14148    let mut parent = BTreeMap::<String, String>::new();
14149    visited.insert(from.to_string());
14150    queue.push_back(from.to_string());
14151    while let Some(current) = queue.pop_front() {
14152        if let Some(neighbors) = adj.get(&current) {
14153            for neighbor in neighbors {
14154                if visited.insert(neighbor.clone()) {
14155                    parent.insert(neighbor.clone(), current.clone());
14156                    if neighbor == to {
14157                        let mut path = vec![to.to_string()];
14158                        let mut cursor = to.to_string();
14159                        while let Some(prev) = parent.get(&cursor) {
14160                            path.push(prev.clone());
14161                            cursor = prev.clone();
14162                        }
14163                        path.reverse();
14164                        return Some(path);
14165                    }
14166                    queue.push_back(neighbor.clone());
14167                }
14168            }
14169        }
14170    }
14171    None
14172}
14173
14174fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14175    let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14176    for edge in edges {
14177        let neighbor = if edge.from == current {
14178            edge.to.as_str()
14179        } else if edge.to == current {
14180            edge.from.as_str()
14181        } else {
14182            continue;
14183        };
14184        let score = traversal_relation_score(edge, current);
14185        best_score_by_neighbor
14186            .entry(neighbor.to_string())
14187            .and_modify(|best| *best = (*best).max(score))
14188            .or_insert(score);
14189    }
14190    let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14191    ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14192        right_score
14193            .cmp(left_score)
14194            .then_with(|| left_handle.cmp(right_handle))
14195    });
14196    ranked.into_iter().map(|(handle, _)| handle).collect()
14197}
14198
14199fn traversal_neighborhood_handles(
14200    edges: &[TraversalEdge],
14201    origin: &str,
14202    depth: usize,
14203    limit: usize,
14204) -> BTreeSet<String> {
14205    let mut seen = BTreeSet::new();
14206    let mut queue = VecDeque::new();
14207    seen.insert(origin.to_string());
14208    queue.push_back((origin.to_string(), 0usize));
14209    while let Some((current, current_depth)) = queue.pop_front() {
14210        if current_depth >= depth {
14211            continue;
14212        }
14213        for neighbor in traversal_scored_neighbors(edges, &current) {
14214            if limit > 0 && seen.len() >= limit {
14215                return seen;
14216            }
14217            if seen.insert(neighbor.clone()) {
14218                queue.push_back((neighbor, current_depth + 1));
14219            }
14220        }
14221    }
14222    seen
14223}
14224
14225fn traversal_edges_between(
14226    handles: &BTreeSet<String>,
14227    edges: &[TraversalEdge],
14228) -> Vec<TraversalEdge> {
14229    edges
14230        .iter()
14231        .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14232        .cloned()
14233        .collect()
14234}
14235
14236fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14237    let mut result = Vec::new();
14238    for pair in path.windows(2) {
14239        if let Some(edge) = edges.iter().find(|edge| {
14240            (edge.from == pair[0] && edge.to == pair[1])
14241                || (edge.from == pair[1] && edge.to == pair[0])
14242        }) {
14243            result.push(edge.clone());
14244        }
14245    }
14246    result
14247}
14248
14249fn sorted_traversal_nodes<'a>(
14250    nodes: impl IntoIterator<Item = &'a TraversalNode>,
14251) -> Vec<TraversalNode> {
14252    let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14253    nodes.sort_by(|left, right| {
14254        left.kind
14255            .cmp(&right.kind)
14256            .then_with(|| left.label.cmp(&right.label))
14257            .then_with(|| left.path.cmp(&right.path))
14258            .then_with(|| left.handle.cmp(&right.handle))
14259    });
14260    nodes
14261}
14262
14263fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14264    let base = match edge.relation.as_str() {
14265        "mentions" => 100,
14266        "contains" => 80,
14267        "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14268        "contains_embedded_symbol" | "embedded_in_fence" => 77,
14269        "contains_markdown_block"
14270        | "contains_embedded_code"
14271        | "enclosing_module"
14272        | "enclosing_section" => 76,
14273        "calls" => {
14274            if edge.from == origin {
14275                70
14276            } else {
14277                65
14278            }
14279        }
14280        "handled_by" | "handles_route" => 68,
14281        "defines_route" => 62,
14282        "imports" => 62,
14283        "previous_sibling" | "next_sibling" => 54,
14284        "mentions_concept" | "mentions_entity" => 66,
14285        "semantic_relation" => 64,
14286        "tagged_concept" | "related_concept" => 58,
14287        "defines" => {
14288            if edge.from == origin {
14289                60
14290            } else {
14291                55
14292            }
14293        }
14294        _ => 10,
14295    };
14296    base + edge.weight
14297}
14298
14299fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
14300    match edge.relation.as_str() {
14301        "mentions" => "matched from backlog/session text".to_string(),
14302        "contains" => "contained in the selected session artifact".to_string(),
14303        "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
14304        "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
14305        "parent" => "parent AST span".to_string(),
14306        "child" => "child AST span".to_string(),
14307        "previous_sibling" => "previous AST sibling".to_string(),
14308        "next_sibling" => "next AST sibling".to_string(),
14309        "contains_markdown_block" => "Markdown section block".to_string(),
14310        "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
14311        "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
14312        "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
14313        "enclosing_module" => "nearest enclosing module".to_string(),
14314        "enclosing_section" => "nearest enclosing Markdown section".to_string(),
14315        "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
14316        "defines" => "file that defines the selected symbol".to_string(),
14317        "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
14318        "defines_route" => "file that declares the selected route".to_string(),
14319        "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
14320        "handled_by" => "route handled by the selected symbol".to_string(),
14321        "handles_route" => "route handled by the selected AST span".to_string(),
14322        "imports" => "import dependency from the selected package".to_string(),
14323        "mentions_concept" => "cached summary concept for the selected source".to_string(),
14324        "mentions_entity" => "cached summary entity for the selected source".to_string(),
14325        "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
14326        "tagged_concept" => "concept label attached to the selected entity".to_string(),
14327        "related_concept" => "co-occurring cached summary concept".to_string(),
14328        "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
14329        "calls" => "caller of the selected symbol".to_string(),
14330        other => format!("connected by {other}"),
14331    }
14332}
14333
14334fn traversal_recommendations(
14335    graph: &TraversalGraphBuild,
14336    origin: Option<&str>,
14337    shortest_path: Option<&[String]>,
14338    limit: usize,
14339) -> Vec<TraversalRecommendation> {
14340    let Some(origin) = origin else {
14341        return Vec::new();
14342    };
14343    let mut recommendations = Vec::new();
14344    let mut seen = BTreeSet::new();
14345
14346    if let Some(path) = shortest_path
14347        && path.len() > 1
14348        && path.first().is_some_and(|handle| handle == origin)
14349        && let Some(next) = graph.nodes.get(&path[1])
14350    {
14351        seen.insert(next.handle.clone());
14352        recommendations.push(TraversalRecommendation {
14353            handle: next.handle.clone(),
14354            kind: next.kind.clone(),
14355            label: next.label.clone(),
14356            reason: "next hop on shortest path".to_string(),
14357            score: 1_000,
14358            expand: next.expand.clone(),
14359        });
14360    }
14361
14362    let mut candidates = graph
14363        .edges
14364        .iter()
14365        .filter_map(|edge| {
14366            let neighbor = if edge.from == origin {
14367                edge.to.as_str()
14368            } else if edge.to == origin {
14369                edge.from.as_str()
14370            } else {
14371                return None;
14372            };
14373            let node = graph.nodes.get(neighbor)?;
14374            Some((traversal_relation_score(edge, origin), edge, node))
14375        })
14376        .collect::<Vec<_>>();
14377    candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
14378        right_score
14379            .cmp(left_score)
14380            .then_with(|| left.kind.cmp(&right.kind))
14381            .then_with(|| left.label.cmp(&right.label))
14382            .then_with(|| left.handle.cmp(&right.handle))
14383    });
14384
14385    let max = if limit == 0 { usize::MAX } else { limit };
14386    for (score, edge, node) in candidates {
14387        if recommendations.len() >= max {
14388            break;
14389        }
14390        if seen.insert(node.handle.clone()) {
14391            recommendations.push(TraversalRecommendation {
14392                handle: node.handle.clone(),
14393                kind: node.kind.clone(),
14394                label: node.label.clone(),
14395                reason: traversal_recommendation_reason(edge, origin),
14396                score,
14397                expand: node.expand.clone(),
14398            });
14399        }
14400    }
14401
14402    recommendations
14403}
14404
14405fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
14406    let scale = nodes.saturating_add(edges);
14407    if scale <= 80 {
14408        ExplorationBudget {
14409            project_size: "small".to_string(),
14410            max_source_windows: 8,
14411            lines_per_window: 96,
14412            relationship_limit: 40,
14413        }
14414    } else if scale <= 800 {
14415        ExplorationBudget {
14416            project_size: "medium".to_string(),
14417            max_source_windows: 6,
14418            lines_per_window: 80,
14419            relationship_limit: 32,
14420        }
14421    } else {
14422        ExplorationBudget {
14423            project_size: "large".to_string(),
14424            max_source_windows: 4,
14425            lines_per_window: 64,
14426            relationship_limit: 24,
14427        }
14428    }
14429}
14430
14431fn exploration_node_label(node: &TraversalNode) -> String {
14432    format!("{}:{}", node.kind, node.label)
14433}
14434
14435fn exploration_source_window_for_node(
14436    root: &Path,
14437    node: &TraversalNode,
14438    budget: &ExplorationBudget,
14439) -> Option<ExplorationSourceWindow> {
14440    let file = node.path.as_ref()?;
14441    let anchor = node
14442        .line
14443        .and_then(|line| usize::try_from(line).ok())
14444        .and_then(|line| line.checked_add(1))
14445        .unwrap_or(1);
14446    let context_before = budget.lines_per_window / 3;
14447    let start = anchor.saturating_sub(context_before).max(1);
14448    let end = start
14449        .saturating_add(budget.lines_per_window)
14450        .saturating_sub(1);
14451    let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
14452    Some(ExplorationSourceWindow {
14453        handle,
14454        file: file.clone(),
14455        start,
14456        end,
14457        reason: format!("cluster around {}", exploration_node_label(node)),
14458        expand: source_read_command(root, file, start, budget.lines_per_window),
14459    })
14460}
14461
14462fn build_exploration_packet(
14463    root: &Path,
14464    totals: &TraversalTotals,
14465    selected_nodes: &[TraversalNode],
14466    selected_edges: &[TraversalEdge],
14467) -> ExplorationPacket {
14468    let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
14469    let node_by_handle = selected_nodes
14470        .iter()
14471        .map(|node| (node.handle.as_str(), node))
14472        .collect::<BTreeMap<_, _>>();
14473    let relationship_map = selected_edges
14474        .iter()
14475        .take(budget.relationship_limit)
14476        .filter_map(|edge| {
14477            let from = node_by_handle.get(edge.from.as_str())?;
14478            let to = node_by_handle.get(edge.to.as_str())?;
14479            Some(ExplorationRelation {
14480                from: exploration_node_label(from),
14481                relation: edge.relation.clone(),
14482                to: exploration_node_label(to),
14483                label: edge.label.clone(),
14484            })
14485        })
14486        .collect::<Vec<_>>();
14487
14488    let mut seen_windows = BTreeSet::new();
14489    let mut source_windows = Vec::new();
14490    for node in selected_nodes {
14491        if source_windows.len() >= budget.max_source_windows {
14492            break;
14493        }
14494        let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
14495            continue;
14496        };
14497        let key = (window.file.clone(), window.start, window.end);
14498        if seen_windows.insert(key) {
14499            source_windows.push(window);
14500        }
14501    }
14502
14503    ExplorationPacket {
14504        budget,
14505        relationship_map,
14506        source_windows,
14507        worker_context: Vec::new(),
14508        no_reread_guidance:
14509            "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
14510                .to_string(),
14511    }
14512}
14513
14514pub(crate) fn traversal_report(
14515    root: &Path,
14516    scope: Option<&str>,
14517    graph: TraversalGraphBuild,
14518    query: Option<&str>,
14519    target: Option<&str>,
14520    depth: usize,
14521    limit: usize,
14522) -> Result<TraversalReport> {
14523    let totals = TraversalTotals {
14524        nodes: graph.nodes.len(),
14525        edges: graph.edges.len(),
14526    };
14527    let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
14528    let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
14529    if let Some(query) = query
14530        && origin_node.is_none()
14531    {
14532        bail!("traversal node not found: {}", query);
14533    }
14534    if let Some(target) = target
14535        && target_node.is_none()
14536    {
14537        bail!("traversal target not found: {}", target);
14538    }
14539
14540    let (mode, selected_nodes, selected_edges, shortest_path) =
14541        if let (Some(origin), Some(target)) = (origin_node, target_node) {
14542            if let Some(handles) =
14543                traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
14544            {
14545                let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
14546                let nodes = handles
14547                    .iter()
14548                    .filter_map(|handle| graph.nodes.get(handle).cloned())
14549                    .collect::<Vec<_>>();
14550                let edges = traversal_path_edges(&handles, &graph.edges);
14551                let path = TraversalPathReport {
14552                    from: origin.clone(),
14553                    to: target.clone(),
14554                    hops: handles.len().saturating_sub(1),
14555                    nodes: nodes.clone(),
14556                    edges: edges.clone(),
14557                };
14558                (
14559                    "path".to_string(),
14560                    nodes,
14561                    traversal_edges_between(&handle_set, &graph.edges),
14562                    Some(path),
14563                )
14564            } else {
14565                (
14566                    "path".to_string(),
14567                    vec![origin.clone(), target.clone()],
14568                    Vec::new(),
14569                    None,
14570                )
14571            }
14572        } else if let Some(origin) = origin_node {
14573            let handles =
14574                traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
14575            let nodes =
14576                sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
14577            let edges = traversal_edges_between(&handles, &graph.edges);
14578            ("neighborhood".to_string(), nodes, edges, None)
14579        } else {
14580            let mut nodes = sorted_traversal_nodes(graph.nodes.values());
14581            let truncated_nodes = limit > 0 && nodes.len() > limit;
14582            if truncated_nodes {
14583                nodes.truncate(limit);
14584            }
14585            let handles = nodes
14586                .iter()
14587                .map(|node| node.handle.clone())
14588                .collect::<BTreeSet<_>>();
14589            let mut edges = traversal_edges_between(&handles, &graph.edges);
14590            let truncated_edges = limit > 0 && edges.len() > limit;
14591            if truncated_edges {
14592                edges.truncate(limit);
14593            }
14594            ("export".to_string(), nodes, edges, None)
14595        };
14596
14597    let shortest_handles = shortest_path.as_ref().map(|path| {
14598        path.nodes
14599            .iter()
14600            .map(|node| node.handle.clone())
14601            .collect::<Vec<_>>()
14602    });
14603    let recommendations = traversal_recommendations(
14604        &graph,
14605        origin_node.map(|node| node.handle.as_str()),
14606        shortest_handles.as_deref(),
14607        if limit == 0 { 10 } else { limit.min(10) },
14608    );
14609    let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
14610    let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
14611
14612    Ok(TraversalReport {
14613        root: root.to_string_lossy().to_string(),
14614        scope: scope.map(str::to_string),
14615        mode,
14616        totals,
14617        query: query.map(str::to_string),
14618        target: target.map(str::to_string),
14619        nodes: selected_nodes,
14620        edges: selected_edges,
14621        shortest_path,
14622        recommendations,
14623        exploration,
14624        truncated,
14625        warnings: graph.warnings,
14626    })
14627}
14628
14629fn html_escape(input: &str) -> String {
14630    input
14631        .replace('&', "&amp;")
14632        .replace('<', "&lt;")
14633        .replace('>', "&gt;")
14634        .replace('"', "&quot;")
14635        .replace('\'', "&#39;")
14636}
14637
14638pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
14639    let json = serde_json::to_string(report)?.replace("</", "<\\/");
14640    let mut html = String::new();
14641    html.push_str(
14642        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
14643    );
14644    html.push_str(
14645        r#"<style>
14646:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
14647@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
14648*{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}}
14649</style>"#,
14650    );
14651    html.push_str("</head><body>");
14652    html.push_str("<div class=\"page\">");
14653    html.push_str(&format!(
14654        "<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>",
14655        html_escape(&report.mode),
14656        report.nodes.len(),
14657        report.totals.nodes,
14658        report.edges.len(),
14659        report.totals.edges
14660    ));
14661    html.push_str(
14662        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>"#,
14663    );
14664    html.push_str("<script id=\"graph-data\" type=\"application/json\">");
14665    html.push_str(&json);
14666    html.push_str(
14667        r##"</script><script>
14668const report = JSON.parse(document.getElementById("graph-data").textContent);
14669const svg = document.getElementById("graph-canvas");
14670const list = document.getElementById("node-list");
14671const selected = document.getElementById("selected");
14672const filter = document.getElementById("filter");
14673const legend = document.getElementById("legend");
14674const nodes = report.nodes.map((node, index) => ({...node, index}));
14675const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
14676const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
14677const colorByKind = new Map([
14678  ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
14679  ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
14680  ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
14681  ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
14682]);
14683function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
14684function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
14685function text(value){ return value == null ? "" : String(value); }
14686function matches(node, query){
14687  if (!query) return true;
14688  const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
14689  return haystack.includes(query);
14690}
14691function layout(){
14692  const rect = svg.getBoundingClientRect();
14693  const width = rect.width || 900;
14694  const height = rect.height || 650;
14695  const cx = width / 2;
14696  const cy = height / 2;
14697  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14698  const counts = new Map();
14699  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
14700  const offsets = new Map();
14701  for (const node of nodes) {
14702    const group = kinds.indexOf(node.kind);
14703    const index = offsets.get(node.kind) || 0;
14704    offsets.set(node.kind, index + 1);
14705    const groupCount = counts.get(node.kind) || 1;
14706    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
14707    const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
14708    node.x = cx + Math.cos(angle) * ring;
14709    node.y = cy + Math.sin(angle) * ring;
14710  }
14711}
14712function draw(){
14713  const query = filter.value.trim().toLowerCase();
14714  const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
14715  svg.innerHTML = "";
14716  for (const edge of edges) {
14717    if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
14718    const from = nodeByHandle.get(edge.from);
14719    const to = nodeByHandle.get(edge.to);
14720    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
14721    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
14722    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
14723    line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
14724    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
14725    svg.appendChild(line);
14726  }
14727  for (const node of nodes) {
14728    if (!visible.has(node.handle)) continue;
14729    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
14730    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
14731    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
14732    circle.setAttribute("fill", color(node.kind));
14733    circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
14734    circle.addEventListener("click", () => selectNode(node));
14735    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
14736    svg.appendChild(circle);
14737    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
14738    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
14739    label.setAttribute("class", "node-label");
14740    label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
14741    svg.appendChild(label);
14742  }
14743  renderList(query);
14744}
14745function renderLegend(){
14746  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14747  legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">&#9679;</b> ${kind}</span>`).join("");
14748}
14749function renderList(query){
14750  const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
14751  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("");
14752  for (const row of list.querySelectorAll(".row")) {
14753    row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
14754  }
14755}
14756function selectNode(node){
14757  const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
14758  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>`;
14759}
14760function escapeHtml(value){
14761  return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));
14762}
14763filter.addEventListener("input", draw);
14764window.addEventListener("resize", () => { layout(); draw(); });
14765renderLegend();
14766layout();
14767draw();
14768if (nodes.length) selectNode(nodes[0]);
14769</script></div></body></html>"##,
14770    );
14771    Ok(html)
14772}
14773
14774fn semantic_related_report_from_store(
14775    root: &Path,
14776    scope: Option<&str>,
14777    query: &str,
14778    limit: usize,
14779    kind: SemanticRelatedKind,
14780    store: &impl GraphStore,
14781) -> Result<SemanticRelatedReport> {
14782    if query.trim().is_empty() {
14783        bail!("semantic query cannot be empty");
14784    }
14785
14786    let query_embedding = semantic_embedding(query);
14787    let node_kinds: &[&str] = match kind {
14788        SemanticRelatedKind::Concept => &["semantic_concept"],
14789        SemanticRelatedKind::Entity => &["semantic_entity"],
14790        SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
14791    };
14792
14793    let items = store
14794        .semantic_top_candidates(&query_embedding, node_kinds, limit)?
14795        .into_iter()
14796        .map(|candidate| {
14797            let node = candidate.node;
14798            SemanticRelatedItem {
14799                handle: node
14800                    .properties
14801                    .get("handle")
14802                    .cloned()
14803                    .unwrap_or_else(|| node.id.clone()),
14804                kind: node.kind,
14805                label: node.label,
14806                score: candidate.score,
14807                file_path: node
14808                    .properties
14809                    .get("source_file")
14810                    .or_else(|| node.properties.get("path"))
14811                    .cloned(),
14812                source_symbol: node.properties.get("source_symbol").cloned(),
14813                detail: node
14814                    .properties
14815                    .get("description")
14816                    .or_else(|| node.properties.get("detail"))
14817                    .cloned(),
14818                expand: node
14819                    .properties
14820                    .get("expand")
14821                    .cloned()
14822                    .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
14823            }
14824        })
14825        .collect::<Vec<_>>();
14826
14827    let mut warnings = Vec::new();
14828    if items.is_empty() {
14829        warnings.push(
14830            "no semantic graph rows found; run `tsift summarize --extract <path>` first"
14831                .to_string(),
14832        );
14833    }
14834
14835    Ok(SemanticRelatedReport {
14836        root: root.to_string_lossy().to_string(),
14837        scope: scope.map(str::to_string),
14838        query: query.to_string(),
14839        embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
14840        count: items.len(),
14841        items,
14842        warnings,
14843    })
14844}
14845
14846fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
14847    Ok(store.nodes_by_kind("semantic_concept")?.len()
14848        + store.nodes_by_kind("semantic_entity")?.len())
14849}
14850
14851fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
14852    if limit == 0 {
14853        return 0;
14854    }
14855    limit.saturating_mul(4).clamp(
14856        GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
14857        GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
14858    )
14859}
14860
14861fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
14862    if limit == 0 {
14863        return usize::MAX;
14864    }
14865    limit.saturating_mul(3).max(limit).max(seed_count)
14866}
14867
14868fn graph_db_semantic_seeded_neighborhood(
14869    store: &impl GraphStore,
14870    seed_ids: &[String],
14871    depth: usize,
14872    limit: usize,
14873) -> Result<GraphDbSemanticSeededSubgraph> {
14874    let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
14875    let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
14876    let mut diagnostics = vec![
14877        "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
14878        "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(),
14879        format!(
14880            "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
14881            if edge_scan_cap == 0 {
14882                "unbounded".to_string()
14883            } else {
14884                edge_scan_cap.to_string()
14885            },
14886            if node_discovery_cap == usize::MAX {
14887                "unbounded".to_string()
14888            } else {
14889                node_discovery_cap.to_string()
14890            }
14891        ),
14892    ];
14893
14894    let options = SemanticSeededNeighborhoodOptions::new(depth, limit)
14895        .with_edge_scan_cap(edge_scan_cap)
14896        .with_node_discovery_cap(node_discovery_cap);
14897    let result = store.semantic_seeded_neighborhood(seed_ids, &options)?;
14898
14899    for seed_id in &result.missing_seed_ids {
14900        diagnostics.push(format!(
14901            "semantic seed {seed_id} was not present in the graph store"
14902        ));
14903    }
14904
14905    if result.skipped_by_edge_cap > 0 {
14906        diagnostics.push(format!(
14907            "semantic-seeded expansion skipped {} lower-scoring incident/outgoing edge(s) after per-node caps",
14908            result.skipped_by_edge_cap
14909        ));
14910    }
14911    if result.skipped_by_node_cap > 0 {
14912        diagnostics.push(format!(
14913            "semantic-seeded expansion skipped {} lower-scoring node discovery edge(s) after the discovery cap",
14914            result.skipped_by_node_cap
14915        ));
14916    }
14917
14918    if result.truncated {
14919        diagnostics.push(format!(
14920            "semantic-seeded neighborhood truncated from {} to {limit} node(s)",
14921            result.total_discovered
14922        ));
14923    }
14924
14925    Ok(GraphDbSemanticSeededSubgraph {
14926        nodes: result.nodes,
14927        edges: result.edges,
14928        truncated: result.truncated,
14929        diagnostics,
14930    })
14931}
14932
14933#[allow(clippy::too_many_arguments)]
14934fn cmd_semantic_related(
14935    query: &str,
14936    path: &Path,
14937    scope: Option<&str>,
14938    limit: usize,
14939    kind: SemanticRelatedKind,
14940    json_output: bool,
14941    compact: bool,
14942    pretty: bool,
14943    terse: bool,
14944    schema: bool,
14945) -> Result<()> {
14946    let root = lint::resolve_project_root_or_canonical_path(path)?;
14947    write_traversal_graph_store(&root, path, scope)?;
14948    let graph_db = graph_substrate_db_path(&root, scope);
14949    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14950    let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
14951    if let Some(recovery) = store.read_only_recovery() {
14952        report
14953            .warnings
14954            .push(graph_db_read_recovery_diagnostic(recovery));
14955    }
14956
14957    if json_output {
14958        println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
14959    } else if compact {
14960        for item in &report.items {
14961            println!(
14962                "{:.3}\t{}\t{}\t{}",
14963                item.score, item.kind, item.label, item.handle
14964            );
14965        }
14966        for warning in &report.warnings {
14967            eprintln!("warning: {warning}");
14968        }
14969    } else {
14970        println!(
14971            "Related semantic graph rows for {:?} ({})",
14972            report.query, report.embedding_model
14973        );
14974        for item in &report.items {
14975            println!(
14976                "  {:.3} [{}] {} ({})",
14977                item.score, item.kind, item.label, item.handle
14978            );
14979            if let Some(detail) = &item.detail {
14980                println!("      {}", detail);
14981            }
14982            if let Some(file_path) = &item.file_path {
14983                println!("      file: {}", file_path);
14984            }
14985            println!("      expand: {}", item.expand);
14986        }
14987        for warning in &report.warnings {
14988            eprintln!("warning: {warning}");
14989        }
14990    }
14991
14992    Ok(())
14993}
14994
14995#[derive(Serialize)]
14996struct SourceLinePreview {
14997    line: usize,
14998    text: String,
14999}
15000
15001#[derive(Serialize)]
15002pub(crate) struct SourceRangePreview {
15003    start: usize,
15004    end: usize,
15005    total_lines: usize,
15006    truncated_before: bool,
15007    truncated_after: bool,
15008}
15009
15010#[derive(Serialize)]
15011struct SourceExpandCommands {
15012    #[serde(skip_serializing_if = "Option::is_none")]
15013    before: Option<String>,
15014    #[serde(skip_serializing_if = "Option::is_none")]
15015    after: Option<String>,
15016    #[serde(skip_serializing_if = "Option::is_none")]
15017    body: Option<String>,
15018    file: String,
15019    #[serde(skip_serializing_if = "Option::is_none")]
15020    markdown_ast: Option<String>,
15021}
15022
15023#[derive(Serialize)]
15024struct SourceSymbolRef {
15025    handle: String,
15026    name: String,
15027    kind: String,
15028    language: String,
15029    file: String,
15030    line: usize,
15031    #[serde(skip_serializing_if = "Option::is_none")]
15032    end_line: Option<usize>,
15033    #[serde(skip_serializing_if = "Option::is_none")]
15034    signature: Option<String>,
15035    #[serde(skip_serializing_if = "Option::is_none")]
15036    span: Option<AstSpanPreview>,
15037    expand: String,
15038}
15039
15040#[derive(Serialize)]
15041struct SourceSummaryRef {
15042    handle: String,
15043    symbol_name: String,
15044    file_path: String,
15045    summary: String,
15046    expand: String,
15047}
15048
15049#[derive(Serialize)]
15050struct SourceReadReport {
15051    handle: String,
15052    root: String,
15053    file: String,
15054    range: SourceRangePreview,
15055    preview: Vec<SourceLinePreview>,
15056    symbols: Vec<SourceSymbolRef>,
15057    summaries: Vec<SourceSummaryRef>,
15058    #[serde(skip_serializing_if = "Option::is_none")]
15059    markdown: Option<SourceReadMarkdownProjection>,
15060    expand: SourceExpandCommands,
15061    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15062    warnings: Vec<String>,
15063}
15064
15065#[derive(Serialize)]
15066struct SourceReadAstExpandCommands {
15067    window: String,
15068    file_window: String,
15069    #[serde(skip_serializing_if = "Option::is_none")]
15070    markdown_ast: Option<String>,
15071}
15072
15073#[derive(Serialize)]
15074struct SourceReadAstReport {
15075    handle: String,
15076    root: String,
15077    file: String,
15078    range: SourceRangePreview,
15079    symbols: Vec<SourceSymbolRef>,
15080    summaries: Vec<SourceSummaryRef>,
15081    #[serde(skip_serializing_if = "Option::is_none")]
15082    markdown: Option<SourceReadMarkdownProjection>,
15083    expand: SourceReadAstExpandCommands,
15084    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15085    warnings: Vec<String>,
15086}
15087
15088#[derive(Serialize)]
15089struct SymbolReadTarget {
15090    handle: String,
15091    name: String,
15092    kind: String,
15093    language: String,
15094    file: String,
15095    line: usize,
15096    #[serde(skip_serializing_if = "Option::is_none")]
15097    end_line: Option<usize>,
15098    #[serde(skip_serializing_if = "Option::is_none")]
15099    signature: Option<String>,
15100    #[serde(skip_serializing_if = "Option::is_none")]
15101    parent_module: Option<String>,
15102    #[serde(skip_serializing_if = "Option::is_none")]
15103    visibility: Option<String>,
15104    #[serde(skip_serializing_if = "Option::is_none")]
15105    span: Option<AstSpanPreview>,
15106}
15107
15108#[derive(Serialize)]
15109struct SymbolReadExpandCommands {
15110    source_window: String,
15111    #[serde(skip_serializing_if = "Option::is_none")]
15112    body: Option<String>,
15113    file: String,
15114    explain: String,
15115    callers: String,
15116    callees: String,
15117    #[serde(skip_serializing_if = "Option::is_none")]
15118    markdown_ast: Option<String>,
15119}
15120
15121#[derive(Serialize)]
15122struct SymbolReadReport {
15123    handle: String,
15124    root: String,
15125    query: String,
15126    symbol: SymbolReadTarget,
15127    range: SourceRangePreview,
15128    body: Vec<SourceLinePreview>,
15129    child_symbols: Vec<SourceSymbolRef>,
15130    summaries: Vec<SourceSummaryRef>,
15131    expand: SymbolReadExpandCommands,
15132    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15133    warnings: Vec<String>,
15134}
15135
15136#[derive(Clone)]
15137pub(crate) struct MarkdownAstRawNode {
15138    handle: String,
15139    span_handle: String,
15140    name: String,
15141    kind: String,
15142    block_kind: String,
15143    node_kind: String,
15144    start_byte: usize,
15145    end_byte: usize,
15146    body_start_byte: Option<usize>,
15147    body_end_byte: Option<usize>,
15148}
15149
15150#[derive(Clone)]
15151pub(crate) struct MarkdownAstProjection {
15152    source_hash: String,
15153    nodes: Vec<MarkdownAstRawNode>,
15154    parse_duration_micros: u128,
15155    cache_hit: bool,
15156}
15157
15158#[derive(Clone)]
15159struct MarkdownAstCacheEntry {
15160    source_hash: String,
15161    nodes: Vec<MarkdownAstRawNode>,
15162    parse_duration_micros: u128,
15163}
15164
15165static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15166    OnceLock::new();
15167
15168#[derive(Serialize, Clone)]
15169struct MarkdownAstNodeMetadata {
15170    #[serde(skip_serializing_if = "Option::is_none")]
15171    heading_level: Option<usize>,
15172    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15173    section_path: Vec<String>,
15174    #[serde(skip_serializing_if = "Option::is_none")]
15175    section_handle: Option<String>,
15176    #[serde(skip_serializing_if = "Option::is_none")]
15177    list_depth: Option<usize>,
15178    #[serde(skip_serializing_if = "Option::is_none")]
15179    list_marker: Option<String>,
15180    #[serde(skip_serializing_if = "Option::is_none")]
15181    list_order: Option<usize>,
15182    #[serde(skip_serializing_if = "Option::is_none")]
15183    fence_language: Option<String>,
15184    #[serde(skip_serializing_if = "Option::is_none")]
15185    fence_marker: Option<String>,
15186    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15187    embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15188}
15189
15190#[derive(Serialize, Clone)]
15191struct MarkdownAstNodeExpand {
15192    source_window: String,
15193    source_body: String,
15194    symbol_read: String,
15195    edit_intents: String,
15196}
15197
15198#[derive(Serialize, Clone)]
15199struct MarkdownAstCacheReport {
15200    source_hash: String,
15201    cache_hit: bool,
15202    parse_duration_micros: u128,
15203    node_count: usize,
15204    section_count: usize,
15205    list_item_count: usize,
15206    code_block_count: usize,
15207}
15208
15209#[derive(Serialize, Clone)]
15210struct MarkdownAstPhaseTiming {
15211    name: String,
15212    duration_micros: u128,
15213    detail: String,
15214}
15215
15216#[derive(Serialize, Clone)]
15217struct MarkdownAstOutlineEntry {
15218    handle: String,
15219    span_handle: String,
15220    name: String,
15221    kind: String,
15222    block_kind: String,
15223    line: usize,
15224    end_line: usize,
15225    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15226    section_path: Vec<String>,
15227    child_count: usize,
15228    expand: String,
15229}
15230
15231#[derive(Serialize, Clone)]
15232struct MarkdownAstProjectionPreview {
15233    mode: String,
15234    total_nodes: usize,
15235    returned_nodes: usize,
15236    omitted_nodes: usize,
15237    selected_node: Option<String>,
15238    cache: MarkdownAstCacheReport,
15239    outline: Vec<MarkdownAstOutlineEntry>,
15240    phase_timings: Vec<MarkdownAstPhaseTiming>,
15241}
15242
15243#[derive(Serialize)]
15244struct SourceReadMarkdownProjection {
15245    handle: String,
15246    mode: String,
15247    total_nodes: usize,
15248    visible_nodes: usize,
15249    outline: Vec<MarkdownAstOutlineEntry>,
15250    expand: String,
15251}
15252
15253#[derive(Serialize, Clone)]
15254struct SourceByteRangePreview {
15255    start: usize,
15256    end: usize,
15257}
15258
15259#[derive(Serialize, Clone)]
15260struct MarkdownAstNode {
15261    handle: String,
15262    span_handle: String,
15263    name: String,
15264    kind: String,
15265    block_kind: String,
15266    node_kind: String,
15267    line: usize,
15268    end_line: usize,
15269    byte_span: SourceByteRangePreview,
15270    #[serde(skip_serializing_if = "Option::is_none")]
15271    body_byte_span: Option<SourceByteRangePreview>,
15272    parent_handle: Option<String>,
15273    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15274    child_handles: Vec<String>,
15275    metadata: MarkdownAstNodeMetadata,
15276    expand: MarkdownAstNodeExpand,
15277}
15278
15279#[derive(Serialize)]
15280struct MarkdownAstExpandCommands {
15281    file: String,
15282    source_read: String,
15283    edit_intents: String,
15284}
15285
15286#[derive(Serialize)]
15287struct MarkdownAstReport {
15288    handle: String,
15289    root: String,
15290    file: String,
15291    range: SourceRangePreview,
15292    projection: MarkdownAstProjectionPreview,
15293    nodes: Vec<MarkdownAstNode>,
15294    expand: MarkdownAstExpandCommands,
15295    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15296    warnings: Vec<String>,
15297}
15298
15299pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
15300    let candidate = if file.is_absolute() {
15301        file.to_path_buf()
15302    } else {
15303        root.join(file)
15304    };
15305    let canonical = candidate
15306        .canonicalize()
15307        .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
15308    if !canonical.is_file() {
15309        bail!("source file is not a regular file: {}", canonical.display());
15310    }
15311    let canonical_root = root
15312        .canonicalize()
15313        .with_context(|| format!("canonicalizing project root {}", root.display()))?;
15314    if !canonical.starts_with(&canonical_root) {
15315        bail!(
15316            "source file {} is outside project root {}",
15317            canonical.display(),
15318            canonical_root.display()
15319        );
15320    }
15321    Ok(canonical)
15322}
15323
15324pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
15325    source_read_window_command(root, file, start, lines)
15326}
15327
15328pub(crate) fn source_read_window_command(
15329    root: &Path,
15330    file: &str,
15331    start: usize,
15332    lines: usize,
15333) -> String {
15334    format!(
15335        "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
15336        shell_quote(file),
15337        shell_quote(&root.to_string_lossy()),
15338        start,
15339        lines
15340    )
15341}
15342
15343pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
15344    format!(
15345        "tsift --envelope source-read {} --path {} --budget normal",
15346        shell_quote(file),
15347        shell_quote(&root.to_string_lossy())
15348    )
15349}
15350
15351pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
15352    format!(
15353        "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
15354        shell_quote(symbol),
15355        shell_quote(&root.to_string_lossy()),
15356        shell_quote(file)
15357    )
15358}
15359
15360fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
15361    format!(
15362        "tsift --envelope explain {} --path {} --budget normal",
15363        shell_quote(symbol),
15364        shell_quote(&root.to_string_lossy())
15365    )
15366}
15367
15368fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
15369    format!(
15370        "tsift graph {} --path {} --{} --json",
15371        shell_quote(symbol),
15372        shell_quote(&root.to_string_lossy()),
15373        relation
15374    )
15375}
15376
15377fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
15378    format!(
15379        "tsift summarize {} --path {} --json",
15380        shell_quote(symbol),
15381        shell_quote(&root.to_string_lossy())
15382    )
15383}
15384
15385pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
15386    let mut command = format!(
15387        "tsift --envelope markdown-ast {} --path {} --budget normal",
15388        shell_quote(file),
15389        shell_quote(&root.to_string_lossy())
15390    );
15391    if let Some(node) = node {
15392        command.push_str(" --node ");
15393        command.push_str(&shell_quote(node));
15394    }
15395    command
15396}
15397
15398fn markdown_edit_intents_command(root: &Path) -> String {
15399    format!(
15400        "tsift --envelope edit-intents --path {} --budget normal",
15401        shell_quote(&root.to_string_lossy())
15402    )
15403}
15404
15405pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
15406    usize::try_from(symbol.line)
15407        .ok()
15408        .and_then(|line| line.checked_add(1))
15409        .unwrap_or(1)
15410}
15411
15412fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
15413    symbol
15414        .end_line
15415        .and_then(|line| usize::try_from(line).ok())
15416        .and_then(|line| line.checked_add(1))
15417}
15418
15419fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
15420    value.and_then(|byte| usize::try_from(byte).ok())
15421}
15422
15423fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
15424    let byte = byte.min(source.len());
15425    source[..byte]
15426        .iter()
15427        .filter(|value| **value == b'\n')
15428        .count()
15429        .saturating_add(1)
15430}
15431
15432fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
15433    source_line_for_byte(source, end_byte.saturating_sub(1))
15434}
15435
15436fn ast_span_handle(
15437    file: &str,
15438    name: &str,
15439    kind: &str,
15440    start_byte: usize,
15441    end_byte: usize,
15442) -> String {
15443    stable_handle(
15444        "span",
15445        &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
15446    )
15447}
15448
15449pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
15450    Some((
15451        symbol_span_byte(symbol.start_byte)?,
15452        symbol_span_byte(symbol.end_byte)?,
15453    ))
15454}
15455
15456pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
15457    Some((
15458        symbol_span_byte(symbol.start_byte)?,
15459        symbol_span_byte(symbol.end_byte)?,
15460    ))
15461}
15462
15463pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
15464    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15465    Some(ast_span_handle(
15466        &symbol.file,
15467        &symbol.name,
15468        &symbol.kind,
15469        start_byte,
15470        end_byte,
15471    ))
15472}
15473
15474fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
15475    left.file == right.file
15476        && left.name == right.name
15477        && left.kind == right.kind
15478        && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
15479}
15480
15481fn stored_symbol_parent_span_handle(
15482    symbol: &index::StoredSymbol,
15483    symbols: &[index::StoredSymbol],
15484) -> Option<String> {
15485    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15486    symbols
15487        .iter()
15488        .filter(|candidate| {
15489            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15490                return false;
15491            }
15492            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15493            else {
15494                return false;
15495            };
15496            candidate_start <= start_byte && candidate_end >= end_byte
15497        })
15498        .min_by_key(|candidate| {
15499            stored_symbol_span_bounds(candidate)
15500                .map(|(start, end)| end.saturating_sub(start))
15501                .unwrap_or(usize::MAX)
15502        })
15503        .and_then(stored_symbol_span_handle)
15504}
15505
15506fn stored_symbol_child_span_handles(
15507    symbol: &index::StoredSymbol,
15508    symbols: &[index::StoredSymbol],
15509    limit: usize,
15510) -> Vec<String> {
15511    let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
15512        return Vec::new();
15513    };
15514    symbols
15515        .iter()
15516        .filter(|candidate| {
15517            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15518                return false;
15519            }
15520            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15521            else {
15522                return false;
15523            };
15524            candidate_start >= start_byte && candidate_end <= end_byte
15525        })
15526        .take(limit)
15527        .filter_map(stored_symbol_span_handle)
15528        .collect()
15529}
15530
15531fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
15532    let start = start_byte.min(source.len());
15533    let line_end = source[start..]
15534        .iter()
15535        .position(|value| *value == b'\n')
15536        .map(|pos| start + pos)
15537        .unwrap_or(source.len());
15538    let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
15539    let marker = line.trim_start();
15540    let level = marker.chars().take_while(|ch| *ch == '#').count();
15541    (1..=6).contains(&level).then_some(level)
15542}
15543
15544fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
15545    let start = start_byte.min(source.len());
15546    let line_start = source[..start]
15547        .iter()
15548        .rposition(|value| *value == b'\n')
15549        .map(|pos| pos + 1)
15550        .unwrap_or(0);
15551    source[line_start..start]
15552        .iter()
15553        .map(|byte| match byte {
15554            b'\t' => 4,
15555            b' ' => 1,
15556            _ => 0,
15557        })
15558        .sum::<usize>()
15559        / 2
15560}
15561
15562fn markdown_enclosing_heading_symbols<'a>(
15563    file: &str,
15564    start_byte: usize,
15565    end_byte: usize,
15566    symbols: &'a [index::StoredSymbol],
15567) -> Vec<&'a index::StoredSymbol> {
15568    let mut headings = symbols
15569        .iter()
15570        .filter(|candidate| candidate.file == file && candidate.kind == "heading")
15571        .filter(|candidate| {
15572            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15573            else {
15574                return false;
15575            };
15576            candidate_start <= start_byte && candidate_end >= end_byte
15577        })
15578        .collect::<Vec<_>>();
15579    headings.sort_by(|left, right| {
15580        stored_symbol_span_bounds(left)
15581            .map(|(start, _)| start)
15582            .unwrap_or(usize::MAX)
15583            .cmp(
15584                &stored_symbol_span_bounds(right)
15585                    .map(|(start, _)| start)
15586                    .unwrap_or(usize::MAX),
15587            )
15588            .then(left.name.cmp(&right.name))
15589    });
15590    headings
15591}
15592
15593fn markdown_stored_symbol_metadata(
15594    symbol: &index::StoredSymbol,
15595    source: &[u8],
15596    symbols: &[index::StoredSymbol],
15597) -> Option<MarkdownSpanMetadata> {
15598    if symbol.language != "markdown" {
15599        return None;
15600    }
15601    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15602    let section_symbols =
15603        markdown_enclosing_heading_symbols(&symbol.file, start_byte, end_byte, symbols);
15604    let section_path = section_symbols
15605        .iter()
15606        .map(|heading| heading.name.clone())
15607        .collect::<Vec<_>>();
15608    let section_handle = section_symbols
15609        .last()
15610        .and_then(|heading| stored_symbol_span_handle(heading));
15611    let heading_level = (symbol.kind == "heading")
15612        .then(|| markdown_heading_level(source, start_byte))
15613        .flatten();
15614    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15615    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15616    let embedded_symbols = if symbol.kind == "code_block" {
15617        markdown_embedded_symbols(
15618            &symbol.file,
15619            source,
15620            symbol_span_byte(symbol.body_start_byte),
15621            symbol_span_byte(symbol.body_end_byte),
15622            fence_language.as_deref(),
15623        )
15624    } else {
15625        Vec::new()
15626    };
15627
15628    (heading_level.is_some()
15629        || !section_path.is_empty()
15630        || section_handle.is_some()
15631        || list_depth.is_some()
15632        || fence_language.is_some()
15633        || !embedded_symbols.is_empty())
15634    .then_some(MarkdownSpanMetadata {
15635        heading_level,
15636        section_path,
15637        section_handle,
15638        list_depth,
15639        fence_language,
15640        embedded_symbols,
15641    })
15642}
15643
15644fn markdown_symbol_hit_metadata(
15645    symbol: &index::SymbolHit,
15646    source: &[u8],
15647    start_byte: usize,
15648) -> Option<MarkdownSpanMetadata> {
15649    if symbol.language != "markdown" {
15650        return None;
15651    }
15652    let heading_level = (symbol.kind == "heading")
15653        .then(|| markdown_heading_level(source, start_byte))
15654        .flatten();
15655    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15656    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15657    let embedded_symbols = if symbol.kind == "code_block" {
15658        markdown_embedded_symbols(
15659            &symbol.file,
15660            source,
15661            symbol_span_byte(symbol.body_start_byte),
15662            symbol_span_byte(symbol.body_end_byte),
15663            fence_language.as_deref(),
15664        )
15665    } else {
15666        Vec::new()
15667    };
15668    (heading_level.is_some()
15669        || list_depth.is_some()
15670        || fence_language.is_some()
15671        || !embedded_symbols.is_empty())
15672    .then_some(MarkdownSpanMetadata {
15673        heading_level,
15674        section_path: Vec::new(),
15675        section_handle: None,
15676        list_depth,
15677        fence_language,
15678        embedded_symbols,
15679    })
15680}
15681
15682fn is_markdown_path(path: &Path) -> bool {
15683    path.extension()
15684        .and_then(|ext| ext.to_str())
15685        .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
15686        .unwrap_or(false)
15687}
15688
15689fn markdown_ast_block_kind(kind: &str) -> String {
15690    match kind {
15691        "heading" => "section",
15692        "code_block" => "fenced_code_block",
15693        "list_item" => "list_item",
15694        other => other,
15695    }
15696    .to_string()
15697}
15698
15699fn markdown_embedded_language_key(language: &str) -> Option<String> {
15700    let key = language
15701        .split_whitespace()
15702        .next()
15703        .unwrap_or("")
15704        .trim()
15705        .trim_start_matches("language-")
15706        .trim_start_matches("lang-")
15707        .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
15708        .to_ascii_lowercase();
15709    (!key.is_empty()).then_some(key)
15710}
15711
15712fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
15713    let key = markdown_embedded_language_key(language)?;
15714    let extension = match key.as_str() {
15715        "rust" => "rs",
15716        "python" => "py",
15717        "typescript" => "ts",
15718        "javascript" => "js",
15719        "kotlin" => "kt",
15720        "shell" | "sh" | "zsh" => "bash",
15721        other => other,
15722    };
15723    let lang = graph::Lang::from_extension(extension)?;
15724    (lang.name() != "markdown").then_some(lang)
15725}
15726
15727fn markdown_embedded_ast_span_handle(
15728    file: &str,
15729    language: &str,
15730    name: &str,
15731    kind: &str,
15732    start_byte: usize,
15733    end_byte: usize,
15734) -> String {
15735    stable_handle(
15736        "span",
15737        &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
15738    )
15739}
15740
15741fn markdown_embedded_symbols(
15742    file: &str,
15743    source: &[u8],
15744    body_start_byte: Option<usize>,
15745    body_end_byte: Option<usize>,
15746    fence_language: Option<&str>,
15747) -> Vec<MarkdownEmbeddedSymbol> {
15748    let Some(fence_language) = fence_language else {
15749        return Vec::new();
15750    };
15751    let Some(lang) = markdown_embedded_lang(fence_language) else {
15752        return Vec::new();
15753    };
15754    let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
15755        return Vec::new();
15756    };
15757    let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
15758    else {
15759        return Vec::new();
15760    };
15761    if body.is_empty() {
15762        return Vec::new();
15763    }
15764
15765    let Ok(symbols) = lang.extract_symbols(body) else {
15766        return Vec::new();
15767    };
15768    let language = lang.name().to_string();
15769    symbols
15770        .into_iter()
15771        .map(|symbol| {
15772            let start_byte = body_start_byte.saturating_add(symbol.start_byte);
15773            let end_byte = body_start_byte.saturating_add(symbol.end_byte);
15774            let body_start = symbol
15775                .body_start_byte
15776                .map(|byte| body_start_byte.saturating_add(byte));
15777            let body_end = symbol
15778                .body_end_byte
15779                .map(|byte| body_start_byte.saturating_add(byte));
15780            let start_line = source_line_for_byte(source, start_byte);
15781            let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
15782            MarkdownEmbeddedSymbol {
15783                handle: markdown_embedded_ast_span_handle(
15784                    file,
15785                    &language,
15786                    &symbol.name,
15787                    &symbol.kind,
15788                    start_byte,
15789                    end_byte,
15790                ),
15791                name: symbol.name,
15792                kind: symbol.kind,
15793                language: language.clone(),
15794                node_kind: symbol.node_kind,
15795                start_byte,
15796                end_byte,
15797                start_line,
15798                end_line,
15799                body_start_byte: body_start,
15800                body_end_byte: body_end,
15801                body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
15802                body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
15803            }
15804        })
15805        .collect()
15806}
15807
15808fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
15809    let start = start_byte.min(source.len());
15810    let line_start = source[..start]
15811        .iter()
15812        .rposition(|value| *value == b'\n')
15813        .map(|pos| pos + 1)
15814        .unwrap_or(0);
15815    let line_end = source[start..]
15816        .iter()
15817        .position(|value| *value == b'\n')
15818        .map(|pos| start + pos)
15819        .unwrap_or(source.len());
15820    std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
15821}
15822
15823fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
15824    let line = markdown_source_line(source, start_byte);
15825    let trimmed = line.trim_start();
15826    for marker in ["-", "*", "+"] {
15827        if trimmed
15828            .strip_prefix(marker)
15829            .and_then(|rest| rest.strip_prefix(' '))
15830            .is_some()
15831        {
15832            return (Some(marker.to_string()), None);
15833        }
15834    }
15835
15836    let digit_end = trimmed
15837        .find(|ch: char| !ch.is_ascii_digit())
15838        .unwrap_or(trimmed.len());
15839    let (digits, rest) = trimmed.split_at(digit_end);
15840    if !digits.is_empty() {
15841        for marker in [".", ")"] {
15842            if rest
15843                .strip_prefix(marker)
15844                .and_then(|value| value.strip_prefix(' '))
15845                .is_some()
15846            {
15847                return (
15848                    Some(format!("{digits}{marker}")),
15849                    digits.parse::<usize>().ok(),
15850                );
15851            }
15852        }
15853    }
15854    (None, None)
15855}
15856
15857fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
15858    let line = markdown_source_line(source, start_byte);
15859    let trimmed = line.trim_start();
15860    ["```", "~~~"]
15861        .into_iter()
15862        .find(|marker| trimmed.starts_with(marker))
15863        .map(str::to_string)
15864}
15865
15866fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
15867    let mut nodes = graph::Lang::Markdown
15868        .extract_symbols(source)
15869        .context("extracting Markdown AST nodes")?
15870        .into_iter()
15871        .map(|symbol| {
15872            let body_start_byte = symbol.body_start_byte;
15873            let body_end_byte = symbol.body_end_byte;
15874            let span_handle = ast_span_handle(
15875                file,
15876                &symbol.name,
15877                &symbol.kind,
15878                symbol.start_byte,
15879                symbol.end_byte,
15880            );
15881            MarkdownAstRawNode {
15882                handle: stable_handle(
15883                    "mdast",
15884                    &format!(
15885                        "{}:{}:{}:{}:{}",
15886                        file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
15887                    ),
15888                ),
15889                span_handle,
15890                name: symbol.name,
15891                kind: symbol.kind.clone(),
15892                block_kind: markdown_ast_block_kind(&symbol.kind),
15893                node_kind: symbol.node_kind,
15894                start_byte: symbol.start_byte,
15895                end_byte: symbol.end_byte,
15896                body_start_byte,
15897                body_end_byte,
15898            }
15899        })
15900        .collect::<Vec<_>>();
15901    nodes.sort_by(|left, right| {
15902        left.start_byte
15903            .cmp(&right.start_byte)
15904            .then(left.end_byte.cmp(&right.end_byte))
15905            .then(left.kind.cmp(&right.kind))
15906            .then(left.name.cmp(&right.name))
15907    });
15908    Ok(nodes)
15909}
15910
15911pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
15912    let source_hash = blake3::hash(source).to_hex().to_string();
15913    let cache_key = format!("{file}:{source_hash}");
15914    let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
15915    if let Some(entry) = cache
15916        .lock()
15917        .expect("markdown ast cache poisoned")
15918        .get(&cache_key)
15919    {
15920        return Ok(MarkdownAstProjection {
15921            source_hash: entry.source_hash.clone(),
15922            nodes: entry.nodes.clone(),
15923            parse_duration_micros: entry.parse_duration_micros,
15924            cache_hit: true,
15925        });
15926    }
15927
15928    let started = Instant::now();
15929    let nodes = markdown_ast_extract_raw_nodes(file, source)?;
15930    let parse_duration_micros = started.elapsed().as_micros();
15931    cache.lock().expect("markdown ast cache poisoned").insert(
15932        cache_key,
15933        MarkdownAstCacheEntry {
15934            source_hash: source_hash.clone(),
15935            nodes: nodes.clone(),
15936            parse_duration_micros,
15937        },
15938    );
15939    Ok(MarkdownAstProjection {
15940        source_hash,
15941        nodes,
15942        parse_duration_micros,
15943        cache_hit: false,
15944    })
15945}
15946
15947fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
15948    MarkdownAstCacheReport {
15949        source_hash: projection.source_hash.clone(),
15950        cache_hit: projection.cache_hit,
15951        parse_duration_micros: projection.parse_duration_micros,
15952        node_count: projection.nodes.len(),
15953        section_count: projection
15954            .nodes
15955            .iter()
15956            .filter(|node| node.kind == "heading")
15957            .count(),
15958        list_item_count: projection
15959            .nodes
15960            .iter()
15961            .filter(|node| node.kind == "list_item")
15962            .count(),
15963        code_block_count: projection
15964            .nodes
15965            .iter()
15966            .filter(|node| node.kind == "code_block")
15967            .count(),
15968    }
15969}
15970
15971fn markdown_ast_node_direct_child_count(
15972    node: &MarkdownAstRawNode,
15973    nodes: &[MarkdownAstRawNode],
15974) -> usize {
15975    nodes
15976        .iter()
15977        .filter(|candidate| {
15978            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
15979        })
15980        .count()
15981}
15982
15983fn markdown_ast_outline_entry(
15984    root: &Path,
15985    file: &str,
15986    source: &[u8],
15987    nodes: &[MarkdownAstRawNode],
15988    node: &MarkdownAstRawNode,
15989    max_bytes: usize,
15990) -> MarkdownAstOutlineEntry {
15991    let line = source_line_for_byte(source, node.start_byte);
15992    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
15993    MarkdownAstOutlineEntry {
15994        handle: node.handle.clone(),
15995        span_handle: node.span_handle.clone(),
15996        name: truncate_for_budget(&node.name, max_bytes),
15997        kind: node.kind.clone(),
15998        block_kind: node.block_kind.clone(),
15999        line,
16000        end_line,
16001        section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16002        child_count: markdown_ast_node_direct_child_count(node, nodes),
16003        expand: markdown_ast_command(root, file, Some(&node.handle)),
16004    }
16005}
16006
16007fn markdown_ast_outline_entries(
16008    root: &Path,
16009    file: &str,
16010    source: &[u8],
16011    nodes: &[MarkdownAstRawNode],
16012    limit: usize,
16013    max_bytes: usize,
16014) -> Vec<MarkdownAstOutlineEntry> {
16015    let mut headings = nodes
16016        .iter()
16017        .filter(|node| node.kind == "heading")
16018        .collect::<Vec<_>>();
16019    let mut blocks = nodes
16020        .iter()
16021        .filter(|node| node.kind != "heading")
16022        .collect::<Vec<_>>();
16023    headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16024    blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16025    headings
16026        .into_iter()
16027        .chain(blocks)
16028        .take(limit)
16029        .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16030        .collect()
16031}
16032
16033fn markdown_ast_node_intersects_lines(
16034    source: &[u8],
16035    node: &MarkdownAstRawNode,
16036    start: usize,
16037    end: usize,
16038) -> bool {
16039    let line = source_line_for_byte(source, node.start_byte);
16040    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16041    line <= end && end_line >= start
16042}
16043
16044fn source_read_markdown_projection(
16045    root: &Path,
16046    file: &str,
16047    source: &[u8],
16048    start: usize,
16049    end: usize,
16050    budget: ResponseBudget,
16051) -> Result<SourceReadMarkdownProjection> {
16052    let projection = markdown_ast_projection(file, source)?;
16053    let visible_nodes = projection
16054        .nodes
16055        .iter()
16056        .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16057        .collect::<Vec<_>>();
16058    let mut outline_nodes = visible_nodes.clone();
16059    outline_nodes.sort_by_key(|node| {
16060        (
16061            node.kind != "heading",
16062            node.start_byte,
16063            node.end_byte,
16064            node.name.as_str(),
16065        )
16066    });
16067    let outline = outline_nodes
16068        .into_iter()
16069        .take(budget.preview_items())
16070        .map(|node| {
16071            markdown_ast_outline_entry(
16072                root,
16073                file,
16074                source,
16075                &projection.nodes,
16076                node,
16077                budget.preview_bytes(),
16078            )
16079        })
16080        .collect::<Vec<_>>();
16081    Ok(SourceReadMarkdownProjection {
16082        handle: stable_handle(
16083            "mdproj",
16084            &format!("{file}:{start}:{end}:{}", projection.source_hash),
16085        ),
16086        mode: "window_outline".to_string(),
16087        total_nodes: projection.nodes.len(),
16088        visible_nodes: visible_nodes.len(),
16089        outline,
16090        expand: markdown_ast_command(root, file, None),
16091    })
16092}
16093
16094fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16095    if parent.handle == child.handle {
16096        return false;
16097    }
16098    parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16099}
16100
16101fn markdown_ast_parent_handle(
16102    node: &MarkdownAstRawNode,
16103    nodes: &[MarkdownAstRawNode],
16104) -> Option<String> {
16105    nodes
16106        .iter()
16107        .filter(|candidate| markdown_ast_contains(candidate, node))
16108        .min_by_key(|candidate| {
16109            (
16110                candidate.end_byte.saturating_sub(candidate.start_byte),
16111                candidate.start_byte,
16112            )
16113        })
16114        .map(|candidate| candidate.handle.clone())
16115}
16116
16117fn markdown_ast_child_handles(
16118    node: &MarkdownAstRawNode,
16119    nodes: &[MarkdownAstRawNode],
16120    limit: usize,
16121) -> Vec<String> {
16122    nodes
16123        .iter()
16124        .filter(|candidate| {
16125            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16126        })
16127        .take(limit)
16128        .map(|candidate| candidate.handle.clone())
16129        .collect()
16130}
16131
16132fn markdown_ast_section_nodes<'a>(
16133    node: &MarkdownAstRawNode,
16134    nodes: &'a [MarkdownAstRawNode],
16135) -> Vec<&'a MarkdownAstRawNode> {
16136    let mut headings = nodes
16137        .iter()
16138        .filter(|candidate| candidate.kind == "heading")
16139        .filter(|candidate| {
16140            candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16141        })
16142        .collect::<Vec<_>>();
16143    headings.sort_by(|left, right| {
16144        left.start_byte
16145            .cmp(&right.start_byte)
16146            .then(left.end_byte.cmp(&right.end_byte))
16147            .then(left.name.cmp(&right.name))
16148    });
16149    headings
16150}
16151
16152fn markdown_ast_node_metadata(
16153    file: &str,
16154    node: &MarkdownAstRawNode,
16155    source: &[u8],
16156    nodes: &[MarkdownAstRawNode],
16157) -> MarkdownAstNodeMetadata {
16158    let section_nodes = markdown_ast_section_nodes(node, nodes);
16159    let section_path = section_nodes
16160        .iter()
16161        .map(|heading| heading.name.clone())
16162        .collect::<Vec<_>>();
16163    let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16164    let heading_level = (node.kind == "heading")
16165        .then(|| markdown_heading_level(source, node.start_byte))
16166        .flatten();
16167    let (list_marker, list_order) = if node.kind == "list_item" {
16168        markdown_list_attributes(source, node.start_byte)
16169    } else {
16170        (None, None)
16171    };
16172    let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16173    let embedded_symbols = if node.kind == "code_block" {
16174        markdown_embedded_symbols(
16175            file,
16176            source,
16177            node.body_start_byte,
16178            node.body_end_byte,
16179            fence_language.as_deref(),
16180        )
16181    } else {
16182        Vec::new()
16183    };
16184    MarkdownAstNodeMetadata {
16185        heading_level,
16186        section_path,
16187        section_handle,
16188        list_depth: (node.kind == "list_item")
16189            .then(|| markdown_list_depth(source, node.start_byte)),
16190        list_marker,
16191        list_order,
16192        fence_language,
16193        fence_marker: (node.kind == "code_block")
16194            .then(|| markdown_fence_marker(source, node.start_byte))
16195            .flatten(),
16196        embedded_symbols,
16197    }
16198}
16199
16200fn markdown_ast_node_expand(
16201    root: &Path,
16202    file: &str,
16203    node: &MarkdownAstRawNode,
16204    source: &[u8],
16205) -> MarkdownAstNodeExpand {
16206    let start_line = source_line_for_byte(source, node.start_byte);
16207    let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16208    let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16209    let body_start_line = node
16210        .body_start_byte
16211        .map(|byte| source_line_for_byte(source, byte))
16212        .unwrap_or(start_line);
16213    let body_end_line = node
16214        .body_end_byte
16215        .map(|byte| source_line_for_end_byte(source, byte))
16216        .unwrap_or(end_line)
16217        .max(body_start_line);
16218    let body_line_count = body_end_line
16219        .saturating_sub(body_start_line)
16220        .saturating_add(1)
16221        .max(1);
16222    MarkdownAstNodeExpand {
16223        source_window: source_read_command(root, file, start_line, line_count),
16224        source_body: source_read_command(root, file, body_start_line, body_line_count),
16225        symbol_read: source_symbol_read_command(root, &node.name, file),
16226        edit_intents: markdown_edit_intents_command(root),
16227    }
16228}
16229
16230fn markdown_ast_node(
16231    root: &Path,
16232    file: &str,
16233    node: &MarkdownAstRawNode,
16234    source: &[u8],
16235    nodes: &[MarkdownAstRawNode],
16236    child_limit: usize,
16237) -> MarkdownAstNode {
16238    let line = source_line_for_byte(source, node.start_byte);
16239    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16240    let body_byte_span = node
16241        .body_start_byte
16242        .zip(node.body_end_byte)
16243        .map(|(start, end)| SourceByteRangePreview { start, end });
16244    MarkdownAstNode {
16245        handle: node.handle.clone(),
16246        span_handle: node.span_handle.clone(),
16247        name: node.name.clone(),
16248        kind: node.kind.clone(),
16249        block_kind: node.block_kind.clone(),
16250        node_kind: node.node_kind.clone(),
16251        line,
16252        end_line,
16253        byte_span: SourceByteRangePreview {
16254            start: node.start_byte,
16255            end: node.end_byte,
16256        },
16257        body_byte_span,
16258        parent_handle: markdown_ast_parent_handle(node, nodes),
16259        child_handles: markdown_ast_child_handles(node, nodes, child_limit),
16260        metadata: markdown_ast_node_metadata(file, node, source, nodes),
16261        expand: markdown_ast_node_expand(root, file, node, source),
16262    }
16263}
16264
16265pub(crate) fn stored_symbol_ast_span(
16266    symbol: &index::StoredSymbol,
16267    source: &[u8],
16268    symbols: &[index::StoredSymbol],
16269    child_limit: usize,
16270) -> Option<AstSpanPreview> {
16271    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16272    let node_kind = symbol.node_kind.clone()?;
16273    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16274    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16275    Some(AstSpanPreview {
16276        handle: ast_span_handle(
16277            &symbol.file,
16278            &symbol.name,
16279            &symbol.kind,
16280            start_byte,
16281            end_byte,
16282        ),
16283        node_kind,
16284        start_byte,
16285        end_byte,
16286        start_line: source_line_for_byte(source, start_byte),
16287        end_line: source_line_for_end_byte(source, end_byte),
16288        body_start_byte,
16289        body_end_byte,
16290        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16291        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16292        parent_handle: stored_symbol_parent_span_handle(symbol, symbols),
16293        child_handles: stored_symbol_child_span_handles(symbol, symbols, child_limit),
16294        markdown: markdown_stored_symbol_metadata(symbol, source, symbols),
16295    })
16296}
16297
16298pub(crate) fn symbol_hit_ast_span(
16299    symbol: &index::SymbolHit,
16300    source: &[u8],
16301) -> Option<AstSpanPreview> {
16302    let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
16303    let node_kind = symbol.node_kind.clone()?;
16304    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16305    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16306    Some(AstSpanPreview {
16307        handle: ast_span_handle(
16308            &symbol.file,
16309            &symbol.name,
16310            &symbol.kind,
16311            start_byte,
16312            end_byte,
16313        ),
16314        node_kind,
16315        start_byte,
16316        end_byte,
16317        start_line: source_line_for_byte(source, start_byte),
16318        end_line: source_line_for_end_byte(source, end_byte),
16319        body_start_byte,
16320        body_end_byte,
16321        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16322        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16323        parent_handle: None,
16324        child_handles: Vec::new(),
16325        markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
16326    })
16327}
16328
16329pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
16330    usize::try_from(symbol.line)
16331        .ok()
16332        .and_then(|line| line.checked_add(1))
16333        .unwrap_or(1)
16334}
16335
16336pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
16337    symbol
16338        .end_line
16339        .and_then(|line| usize::try_from(line).ok())
16340        .and_then(|line| line.checked_add(1))
16341}
16342
16343fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
16344    if end == 0 {
16345        return false;
16346    }
16347    let symbol_start = source_symbol_line(symbol);
16348    let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
16349    symbol_start <= end && symbol_end >= start
16350}
16351
16352#[allow(clippy::too_many_arguments)]
16353fn load_source_symbols(
16354    root: &Path,
16355    file_abs: &Path,
16356    file_display: &str,
16357    source: &[u8],
16358    scope: Option<&str>,
16359    start: usize,
16360    end: usize,
16361    limit: usize,
16362    max_bytes: usize,
16363    warnings: &mut Vec<String>,
16364) -> Vec<SourceSymbolRef> {
16365    let db_path = match resolve_query_db_path(root, file_abs, scope) {
16366        Ok(path) => path,
16367        Err(err) => {
16368            warnings.push(format!("index refs unavailable: {err:#}"));
16369            return Vec::new();
16370        }
16371    };
16372    if !db_path.exists() {
16373        warnings.push(format!(
16374            "index refs unavailable: no index found at {}",
16375            db_path.display()
16376        ));
16377        return Vec::new();
16378    }
16379
16380    let db = match index::IndexDb::open_read_only_resilient(&db_path) {
16381        Ok(db) => db,
16382        Err(err) => {
16383            warnings.push(format!("index refs unavailable: {err:#}"));
16384            return Vec::new();
16385        }
16386    };
16387
16388    let file_key = file_abs.to_string_lossy().to_string();
16389    let symbols = match db.symbols_for_file(&file_key) {
16390        Ok(symbols) => symbols,
16391        Err(err) => {
16392            warnings.push(format!("symbol refs unavailable: {err:#}"));
16393            return Vec::new();
16394        }
16395    };
16396
16397    symbols
16398        .iter()
16399        .filter(|symbol| source_symbol_intersects(symbol, start, end))
16400        .take(limit)
16401        .map(|symbol| {
16402            let line = source_symbol_line(symbol);
16403            let end_line = source_symbol_end_line(symbol);
16404            let handle = stable_handle(
16405                "ssym",
16406                &format!("{}:{}:{}", file_display, symbol.name, line),
16407            );
16408            SourceSymbolRef {
16409                handle,
16410                name: truncate_for_budget(&symbol.name, max_bytes),
16411                kind: symbol.kind.clone(),
16412                language: symbol.language.clone(),
16413                file: file_display.to_string(),
16414                line,
16415                end_line,
16416                signature: symbol
16417                    .signature
16418                    .clone()
16419                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
16420                span: stored_symbol_ast_span(symbol, source, &symbols, limit),
16421                expand: source_symbol_read_command(root, &symbol.name, file_display),
16422            }
16423        })
16424        .collect()
16425}
16426
16427fn load_source_summaries(
16428    root: &Path,
16429    file_display: &str,
16430    limit: usize,
16431    max_bytes: usize,
16432    warnings: &mut Vec<String>,
16433) -> Vec<SourceSummaryRef> {
16434    let db_path = root.join(".tsift/summaries.db");
16435    if !db_path.exists() {
16436        return Vec::new();
16437    }
16438    let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
16439        Ok(db) => db,
16440        Err(err) => {
16441            warnings.push(format!("summary refs unavailable: {err:#}"));
16442            return Vec::new();
16443        }
16444    };
16445    let summaries = match db.get_by_file(file_display) {
16446        Ok(summaries) => summaries,
16447        Err(err) => {
16448            warnings.push(format!("summary refs unavailable: {err:#}"));
16449            return Vec::new();
16450        }
16451    };
16452
16453    summaries
16454        .into_iter()
16455        .take(limit)
16456        .map(|summary| SourceSummaryRef {
16457            handle: stable_handle(
16458                "sum",
16459                &format!(
16460                    "{}:{}:{}",
16461                    summary.file_path, summary.symbol_name, summary.id
16462                ),
16463            ),
16464            symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
16465            file_path: summary.file_path,
16466            summary: truncate_for_budget(&summary.summary, max_bytes),
16467            expand: source_summary_expand_command(root, &summary.symbol_name),
16468        })
16469        .collect()
16470}
16471
16472fn cmd_markdown_ast(
16473    file: &Path,
16474    path: &Path,
16475    node: Option<&str>,
16476    format: OutputFormat,
16477    absolute: bool,
16478    budget: ResponseBudget,
16479) -> Result<()> {
16480    let root = lint::resolve_project_root_or_canonical_path(path)?;
16481    let file_abs = resolve_source_file(&root, file)?;
16482    if !is_markdown_path(&file_abs) {
16483        bail!(
16484            "markdown-ast only supports Markdown files (.md/.mdx): {}",
16485            file_abs.display()
16486        );
16487    }
16488    let file_display = if absolute {
16489        file_abs.to_string_lossy().to_string()
16490    } else {
16491        relativize_pathbuf(&file_abs, &root)
16492            .to_string_lossy()
16493            .to_string()
16494    };
16495    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16496    let text = String::from_utf8_lossy(&source);
16497    let total_lines = text.lines().count();
16498    let projection = markdown_ast_projection(&file_display, &source)?;
16499    let raw_nodes = &projection.nodes;
16500    let max_items = budget.preview_items();
16501    let max_bytes = budget.preview_bytes();
16502
16503    let selected_nodes = if let Some(handle) = node {
16504        let matches = raw_nodes
16505            .iter()
16506            .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
16507            .collect::<Vec<_>>();
16508        if matches.is_empty() {
16509            bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
16510        }
16511        matches
16512    } else {
16513        raw_nodes.iter().take(max_items).collect::<Vec<_>>()
16514    };
16515    let nodes = selected_nodes
16516        .into_iter()
16517        .map(|raw| {
16518            let mut node =
16519                markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
16520            node.name = truncate_for_budget(&node.name, max_bytes);
16521            node
16522        })
16523        .collect::<Vec<_>>();
16524    let outline_started = Instant::now();
16525    let outline = markdown_ast_outline_entries(
16526        &root,
16527        &file_display,
16528        &source,
16529        raw_nodes,
16530        max_items,
16531        max_bytes,
16532    );
16533    let outline_duration_micros = outline_started.elapsed().as_micros();
16534    let projection_preview = MarkdownAstProjectionPreview {
16535        mode: if node.is_some() {
16536            "selected_node".to_string()
16537        } else {
16538            "outline_first".to_string()
16539        },
16540        total_nodes: raw_nodes.len(),
16541        returned_nodes: nodes.len(),
16542        omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
16543        selected_node: node.map(str::to_string),
16544        cache: markdown_ast_cache_report(&projection),
16545        outline,
16546        phase_timings: vec![
16547            MarkdownAstPhaseTiming {
16548                name: "parse_extract".to_string(),
16549                duration_micros: projection.parse_duration_micros,
16550                detail: if projection.cache_hit {
16551                    "reused cached tree-sitter Markdown symbol extraction".to_string()
16552                } else {
16553                    "tree-sitter Markdown symbol extraction".to_string()
16554                },
16555            },
16556            MarkdownAstPhaseTiming {
16557                name: "outline_projection".to_string(),
16558                duration_micros: outline_duration_micros,
16559                detail: "outline-first section/block preview construction".to_string(),
16560            },
16561        ],
16562    };
16563    let report = MarkdownAstReport {
16564        handle: stable_handle("mdastrep", &file_display),
16565        root: root.to_string_lossy().to_string(),
16566        file: file_display.clone(),
16567        range: SourceRangePreview {
16568            start: 1,
16569            end: total_lines,
16570            total_lines,
16571            truncated_before: false,
16572            truncated_after: false,
16573        },
16574        projection: projection_preview,
16575        nodes,
16576        expand: MarkdownAstExpandCommands {
16577            file: markdown_ast_command(&root, &file_display, None),
16578            source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
16579            edit_intents: markdown_edit_intents_command(&root),
16580        },
16581        warnings: Vec::new(),
16582    };
16583
16584    if format.json_output {
16585        let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
16586        let mut follow_up = vec![
16587            report.expand.file.clone(),
16588            report.expand.source_read.clone(),
16589            report.expand.edit_intents.clone(),
16590        ];
16591        follow_up.extend(
16592            report
16593                .nodes
16594                .iter()
16595                .map(|node| node.expand.source_window.clone()),
16596        );
16597        print_json_or_envelope(
16598            &report,
16599            &format,
16600            "markdown-ast",
16601            "ast",
16602            ToolEnvelopeSummary {
16603                text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
16604                metrics: vec![
16605                    envelope_metric("nodes", report.nodes.len()),
16606                    envelope_metric("total_nodes", report.projection.total_nodes),
16607                    envelope_metric(
16608                        "parse_duration_micros",
16609                        report.projection.cache.parse_duration_micros,
16610                    ),
16611                    envelope_metric("total_lines", report.range.total_lines),
16612                ],
16613            },
16614            truncated,
16615            follow_up,
16616        )?;
16617    } else if format.compact {
16618        println!(
16619            "markdown-ast {} nodes:{} handle:{}",
16620            report.file,
16621            report.nodes.len(),
16622            report.handle
16623        );
16624        for node in &report.nodes {
16625            println!(
16626                "  {} {} {}:{}-{}",
16627                node.handle, node.kind, node.name, node.line, node.end_line
16628            );
16629        }
16630        if node.is_none() && raw_nodes.len() > report.nodes.len() {
16631            println!("expand: {}", report.expand.file);
16632        }
16633    } else {
16634        println!(
16635            "Markdown AST `{}` nodes {} of {} ({})",
16636            report.file,
16637            report.nodes.len(),
16638            raw_nodes.len(),
16639            report.handle
16640        );
16641        for node in &report.nodes {
16642            println!(
16643                "  {} `{}` {}:{}-{} — {}",
16644                node.handle,
16645                node.name,
16646                node.kind,
16647                node.line,
16648                node.end_line,
16649                node.expand.source_window
16650            );
16651        }
16652        if node.is_none() && raw_nodes.len() > report.nodes.len() {
16653            println!();
16654            println!("Expand:");
16655            println!("  file: {}", report.expand.file);
16656        }
16657    }
16658
16659    Ok(())
16660}
16661
16662#[allow(clippy::too_many_arguments)]
16663fn cmd_source_read(
16664    file: &Path,
16665    path: &Path,
16666    style: SourceReadStyle,
16667    start: usize,
16668    lines: usize,
16669    end: Option<usize>,
16670    scope: Option<&str>,
16671    format: OutputFormat,
16672    absolute: bool,
16673    budget: ResponseBudget,
16674) -> Result<()> {
16675    if start == 0 {
16676        bail!("--start is 1-based and must be greater than zero");
16677    }
16678    if lines == 0 {
16679        bail!("--lines must be greater than zero");
16680    }
16681    if let Some(end) = end
16682        && end < start
16683    {
16684        bail!("--end must be greater than or equal to --start");
16685    }
16686
16687    let root = lint::resolve_project_root_or_canonical_path(path)?;
16688    let file_abs = resolve_source_file(&root, file)?;
16689    let file_display = if absolute {
16690        file_abs.to_string_lossy().to_string()
16691    } else {
16692        relativize_pathbuf(&file_abs, &root)
16693            .to_string_lossy()
16694            .to_string()
16695    };
16696
16697    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16698    let text = String::from_utf8_lossy(&source);
16699    let all_lines: Vec<&str> = text.lines().collect();
16700    let total_lines = all_lines.len();
16701    if total_lines > 0 && start > total_lines {
16702        bail!(
16703            "--start {} is beyond end of {} ({} lines)",
16704            start,
16705            file_display,
16706            total_lines
16707        );
16708    }
16709    let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
16710    let end_line = requested_end.min(total_lines);
16711    let mut warnings = Vec::new();
16712    let max_items = budget.preview_items();
16713    let max_bytes = budget.preview_bytes();
16714    if style == SourceReadStyle::Ast {
16715        let symbols = load_source_symbols(
16716            &root,
16717            &file_abs,
16718            &file_display,
16719            &source,
16720            scope,
16721            start,
16722            end_line,
16723            max_items,
16724            max_bytes,
16725            &mut warnings,
16726        );
16727        let summaries =
16728            load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
16729        let markdown = if is_markdown_path(&file_abs) {
16730            match source_read_markdown_projection(
16731                &root,
16732                &file_display,
16733                &source,
16734                start,
16735                end_line,
16736                budget,
16737            ) {
16738                Ok(markdown) => Some(markdown),
16739                Err(err) => {
16740                    warnings.push(format!("markdown projection unavailable: {err:#}"));
16741                    None
16742                }
16743            }
16744        } else {
16745            None
16746        };
16747        let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
16748        let report = SourceReadAstReport {
16749            handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
16750            root: root.to_string_lossy().to_string(),
16751            file: file_display.clone(),
16752            range: SourceRangePreview {
16753                start,
16754                end: end_line,
16755                total_lines,
16756                truncated_before: start > 1,
16757                truncated_after: end_line < total_lines,
16758            },
16759            symbols,
16760            summaries,
16761            markdown,
16762            expand: SourceReadAstExpandCommands {
16763                window: source_read_window_command(&root, &file_display, start, window_lines),
16764                file_window: source_read_window_command(
16765                    &root,
16766                    &file_display,
16767                    1,
16768                    total_lines.max(window_lines),
16769                ),
16770                markdown_ast: is_markdown_path(&file_abs)
16771                    .then(|| markdown_ast_command(&root, &file_display, None)),
16772            },
16773            warnings,
16774        };
16775
16776        if format.json_output {
16777            let truncated = report.range.truncated_before
16778                || report.range.truncated_after
16779                || report.symbols.len() >= max_items
16780                || report.summaries.len() >= max_items;
16781            let follow_up = [
16782                Some(report.expand.window.clone()),
16783                Some(report.expand.file_window.clone()),
16784                report.expand.markdown_ast.clone(),
16785            ]
16786            .into_iter()
16787            .flatten()
16788            .collect::<Vec<_>>();
16789            print_json_or_envelope(
16790                &report,
16791                &format,
16792                "source-read",
16793                "ast",
16794                ToolEnvelopeSummary {
16795                    text: format!(
16796                        "source ast {}:{}-{}",
16797                        report.file, report.range.start, report.range.end
16798                    ),
16799                    metrics: vec![
16800                        envelope_metric("symbols", report.symbols.len()),
16801                        envelope_metric("summaries", report.summaries.len()),
16802                        envelope_metric(
16803                            "markdown_nodes",
16804                            report
16805                                .markdown
16806                                .as_ref()
16807                                .map_or(0, |markdown| markdown.visible_nodes),
16808                        ),
16809                    ],
16810                },
16811                truncated,
16812                follow_up,
16813            )?;
16814        } else if format.compact {
16815            println!(
16816                "source-ast {}:{}-{} / {} handle:{}",
16817                report.file,
16818                report.range.start,
16819                report.range.end,
16820                report.range.total_lines,
16821                report.handle
16822            );
16823            for symbol in &report.symbols {
16824                println!(
16825                    "  {} {}:{} {}",
16826                    symbol.name, symbol.file, symbol.line, symbol.expand
16827                );
16828            }
16829            if !report.summaries.is_empty() {
16830                println!("summaries[{}]", report.summaries.len());
16831            }
16832            for warning in &report.warnings {
16833                eprintln!("warning: {warning}");
16834            }
16835        } else {
16836            println!(
16837                "Source AST `{}` lines {}-{} of {} ({})",
16838                report.file,
16839                report.range.start,
16840                report.range.end,
16841                report.range.total_lines,
16842                report.handle
16843            );
16844            if !report.symbols.is_empty() {
16845                println!();
16846                println!("Symbol refs:");
16847                for symbol in &report.symbols {
16848                    println!(
16849                        "  {} `{}` {}:{} — {}",
16850                        symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
16851                    );
16852                }
16853            }
16854            if !report.summaries.is_empty() {
16855                println!();
16856                println!("Summary refs:");
16857                for summary in &report.summaries {
16858                    println!(
16859                        "  {} `{}` — {}",
16860                        summary.handle, summary.symbol_name, summary.expand
16861                    );
16862                }
16863            }
16864            println!();
16865            println!("Expand:");
16866            println!("  window:      {}", report.expand.window);
16867            println!("  file window: {}", report.expand.file_window);
16868            if let Some(markdown_ast) = &report.expand.markdown_ast {
16869                println!("  markdown:    {}", markdown_ast);
16870            }
16871            for warning in &report.warnings {
16872                eprintln!("warning: {warning}");
16873            }
16874        }
16875
16876        return Ok(());
16877    }
16878    let max_bytes = budget.preview_bytes();
16879    let token_cap = budget.body_token_cap();
16880    let (preview, preview_end, body_truncated) = if total_lines == 0 {
16881        (Vec::new(), end_line, false)
16882    } else {
16883        let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
16884        (capped.preview, capped.capped_end, capped.was_capped)
16885    };
16886    let effective_end = if body_truncated {
16887        preview_end
16888    } else {
16889        end_line
16890    };
16891
16892    if body_truncated {
16893        warnings.push(format!(
16894            "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
16895        ));
16896    }
16897    let symbols = load_source_symbols(
16898        &root,
16899        &file_abs,
16900        &file_display,
16901        &source,
16902        scope,
16903        start,
16904        effective_end,
16905        max_items,
16906        max_bytes,
16907        &mut warnings,
16908    );
16909    let summaries =
16910        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
16911    let markdown = if is_markdown_path(&file_abs) {
16912        match source_read_markdown_projection(
16913            &root,
16914            &file_display,
16915            &source,
16916            start,
16917            effective_end,
16918            budget,
16919        ) {
16920            Ok(markdown) => Some(markdown),
16921            Err(err) => {
16922                warnings.push(format!("markdown projection unavailable: {err:#}"));
16923                None
16924            }
16925        }
16926    } else {
16927        None
16928    };
16929
16930    let expand = SourceExpandCommands {
16931        before: (start > 1).then(|| {
16932            let before_start = start.saturating_sub(lines).max(1);
16933            source_read_window_command(&root, &file_display, before_start, start - before_start)
16934        }),
16935        after: (effective_end < total_lines)
16936            .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
16937        body: body_truncated.then(|| {
16938            let remaining = end_line.saturating_sub(effective_end);
16939            source_read_window_command(&root, &file_display, effective_end + 1, remaining)
16940        }),
16941        file: source_read_ast_command(&root, &file_display),
16942        markdown_ast: is_markdown_path(&file_abs)
16943            .then(|| markdown_ast_command(&root, &file_display, None)),
16944    };
16945
16946    let report = SourceReadReport {
16947        handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
16948        root: root.to_string_lossy().to_string(),
16949        file: file_display,
16950        range: SourceRangePreview {
16951            start,
16952            end: effective_end,
16953            total_lines,
16954            truncated_before: start > 1,
16955            truncated_after: effective_end < total_lines,
16956        },
16957        preview,
16958        symbols,
16959        summaries,
16960        markdown,
16961        expand,
16962        warnings,
16963    };
16964
16965    if format.json_output {
16966        let truncated = report.range.truncated_before || report.range.truncated_after;
16967        let follow_up = [
16968            report.expand.before.clone(),
16969            report.expand.after.clone(),
16970            report.expand.body.clone(),
16971            Some(report.expand.file.clone()),
16972            report.expand.markdown_ast.clone(),
16973        ]
16974        .into_iter()
16975        .flatten()
16976        .collect::<Vec<_>>();
16977        print_json_or_envelope(
16978            &report,
16979            &format,
16980            "source-read",
16981            "window",
16982            ToolEnvelopeSummary {
16983                text: format!(
16984                    "source window {}:{}-{}",
16985                    report.file, report.range.start, report.range.end
16986                ),
16987                metrics: vec![
16988                    envelope_metric("lines", report.preview.len()),
16989                    envelope_metric("symbols", report.symbols.len()),
16990                    envelope_metric("summaries", report.summaries.len()),
16991                    envelope_metric(
16992                        "markdown_nodes",
16993                        report
16994                            .markdown
16995                            .as_ref()
16996                            .map_or(0, |markdown| markdown.visible_nodes),
16997                    ),
16998                ],
16999            },
17000            truncated,
17001            follow_up,
17002        )?;
17003    } else if format.compact {
17004        println!(
17005            "source {}:{}-{} / {} handle:{}",
17006            report.file,
17007            report.range.start,
17008            report.range.end,
17009            report.range.total_lines,
17010            report.handle
17011        );
17012        for line in &report.preview {
17013            println!("{:>5} {}", line.line, line.text);
17014        }
17015        if !report.symbols.is_empty() {
17016            println!("syms[{}]:", report.symbols.len());
17017            for symbol in &report.symbols {
17018                println!("  {} {}:{}", symbol.name, symbol.file, symbol.line);
17019            }
17020        }
17021        if report.range.truncated_before || report.range.truncated_after {
17022            println!("expand: {}", report.expand.file);
17023        }
17024    } else {
17025        println!(
17026            "Source window `{}` lines {}-{} of {} ({})",
17027            report.file,
17028            report.range.start,
17029            report.range.end,
17030            report.range.total_lines,
17031            report.handle
17032        );
17033        for line in &report.preview {
17034            println!("{:>5} | {}", line.line, line.text);
17035        }
17036        if !report.symbols.is_empty() {
17037            println!();
17038            println!("Symbol refs:");
17039            for symbol in &report.symbols {
17040                println!(
17041                    "  {} `{}` {}:{} — {}",
17042                    symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17043                );
17044            }
17045        }
17046        if !report.summaries.is_empty() {
17047            println!();
17048            println!("Summary refs:");
17049            for summary in &report.summaries {
17050                println!(
17051                    "  {} `{}` — {}",
17052                    summary.handle, summary.symbol_name, summary.expand
17053                );
17054            }
17055        }
17056        if report.range.truncated_before || report.range.truncated_after {
17057            println!();
17058            println!("Expand:");
17059            if let Some(before) = &report.expand.before {
17060                println!("  before: {}", before);
17061            }
17062            if let Some(after) = &report.expand.after {
17063                println!("  after: {}", after);
17064            }
17065            println!("  file:   {}", report.expand.file);
17066        }
17067        for warning in &report.warnings {
17068            eprintln!("warning: {warning}");
17069        }
17070    }
17071
17072    Ok(())
17073}
17074
17075#[allow(clippy::too_many_arguments)]
17076fn cmd_symbol_read(
17077    symbol: &str,
17078    file_hint: Option<&Path>,
17079    path: &Path,
17080    scope: Option<&str>,
17081    format: OutputFormat,
17082    absolute: bool,
17083    budget: ResponseBudget,
17084) -> Result<()> {
17085    let root = lint::resolve_project_root_or_canonical_path(path)?;
17086    let hinted_file_abs = file_hint
17087        .map(|file| resolve_source_file(&root, file))
17088        .transpose()?;
17089    let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17090    let db_path = resolve_query_db_path(&root, path_hint, scope)?;
17091    if !db_path.exists() {
17092        bail!(
17093            "index refs unavailable: no index found at {}",
17094            db_path.display()
17095        );
17096    }
17097    let db = index::IndexDb::open_read_only_resilient(&db_path)
17098        .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17099    let search_limit = budget.follow_up_items().max(10);
17100    let hits = db
17101        .symbol_search(symbol, search_limit)
17102        .with_context(|| format!("searching symbols for {symbol:?}"))?;
17103    let selected = hits
17104        .into_iter()
17105        .find(|hit| {
17106            let Some(hinted_file_abs) = &hinted_file_abs else {
17107                return true;
17108            };
17109            resolve_source_file(&root, Path::new(&hit.file))
17110                .map(|hit_file| hit_file == *hinted_file_abs)
17111                .unwrap_or(false)
17112        })
17113        .with_context(|| {
17114            let hint = file_hint
17115                .map(|file| format!(" in {}", file.display()))
17116                .unwrap_or_default();
17117            format!("no indexed symbol matched {symbol:?}{hint}")
17118        })?;
17119
17120    let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17121    let file_display = if absolute {
17122        file_abs.to_string_lossy().to_string()
17123    } else {
17124        relativize_pathbuf(&file_abs, &root)
17125            .to_string_lossy()
17126            .to_string()
17127    };
17128    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17129    let content_hash = blake3::hash(&source).to_hex().to_string();
17130    let text = String::from_utf8_lossy(&source);
17131    let all_lines: Vec<&str> = text.lines().collect();
17132    let total_lines = all_lines.len();
17133    let file_symbols = db
17134        .symbols_for_file(&file_abs.to_string_lossy())
17135        .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17136    let max_items = budget.preview_items();
17137    let max_bytes = budget.preview_bytes();
17138    let selected_start = symbol_hit_line(&selected);
17139    let selected_end = symbol_hit_end_line(&selected)
17140        .unwrap_or(selected_start)
17141        .max(selected_start);
17142    let stored_target = file_symbols.iter().find(|candidate| {
17143        candidate.name == selected.name
17144            && candidate.kind == selected.kind
17145            && source_symbol_line(candidate) == selected_start
17146    });
17147    let target_span = stored_target
17148        .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17149        .or_else(|| symbol_hit_ast_span(&selected, &source));
17150    let target_start = target_span
17151        .as_ref()
17152        .map(|span| span.start_line)
17153        .unwrap_or(selected_start);
17154    let target_end = target_span
17155        .as_ref()
17156        .map(|span| span.end_line)
17157        .or_else(|| stored_target.and_then(source_symbol_end_line))
17158        .unwrap_or(selected_end)
17159        .max(target_start);
17160    let target_bounds = stored_target
17161        .and_then(stored_symbol_span_bounds)
17162        .or_else(|| symbol_hit_span_bounds(&selected));
17163    let target_end = stored_target
17164        .and_then(source_symbol_end_line)
17165        .unwrap_or(target_end)
17166        .max(target_start);
17167    let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17168    let line_capped_end = target_start
17169        .saturating_add(body_line_budget)
17170        .saturating_sub(1)
17171        .min(target_end)
17172        .min(total_lines.max(target_start));
17173    let token_cap = budget.body_token_cap();
17174    let (body, effective_preview_end, body_truncated) =
17175        if total_lines == 0 || target_start > total_lines {
17176            (Vec::new(), line_capped_end, false)
17177        } else {
17178            let capped = build_token_capped_preview(
17179                &all_lines,
17180                target_start,
17181                line_capped_end,
17182                max_bytes,
17183                token_cap,
17184            );
17185            (capped.preview, capped.capped_end, capped.was_capped)
17186        };
17187    let preview_end = if body_truncated {
17188        effective_preview_end
17189    } else {
17190        line_capped_end
17191    };
17192    let child_symbols = file_symbols
17193        .iter()
17194        .filter(|candidate| {
17195            if let Some((target_start_byte, target_end_byte)) = target_bounds {
17196                let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17197                else {
17198                    return false;
17199                };
17200                return candidate_start >= target_start_byte
17201                    && candidate_end <= target_end_byte
17202                    && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17203            }
17204            let line = source_symbol_line(candidate);
17205            line > target_start && line <= target_end
17206        })
17207        .take(max_items)
17208        .map(|symbol| {
17209            let line = source_symbol_line(symbol);
17210            let end_line = source_symbol_end_line(symbol);
17211            SourceSymbolRef {
17212                handle: stable_handle(
17213                    "ssym",
17214                    &format!("{}:{}:{}", file_display, symbol.name, line),
17215                ),
17216                name: truncate_for_budget(&symbol.name, max_bytes),
17217                kind: symbol.kind.clone(),
17218                language: symbol.language.clone(),
17219                file: file_display.clone(),
17220                line,
17221                end_line,
17222                signature: symbol
17223                    .signature
17224                    .clone()
17225                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
17226                span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
17227                expand: source_symbol_read_command(&root, &symbol.name, &file_display),
17228            }
17229        })
17230        .collect::<Vec<_>>();
17231    let mut warnings = Vec::new();
17232    if body_truncated {
17233        warnings.push(format!(
17234            "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
17235        ));
17236    }
17237    let summaries =
17238        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17239    let symbol_handle = stable_handle(
17240        "sread",
17241        &format!("{}:{}:{}", file_display, selected.name, target_start),
17242    );
17243    let source_lines = preview_end
17244        .saturating_sub(target_start)
17245        .saturating_add(1)
17246        .max(1);
17247    let expand = SymbolReadExpandCommands {
17248        source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
17249        body: body_truncated.then(|| {
17250            let remaining = target_end.saturating_sub(preview_end);
17251            source_read_window_command(&root, &file_display, preview_end + 1, remaining)
17252        }),
17253        file: source_read_ast_command(&root, &file_display),
17254        explain: source_symbol_expand_command(&root, &selected.name),
17255        callers: source_symbol_graph_command(&root, &selected.name, "callers"),
17256        callees: source_symbol_graph_command(&root, &selected.name, "callees"),
17257        markdown_ast: (selected.language == "markdown").then(|| {
17258            markdown_ast_command(
17259                &root,
17260                &file_display,
17261                target_span.as_ref().map(|span| span.handle.as_str()),
17262            )
17263        }),
17264    };
17265    let report = SymbolReadReport {
17266        handle: symbol_handle.clone(),
17267        root: root.to_string_lossy().to_string(),
17268        query: symbol.to_string(),
17269        symbol: SymbolReadTarget {
17270            handle: symbol_handle,
17271            name: selected.name.clone(),
17272            kind: selected.kind.clone(),
17273            language: selected.language.clone(),
17274            file: file_display.clone(),
17275            line: target_start,
17276            end_line: Some(target_end),
17277            signature: stored_target
17278                .and_then(|stored| stored.signature.clone())
17279                .map(|signature| truncate_for_budget(&signature, max_bytes)),
17280            parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
17281            visibility: stored_target.and_then(|stored| stored.visibility.clone()),
17282            span: target_span,
17283        },
17284        range: SourceRangePreview {
17285            start: target_start,
17286            end: preview_end,
17287            total_lines,
17288            truncated_before: false,
17289            truncated_after: preview_end < target_end,
17290        },
17291        body,
17292        child_symbols,
17293        summaries,
17294        expand,
17295        warnings,
17296    };
17297
17298    if format.json_output {
17299        let truncated = report.range.truncated_after
17300            || report.body.iter().any(|line| line.text.len() >= max_bytes)
17301            || report.child_symbols.len() >= max_items;
17302        let follow_up = [
17303            Some(report.expand.source_window.clone()),
17304            report.expand.body.clone(),
17305            Some(report.expand.file.clone()),
17306            Some(report.expand.explain.clone()),
17307            Some(report.expand.callers.clone()),
17308            Some(report.expand.callees.clone()),
17309        ]
17310        .into_iter()
17311        .flatten()
17312        .chain(report.expand.markdown_ast.clone())
17313        .collect::<Vec<_>>();
17314        print_json_or_envelope(
17315            &report,
17316            &format,
17317            "symbol-read",
17318            "symbol",
17319            ToolEnvelopeSummary {
17320                text: format!(
17321                    "symbol {} {}:{}-{}",
17322                    report.symbol.name, report.symbol.file, report.range.start, report.range.end
17323                ),
17324                metrics: vec![
17325                    envelope_metric("body_lines", report.body.len()),
17326                    envelope_metric("child_symbols", report.child_symbols.len()),
17327                    envelope_metric("summaries", report.summaries.len()),
17328                ],
17329            },
17330            truncated,
17331            follow_up,
17332        )?;
17333    } else if format.compact {
17334        println!(
17335            "symbol {} {}:{}-{} handle:{} hash:{}",
17336            report.symbol.name,
17337            report.symbol.file,
17338            report.range.start,
17339            report.range.end,
17340            report.handle,
17341            content_hash
17342        );
17343        for line in &report.body {
17344            println!("{:>5} {}", line.line, line.text);
17345        }
17346        if !report.child_symbols.is_empty() {
17347            println!("children[{}]:", report.child_symbols.len());
17348            for child in &report.child_symbols {
17349                println!("  {} {}:{}", child.name, child.file, child.line);
17350            }
17351        }
17352    } else {
17353        println!(
17354            "Symbol `{}` in `{}` lines {}-{} ({})",
17355            report.symbol.name,
17356            report.symbol.file,
17357            report.range.start,
17358            report.range.end,
17359            report.handle
17360        );
17361        for line in &report.body {
17362            println!("{:>5} | {}", line.line, line.text);
17363        }
17364        if !report.child_symbols.is_empty() {
17365            println!();
17366            println!("Child symbols:");
17367            for child in &report.child_symbols {
17368                println!(
17369                    "  {} `{}` {}:{} — {}",
17370                    child.handle, child.name, child.file, child.line, child.expand
17371                );
17372            }
17373        }
17374        println!();
17375        println!("Expand:");
17376        println!("  source:  {}", report.expand.source_window);
17377        println!("  file:    {}", report.expand.file);
17378        println!("  explain: {}", report.expand.explain);
17379        println!("  callers: {}", report.expand.callers);
17380        println!("  callees: {}", report.expand.callees);
17381        for warning in &report.warnings {
17382            eprintln!("warning: {warning}");
17383        }
17384    }
17385
17386    Ok(())
17387}
17388
17389#[allow(clippy::too_many_arguments)]
17390#[derive(Serialize)]
17391struct ExplainBudgetDefinitionPreview {
17392    handle: String,
17393    #[serde(skip_serializing_if = "Option::is_none")]
17394    tag_alias: Option<String>,
17395    kind: String,
17396    name: String,
17397    file: String,
17398    line: i64,
17399    expand: String,
17400}
17401
17402#[derive(Serialize)]
17403struct ExplainBudgetEdgePreview {
17404    handle: String,
17405    #[serde(skip_serializing_if = "Option::is_none")]
17406    tag_alias: Option<String>,
17407    name: String,
17408    file: String,
17409    line: i64,
17410    expand: String,
17411}
17412
17413#[derive(Serialize)]
17414struct ExplainBudgetCommunityPreview {
17415    size: usize,
17416    members: Vec<String>,
17417}
17418
17419#[derive(Serialize)]
17420struct ExplainBudgetReport {
17421    symbol: String,
17422    max_items: usize,
17423    max_bytes: usize,
17424    definition_total: usize,
17425    callers_total: usize,
17426    callers_truncated_by_limit: bool,
17427    callees_total: usize,
17428    callees_truncated_by_limit: bool,
17429    truncated: bool,
17430    definitions: Vec<ExplainBudgetDefinitionPreview>,
17431    callers: Vec<ExplainBudgetEdgePreview>,
17432    callees: Vec<ExplainBudgetEdgePreview>,
17433    #[serde(skip_serializing_if = "Option::is_none")]
17434    community: Option<ExplainBudgetCommunityPreview>,
17435}
17436
17437#[allow(clippy::too_many_arguments)]
17438pub(crate) fn build_explain_budget_report(
17439    symbol: &str,
17440    _root: &Path,
17441    symbols: &[index::StoredSymbol],
17442    callers: &[index::StoredEdge],
17443    callers_total: usize,
17444    callers_truncated_by_limit: bool,
17445    callees: &[index::StoredEdge],
17446    callees_total: usize,
17447    callees_truncated_by_limit: bool,
17448    community: Option<&graph::Community>,
17449    budget: ResponseBudget,
17450) -> ExplainBudgetReport {
17451    let max_items = budget.preview_items();
17452    let max_bytes = budget.preview_bytes();
17453    let definitions = symbols
17454        .iter()
17455        .take(max_items)
17456        .map(|entry| {
17457            let symbol_ref = build_compact_symbol_ref(
17458                "edef",
17459                &format!(
17460                    "{}:{}:{}:{}",
17461                    entry.kind, entry.name, entry.file, entry.line
17462                ),
17463                &entry.name,
17464                entry.tags.as_deref(),
17465                max_bytes,
17466            );
17467            ExplainBudgetDefinitionPreview {
17468                handle: symbol_ref.handle,
17469                tag_alias: symbol_ref.tag_alias,
17470                kind: entry.kind.clone(),
17471                name: symbol_ref.name,
17472                file: truncate_for_budget(&entry.file, max_bytes),
17473                line: entry.line,
17474                expand: format!(
17475                    "tsift search {} --exact --path {} --limit 20",
17476                    shell_quote(&entry.name),
17477                    shell_quote(&entry.file)
17478                ),
17479            }
17480        })
17481        .collect();
17482    let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
17483        .iter()
17484        .take(max_items)
17485        .map(|entry| {
17486            let symbol_ref = build_compact_symbol_ref(
17487                "ecall",
17488                &format!(
17489                    "{}:{}:{}:{}",
17490                    entry.caller_name, entry.caller_file, entry.call_site_line, symbol
17491                ),
17492                &entry.caller_name,
17493                None,
17494                max_bytes,
17495            );
17496            ExplainBudgetEdgePreview {
17497                handle: symbol_ref.handle,
17498                tag_alias: symbol_ref.tag_alias,
17499                name: symbol_ref.name,
17500                file: truncate_for_budget(&entry.caller_file, max_bytes),
17501                line: entry.call_site_line,
17502                expand: format!(
17503                    "tsift explain {} --path {} --limit 0",
17504                    shell_quote(&entry.caller_name),
17505                    shell_quote(&entry.caller_file)
17506                ),
17507            }
17508        })
17509        .collect();
17510    let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
17511        .iter()
17512        .take(max_items)
17513        .map(|entry| {
17514            let symbol_ref = build_compact_symbol_ref(
17515                "eces",
17516                &format!(
17517                    "{}:{}:{}:{}",
17518                    entry.callee_name, entry.caller_file, entry.call_site_line, symbol
17519                ),
17520                &entry.callee_name,
17521                None,
17522                max_bytes,
17523            );
17524            ExplainBudgetEdgePreview {
17525                handle: symbol_ref.handle,
17526                tag_alias: symbol_ref.tag_alias,
17527                name: symbol_ref.name,
17528                file: truncate_for_budget(&entry.caller_file, max_bytes),
17529                line: entry.call_site_line,
17530                expand: format!(
17531                    "tsift explain {} --path {} --limit 0",
17532                    shell_quote(&entry.callee_name),
17533                    shell_quote(&entry.caller_file)
17534                ),
17535            }
17536        })
17537        .collect();
17538    let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
17539        size: entry.members.len(),
17540        members: entry
17541            .members
17542            .iter()
17543            .take(max_items)
17544            .map(|member| truncate_for_budget(&member.name, max_bytes))
17545            .collect(),
17546    });
17547
17548    ExplainBudgetReport {
17549        symbol: symbol.to_string(),
17550        max_items,
17551        max_bytes,
17552        definition_total: symbols.len(),
17553        callers_total,
17554        callers_truncated_by_limit,
17555        callees_total,
17556        callees_truncated_by_limit,
17557        truncated: symbols.len() > max_items
17558            || callers_total > callers_preview.len()
17559            || callees_total > callees_preview.len()
17560            || community
17561                .map(|entry| entry.members.len() > max_items)
17562                .unwrap_or(false),
17563        definitions,
17564        callers: callers_preview,
17565        callees: callees_preview,
17566        community: community_preview,
17567    }
17568}
17569
17570pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
17571    println!(
17572        "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
17573        shell_quote(&report.symbol),
17574        report.definitions.len(),
17575        report.definition_total,
17576        report.callers.len(),
17577        report.callers_total,
17578        report.callees.len(),
17579        report.callees_total
17580    );
17581    for entry in &report.definitions {
17582        println!(
17583            "def {} {} {}:{} expand:{}",
17584            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17585            entry.kind,
17586            entry.file,
17587            entry.line,
17588            entry.expand
17589        );
17590    }
17591    for entry in &report.callers {
17592        println!(
17593            "caller {} {}:{} expand:{}",
17594            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17595            entry.file,
17596            entry.line,
17597            entry.expand
17598        );
17599    }
17600    for entry in &report.callees {
17601        println!(
17602            "callee {} {}:{} expand:{}",
17603            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17604            entry.file,
17605            entry.line,
17606            entry.expand
17607        );
17608    }
17609    if let Some(community) = &report.community {
17610        println!(
17611            "community size:{} members:{}",
17612            community.size,
17613            community.members.join(", ")
17614        );
17615    }
17616    if report.truncated {
17617        println!(
17618            "budget truncated items:{} bytes:{}",
17619            report.max_items, report.max_bytes
17620        );
17621    }
17622}
17623
17624/// Reconcile the tsift symbol index against the tagpath `.naming/index.json`
17625/// source set and report files covered by one but not the other.
17626///
17627/// Today silent recall loss happens when tagpath's `[exclude]` / `extends`
17628/// chain or its hard-coded `SKIP_DIRS` skip files or languages that tsift
17629/// still indexes — the tsift symbols in those files cannot resolve a
17630/// `tagpath_handle` even with a fresh tagpath index. This audit surfaces
17631/// the diff so operators can decide whether to broaden the tagpath walk,
17632/// add an `[exclude]` to tsift, or accept the gap.
17633const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
17634    ".git",
17635    "node_modules",
17636    "target",
17637    "__pycache__",
17638    ".venv",
17639    "vendor",
17640];
17641
17642const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
17643    "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
17644    "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
17645    "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
17646    "sh", "bash", "zsh", "sql", "css", "tsx",
17647];
17648
17649pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
17650    let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
17651        .iter()
17652        .map(|ext| (*ext).to_string())
17653        .collect::<BTreeSet<_>>();
17654
17655    let config_path = root.join(".naming.toml");
17656    if !config_path.exists() {
17657        return extensions;
17658    }
17659
17660    match tagpath::config::resolve(&config_path) {
17661        Ok(config) => {
17662            if let Some(grammars) = config.grammars {
17663                for grammar in grammars.languages.values() {
17664                    for ext in &grammar.extensions {
17665                        if let Some(normalized) = normalize_extension(ext) {
17666                            extensions.insert(normalized);
17667                        }
17668                    }
17669                }
17670            }
17671        }
17672        Err(err) => {
17673            eprintln!("tagpath_policy_hint_config_unreadable: {err}");
17674        }
17675    }
17676    extensions
17677}
17678
17679pub(crate) fn tagpath_audit_policy_hints(
17680    rel_path: &str,
17681    supported_extensions: &BTreeSet<String>,
17682) -> Vec<String> {
17683    let path = Path::new(rel_path);
17684    let mut hints = BTreeSet::new();
17685    if let Some(parent) = path.parent() {
17686        for component in parent.components() {
17687            if let std::path::Component::Normal(name) = component {
17688                let name = name.to_string_lossy();
17689                if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
17690                    hints.insert(format!("skip_dir:{name}"));
17691                }
17692            }
17693        }
17694    }
17695    if path
17696        .extension()
17697        .and_then(|ext| ext.to_str())
17698        .and_then(normalize_extension)
17699        .is_some_and(|ext| !supported_extensions.contains(&ext))
17700    {
17701        hints.insert("extension_unsupported".to_string());
17702    }
17703    hints.into_iter().collect()
17704}
17705
17706fn normalize_extension(ext: &str) -> Option<String> {
17707    let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
17708    if normalized.is_empty() {
17709        None
17710    } else {
17711        Some(normalized)
17712    }
17713}
17714
17715pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
17716    match status {
17717        diff_digest::DiffDigestFileStatus::Added => "added",
17718        diff_digest::DiffDigestFileStatus::Modified => "modified",
17719        diff_digest::DiffDigestFileStatus::Deleted => "deleted",
17720    }
17721}
17722
17723pub(crate) fn diff_digest_summary_label(
17724    state: diff_digest::DiffDigestSummaryState,
17725) -> &'static str {
17726    match state {
17727        diff_digest::DiffDigestSummaryState::Current => "current",
17728        diff_digest::DiffDigestSummaryState::Stale => "stale",
17729        diff_digest::DiffDigestSummaryState::Missing => "missing",
17730        diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
17731    }
17732}
17733
17734fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
17735    match state {
17736        test_digest::TestDigestSummaryState::Current => "current",
17737        test_digest::TestDigestSummaryState::Stale => "stale",
17738        test_digest::TestDigestSummaryState::Missing => "missing",
17739        test_digest::TestDigestSummaryState::Unavailable => "unavailable",
17740    }
17741}
17742
17743fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
17744    match state {
17745        log_digest::LogDigestSummaryState::Current => "current",
17746        log_digest::LogDigestSummaryState::Stale => "stale",
17747        log_digest::LogDigestSummaryState::Missing => "missing",
17748        log_digest::LogDigestSummaryState::Unavailable => "unavailable",
17749    }
17750}
17751
17752pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
17753    match mode {
17754        diff_digest::DiffDigestMode::WorkingTree => "worktree",
17755        diff_digest::DiffDigestMode::Cached => "cached",
17756        diff_digest::DiffDigestMode::Revision => "revision",
17757    }
17758}
17759
17760pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
17761    match (&report.mode, &report.revision) {
17762        (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
17763        (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
17764        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17765            format!("revision {revision}")
17766        }
17767        (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
17768    }
17769}
17770
17771pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
17772    match (&report.mode, &report.revision) {
17773        (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
17774        (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
17775        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17776            format!("No diff found for revision {revision}.")
17777        }
17778        (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
17779    }
17780}
17781
17782fn cmd_impact(
17783    path: &Path,
17784    cached: bool,
17785    revision: Option<&str>,
17786    scope: Option<&str>,
17787    limit: usize,
17788    format: OutputFormat,
17789) -> Result<()> {
17790    let report = impact::compute(
17791        path,
17792        impact::ImpactOptions {
17793            cached,
17794            revision,
17795            scope,
17796            limit,
17797        },
17798    )?;
17799    if format.json_output {
17800        println!(
17801            "{}",
17802            to_json_schema(
17803                &report,
17804                format.pretty,
17805                format.terse,
17806                format.ultra_terse,
17807                format.schema
17808            )?
17809        );
17810        return Ok(());
17811    }
17812
17813    if format.compact {
17814        println!(
17815            "impact mode:{} changed:{} symbols:{} tests:{}/{}",
17816            diff_digest_mode_label(report.mode),
17817            report.changed_files.len(),
17818            report.changed_symbols.len(),
17819            report.affected_tests.len(),
17820            report.affected_tests_total
17821        );
17822        for target in &report.affected_tests {
17823            println!(
17824                "{} reasons:{} command:{}",
17825                target.path,
17826                target.reasons.len(),
17827                target.commands.join(" && ")
17828            );
17829        }
17830        for warning in &report.warnings {
17831            println!("warning {warning}");
17832        }
17833        return Ok(());
17834    }
17835
17836    println!("Impact ({})", diff_digest_mode_label(report.mode));
17837    println!("  changed files:          {}", report.changed_files.len());
17838    println!("  changed symbols:        {}", report.changed_symbols.len());
17839    println!(
17840        "  affected tests:         {}/{}",
17841        report.affected_tests.len(),
17842        report.affected_tests_total
17843    );
17844    for target in &report.affected_tests {
17845        println!();
17846        println!("{}", target.path);
17847        for reason in &target.reasons {
17848            println!("  - {reason}");
17849        }
17850        if !target.symbols.is_empty() {
17851            println!("  symbols: {}", target.symbols.join(", "));
17852        }
17853        for command in &target.commands {
17854            println!("  run: {}", command);
17855        }
17856    }
17857    for warning in &report.warnings {
17858        println!("warning: {warning}");
17859    }
17860    Ok(())
17861}
17862
17863pub(crate) fn render_test_digest_from_input(
17864    path: &Path,
17865    input: &str,
17866    runner: Option<&str>,
17867    format: OutputFormat,
17868) -> Result<()> {
17869    let report = test_digest::compute(path, input, runner)?;
17870    if format.json_output {
17871        println!(
17872            "{}",
17873            to_json_schema(
17874                &report,
17875                format.pretty,
17876                format.terse,
17877                format.ultra_terse,
17878                format.schema
17879            )?
17880        );
17881        return Ok(());
17882    }
17883
17884    if report.failure_groups.is_empty() {
17885        println!("No failures detected (runner: {}).", report.runner);
17886        for warning in &report.warnings {
17887            println!("warning: {warning}");
17888        }
17889        return Ok(());
17890    }
17891
17892    if format.compact {
17893        println!(
17894            "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
17895            report.runner,
17896            report.failures,
17897            report.grouped_failures,
17898            report.counts.passed.unwrap_or(0),
17899            report.counts.failed.unwrap_or(report.grouped_failures),
17900            report.counts.skipped.unwrap_or(0),
17901        );
17902        for failure in &report.failure_groups {
17903            let tests = truncate_for_compact(&failure.tests.join(","), 60);
17904            let location = match (&failure.path, failure.line) {
17905                (Some(path), Some(line)) => format!("{path}:{line}"),
17906                (Some(path), None) => path.clone(),
17907                _ => "-".to_string(),
17908            };
17909            println!(
17910                "{} tests:{} count:{} summaries:{} msg:{}",
17911                location,
17912                tests,
17913                failure.occurrences,
17914                test_digest_summary_label(failure.summary_state),
17915                truncate_for_compact(&failure.message, 80)
17916            );
17917        }
17918        for warning in &report.warnings {
17919            println!("warning: {warning}");
17920        }
17921        return Ok(());
17922    }
17923
17924    println!("Test digest ({})", report.runner);
17925    println!("  failures:        {}", report.failures);
17926    println!("  failure groups:  {}", report.grouped_failures);
17927    if let Some(passed) = report.counts.passed {
17928        println!("  passed:          {}", passed);
17929    }
17930    if let Some(failed) = report.counts.failed {
17931        println!("  failed:          {}", failed);
17932    }
17933    if let Some(skipped) = report.counts.skipped {
17934        println!("  skipped:         {}", skipped);
17935    }
17936
17937    for failure in &report.failure_groups {
17938        println!();
17939        match (&failure.path, failure.line, failure.column) {
17940            (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
17941            (Some(path), Some(line), None) => println!("{path}:{line}"),
17942            (Some(path), None, _) => println!("{path}"),
17943            (None, _, _) => println!("(no file anchor)"),
17944        }
17945        println!("  tests: {}", failure.tests.join(", "));
17946        println!("  occurrences: {}", failure.occurrences);
17947        println!("  message: {}", failure.message);
17948        println!(
17949            "  cached summaries: {}",
17950            test_digest_summary_label(failure.summary_state)
17951        );
17952        for summary in &failure.current_summaries {
17953            println!(
17954                "    - {}: {}",
17955                summary.symbol,
17956                truncate_for_compact(&summary.summary, 160)
17957            );
17958        }
17959    }
17960    for warning in &report.warnings {
17961        println!("warning: {warning}");
17962    }
17963    Ok(())
17964}
17965
17966#[derive(Clone, Serialize, Deserialize)]
17967struct DispatchTraceSummary {
17968    backlog: usize,
17969    job_packet: usize,
17970    worker_result: usize,
17971    worker_context: usize,
17972    source_handle: usize,
17973    semantic_rows: usize,
17974}
17975
17976#[derive(Clone, Serialize, Deserialize)]
17977struct DispatchTraceReport {
17978    contract_version: String,
17979    root: String,
17980    #[serde(skip_serializing_if = "Option::is_none")]
17981    scope: Option<String>,
17982    targets: Vec<String>,
17983    projection_freshness: GraphDbFreshnessReport,
17984    projection_hashes: Vec<String>,
17985    evidence_packet_ids: Vec<String>,
17986    shared_preparation: ConflictMatrixSharedPreparationSummary,
17987    worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
17988    worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
17989    summary: DispatchTraceSummary,
17990    nodes: Vec<SubstrateTerseGraphNode>,
17991    edges: Vec<SubstrateTerseGraphEdge>,
17992    conflict_matrix_decisions: Vec<String>,
17993    replay_commands: Vec<String>,
17994    repair_commands: Vec<String>,
17995    truncated: bool,
17996    #[serde(skip_serializing_if = "Vec::is_empty", default)]
17997    warnings: Vec<String>,
17998}
17999
18000fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18001    matches!(
18002        kind,
18003        "session"
18004            | "backlog"
18005            | "job_packet"
18006            | "worker_result"
18007            | "worker_context"
18008            | "source_handle"
18009            | "semantic_concept"
18010            | "semantic_entity"
18011            | "file"
18012            | "symbol"
18013            | "route"
18014    )
18015}
18016
18017fn dispatch_trace_kind_rank(kind: &str) -> usize {
18018    match kind {
18019        "backlog" => 0,
18020        "job_packet" => 1,
18021        "worker_result" => 2,
18022        "worker_context" => 3,
18023        "source_handle" => 4,
18024        "file" => 5,
18025        "symbol" => 6,
18026        "route" => 7,
18027        "semantic_concept" => 8,
18028        "semantic_entity" => 9,
18029        "session" => 10,
18030        _ => 99,
18031    }
18032}
18033
18034fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18035    DispatchTraceSummary {
18036        backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18037        job_packet: nodes
18038            .iter()
18039            .filter(|node| node.kind == "job_packet")
18040            .count(),
18041        worker_result: nodes
18042            .iter()
18043            .filter(|node| node.kind == "worker_result")
18044            .count(),
18045        worker_context: nodes
18046            .iter()
18047            .filter(|node| node.kind == "worker_context")
18048            .count(),
18049        source_handle: nodes
18050            .iter()
18051            .filter(|node| node.kind == "source_handle")
18052            .count(),
18053        semantic_rows: nodes
18054            .iter()
18055            .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18056            .count(),
18057    }
18058}
18059
18060fn dispatch_trace_shared_preparation_summary(
18061    graph_nodes: &[SubstrateGraphNode],
18062    graph_edges: &[SubstrateGraphEdge],
18063    conflict: &ConflictMatrixReport,
18064) -> ConflictMatrixSharedPreparationSummary {
18065    ConflictMatrixSharedPreparationSummary {
18066        evidence_cache_status: conflict
18067            .inputs
18068            .shared_preparation
18069            .evidence_cache_status
18070            .clone(),
18071        graph_nodes: graph_nodes.len(),
18072        graph_edges: graph_edges.len(),
18073        evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18074        source_handles: conflict
18075            .candidates
18076            .iter()
18077            .map(|candidate| candidate.source_handles.len())
18078            .sum(),
18079        worker_context: conflict
18080            .candidates
18081            .iter()
18082            .map(|candidate| candidate.worker_context_handles.len())
18083            .sum(),
18084        worker_results: conflict
18085            .candidates
18086            .iter()
18087            .map(|candidate| candidate.worker_feedback.total)
18088            .sum(),
18089        semantic_rows: conflict
18090            .candidates
18091            .iter()
18092            .map(|candidate| candidate.semantic_related.len())
18093            .sum(),
18094        dispatch_trace_snapshot_nodes: graph_nodes.len(),
18095        dispatch_trace_snapshot_edges: graph_edges.len(),
18096    }
18097}
18098
18099fn dispatch_trace_collect_ids(
18100    targets: &[String],
18101    candidates: &[ConflictMatrixCandidate],
18102    graph_nodes: &[SubstrateGraphNode],
18103    graph_edges: &[SubstrateGraphEdge],
18104    depth: usize,
18105    limit: usize,
18106) -> (BTreeSet<String>, bool) {
18107    let target_refs = targets
18108        .iter()
18109        .map(|target| target.trim_start_matches('#').to_string())
18110        .collect::<BTreeSet<_>>();
18111    let mut ids = BTreeSet::new();
18112    for candidate in candidates {
18113        ids.insert(candidate.target_node_id.clone());
18114        for source in &candidate.source_handles {
18115            ids.insert(source.handle.clone());
18116        }
18117        for handle in &candidate.worker_context_handles {
18118            ids.insert(handle.clone());
18119        }
18120        for semantic in &candidate.semantic_related {
18121            ids.insert(semantic.handle.clone());
18122        }
18123    }
18124    for node in graph_nodes {
18125        if !dispatch_trace_allowed_node_kind(&node.kind) {
18126            continue;
18127        }
18128        if node
18129            .properties
18130            .get("ref_id")
18131            .is_some_and(|ref_id| target_refs.contains(ref_id))
18132        {
18133            ids.insert(node.id.clone());
18134        }
18135    }
18136
18137    let node_by_id = graph_nodes
18138        .iter()
18139        .map(|node| (node.id.as_str(), node))
18140        .collect::<BTreeMap<_, _>>();
18141    let max_nodes = if limit == 0 {
18142        usize::MAX
18143    } else {
18144        limit
18145            .saturating_mul(targets.len().max(1))
18146            .saturating_mul(12)
18147            .max(64)
18148    };
18149    let mut truncated = false;
18150    for _ in 0..depth.max(1) {
18151        let before = ids.len();
18152        let current_ids = ids.clone();
18153        for edge in graph_edges {
18154            if ids.len() >= max_nodes {
18155                truncated = true;
18156                break;
18157            }
18158            let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18159            if !touches {
18160                continue;
18161            }
18162            for endpoint in [&edge.from_id, &edge.to_id] {
18163                let Some(node) = node_by_id.get(endpoint.as_str()) else {
18164                    continue;
18165                };
18166                if dispatch_trace_allowed_node_kind(&node.kind) {
18167                    ids.insert(endpoint.clone());
18168                }
18169            }
18170        }
18171        if ids.len() == before || truncated {
18172            break;
18173        }
18174    }
18175    (ids, truncated)
18176}
18177
18178#[allow(clippy::too_many_arguments)]
18179fn build_dispatch_trace_report_from_conflict_snapshot(
18180    root: &Path,
18181    scope: Option<&str>,
18182    conflict: ConflictMatrixReport,
18183    graph_nodes: Vec<SubstrateGraphNode>,
18184    graph_edges: Vec<SubstrateGraphEdge>,
18185    depth: usize,
18186    limit: usize,
18187    extra_warnings: Vec<String>,
18188) -> Result<DispatchTraceReport> {
18189    let shared_preparation =
18190        dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18191    let (ids, truncated) = dispatch_trace_collect_ids(
18192        &conflict.targets,
18193        &conflict.candidates,
18194        &graph_nodes,
18195        &graph_edges,
18196        depth,
18197        limit,
18198    );
18199    let mut nodes = graph_nodes
18200        .into_iter()
18201        .filter(|node| ids.contains(&node.id))
18202        .collect::<Vec<_>>();
18203    nodes.sort_by(|left, right| {
18204        dispatch_trace_kind_rank(&left.kind)
18205            .cmp(&dispatch_trace_kind_rank(&right.kind))
18206            .then(left.id.cmp(&right.id))
18207    });
18208    let node_ids = nodes
18209        .iter()
18210        .map(|node| node.id.as_str())
18211        .collect::<BTreeSet<_>>();
18212    let mut edges = graph_edges
18213        .into_iter()
18214        .filter(|edge| {
18215            node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18216        })
18217        .collect::<Vec<_>>();
18218    edges.sort_by(|left, right| {
18219        left.from_id
18220            .cmp(&right.from_id)
18221            .then(left.kind.cmp(&right.kind))
18222            .then(left.to_id.cmp(&right.to_id))
18223    });
18224    let mut warnings = conflict.warnings;
18225    warnings.extend(extra_warnings);
18226
18227    Ok(DispatchTraceReport {
18228        contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
18229        root: conflict.root,
18230        scope: conflict.scope,
18231        targets: conflict.targets,
18232        projection_freshness: conflict.orchestration.projection_freshness,
18233        projection_hashes: conflict.orchestration.projection_hashes,
18234        evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
18235        shared_preparation,
18236        worker_prompt_packets: conflict.worker_prompt_packets,
18237        worker_feedback: conflict
18238            .candidates
18239            .iter()
18240            .map(|candidate| candidate.worker_feedback.clone())
18241            .collect(),
18242        summary: dispatch_trace_summary(&nodes),
18243        nodes: nodes.into_iter().map(Into::into).collect(),
18244        edges: edges.into_iter().map(Into::into).collect(),
18245        conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
18246        replay_commands: conflict.next_commands,
18247        repair_commands: graph_db_repair_commands(root, scope),
18248        truncated,
18249        warnings,
18250    })
18251}
18252
18253fn build_dispatch_trace_report(
18254    path: &Path,
18255    scope: Option<&str>,
18256    raw_targets: &[String],
18257    depth: usize,
18258    limit: usize,
18259    impact_limit: usize,
18260) -> Result<DispatchTraceReport> {
18261    let root = lint::resolve_project_root_or_canonical_path(path)?;
18262    let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
18263    if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
18264        write_traversal_graph_store(&root, path, scope)
18265            .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
18266    }
18267    let graph_db = graph_substrate_db_path(&root, scope);
18268    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
18269        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
18270    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
18271    let extra_warnings = store
18272        .read_only_recovery()
18273        .map(graph_db_read_recovery_diagnostic)
18274        .into_iter()
18275        .collect::<Vec<_>>();
18276    let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
18277    let graph_prepared = prepare_conflict_matrix_graph_orchestration(
18278        &root,
18279        scope,
18280        "sqlite",
18281        raw_targets,
18282        &prepared,
18283        depth,
18284        limit,
18285        &store,
18286        freshness.clone(),
18287    )?;
18288    let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
18289        &prepared.preparation_cache.source_watermark,
18290        &prepared.preparation_cache.document_watermark,
18291        &prepared.preparation_cache.staged_diff_watermark,
18292        &[
18293            &format!("targets:{}", raw_targets.join(",")),
18294            &format!("depth:{depth}"),
18295            &format!("limit:{limit}"),
18296        ],
18297    );
18298    if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
18299        &root,
18300        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18301        &dt_cache_key,
18302    ) {
18303        return Ok(cached_report);
18304    }
18305    let conflict = build_conflict_matrix_report_from_prepared_graph(
18306        &root,
18307        path,
18308        scope,
18309        depth,
18310        limit,
18311        impact_limit,
18312        freshness,
18313        extra_warnings.clone(),
18314        &prepared,
18315        &graph_prepared,
18316    )?;
18317    let report = build_dispatch_trace_report_from_conflict_snapshot(
18318        &root,
18319        scope,
18320        conflict,
18321        graph_prepared.graph.nodes,
18322        graph_prepared.graph.edges,
18323        depth,
18324        limit,
18325        extra_warnings,
18326    )?;
18327    cycle_packet_cache::cycle_packet_write_cache(
18328        &root,
18329        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18330        &dt_cache_key,
18331        &report,
18332    );
18333    Ok(report)
18334}
18335
18336fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
18337    let json = serde_json::to_string(report)?.replace("</", "<\\/");
18338    let mut html = String::new();
18339    html.push_str(
18340        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
18341    );
18342    html.push_str(
18343        r#"<style>
18344:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
18345@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
18346*{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}}
18347</style>"#,
18348    );
18349    html.push_str("</head><body><div class=\"page\">");
18350    html.push_str(&format!(
18351        "<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>",
18352        html_escape(&report.targets.join(", ")),
18353        report.evidence_packet_ids.len(),
18354        report.nodes.len(),
18355        report.worker_prompt_packets.len(),
18356        html_escape(&report.contract_version)
18357    ));
18358    html.push_str(
18359        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>"#,
18360    );
18361    html.push_str("<script id=\"trace-data\" type=\"application/json\">");
18362    html.push_str(&json);
18363    html.push_str(
18364        r##"</script><script>
18365const report = JSON.parse(document.getElementById("trace-data").textContent);
18366const svg = document.getElementById("graph-canvas");
18367const nodeList = document.getElementById("nodes");
18368const packets = document.getElementById("packets");
18369const feedback = document.getElementById("feedback");
18370const nodes = report.nodes.map((node, index) => ({...node, index}));
18371const nodeById = new Map(nodes.map(node => [node.id, node]));
18372const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
18373const 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"]]);
18374function color(kind){return colorByKind.get(kind)||"#6b7280";}
18375function text(value){return value == null ? "" : String(value);}
18376function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));}
18377function layout(){
18378  const rect = svg.getBoundingClientRect();
18379  const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
18380  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
18381  const counts = new Map();
18382  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
18383  const offsets = new Map();
18384  for (const node of nodes) {
18385    const group = kinds.indexOf(node.kind);
18386    const index = offsets.get(node.kind) || 0;
18387    offsets.set(node.kind, index + 1);
18388    const total = counts.get(node.kind) || 1;
18389    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
18390    const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
18391    node.x = cx + Math.cos(angle) * ring;
18392    node.y = cy + Math.sin(angle) * ring;
18393  }
18394}
18395function draw(){
18396  svg.innerHTML = "";
18397  for (const edge of edges) {
18398    const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
18399    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
18400    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
18401    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
18402    line.setAttribute("class", "edge");
18403    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
18404    svg.appendChild(line);
18405  }
18406  for (const node of nodes) {
18407    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
18408    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
18409    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
18410    circle.setAttribute("fill", color(node.kind));
18411    circle.setAttribute("class", "node");
18412    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
18413    svg.appendChild(circle);
18414    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
18415    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
18416    label.setAttribute("class", "node-label");
18417    label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
18418    svg.appendChild(label);
18419  }
18420}
18421packets.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>";
18422feedback.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>";
18423nodeList.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("");
18424window.addEventListener("resize", () => { layout(); draw(); });
18425layout(); draw();
18426</script></div></body></html>"##,
18427    );
18428    Ok(html)
18429}
18430
18431struct DispatchTraceOptions<'a> {
18432    path: &'a Path,
18433    scope: Option<&'a str>,
18434    raw_targets: &'a [String],
18435    depth: usize,
18436    limit: usize,
18437    impact_limit: usize,
18438    trace_format: DispatchTraceFormat,
18439}
18440
18441fn cmd_dispatch_trace(
18442    options: DispatchTraceOptions<'_>,
18443    output_format: OutputFormat,
18444) -> Result<()> {
18445    let report = build_dispatch_trace_report(
18446        options.path,
18447        options.scope,
18448        options.raw_targets,
18449        options.depth,
18450        options.limit,
18451        options.impact_limit,
18452    )?;
18453    match options.trace_format {
18454        DispatchTraceFormat::Json => {
18455            if output_format.envelope {
18456                print_json_or_envelope(
18457                    &report,
18458                    &output_format,
18459                    "dispatch-trace",
18460                    "operator-review",
18461                    ToolEnvelopeSummary {
18462                        text: format!(
18463                            "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
18464                            report.targets.len(),
18465                            report.nodes.len(),
18466                            report.worker_prompt_packets.len()
18467                        ),
18468                        metrics: vec![
18469                            envelope_metric("targets", report.targets.len()),
18470                            envelope_metric("nodes", report.nodes.len()),
18471                            envelope_metric("edges", report.edges.len()),
18472                            envelope_metric(
18473                                "worker_prompt_packets",
18474                                report.worker_prompt_packets.len(),
18475                            ),
18476                        ],
18477                    },
18478                    report.truncated,
18479                    report.replay_commands.clone(),
18480                )
18481            } else {
18482                println!(
18483                    "{}",
18484                    to_json_schema(
18485                        &report,
18486                        output_format.pretty,
18487                        output_format.terse,
18488                        output_format.ultra_terse,
18489                        output_format.schema
18490                    )?
18491                );
18492                Ok(())
18493            }
18494        }
18495        DispatchTraceFormat::Html => {
18496            println!("{}", dispatch_trace_html(&report)?);
18497            Ok(())
18498        }
18499    }
18500}
18501
18502#[derive(Clone, Debug)]
18503struct DependencyDagProfile {
18504    id: String,
18505    graph_node_id: String,
18506    label: String,
18507    path: Option<String>,
18508    line: Option<i64>,
18509    detail: Option<String>,
18510    source_files: BTreeSet<String>,
18511    source_symbols: BTreeSet<String>,
18512    config_files: BTreeSet<String>,
18513    expected_tests: BTreeSet<String>,
18514    semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
18515    worker_feedback: ConflictMatrixWorkerFeedback,
18516}
18517
18518#[derive(Clone, Debug, Serialize)]
18519struct DependencyDagNode {
18520    id: String,
18521    graph_node_id: String,
18522    label: String,
18523    #[serde(skip_serializing_if = "Option::is_none")]
18524    path: Option<String>,
18525    #[serde(skip_serializing_if = "Option::is_none")]
18526    line: Option<i64>,
18527    #[serde(skip_serializing_if = "Option::is_none")]
18528    detail: Option<String>,
18529    source_files: Vec<String>,
18530    source_symbols: Vec<String>,
18531    config_files: Vec<String>,
18532    expected_tests: Vec<String>,
18533    semantic_refs: Vec<ConflictMatrixSemanticRef>,
18534    worker_feedback: ConflictMatrixWorkerFeedback,
18535}
18536
18537#[derive(Clone, Debug, Serialize)]
18538struct DependencyDagEdge {
18539    from: String,
18540    to: String,
18541    kind: String,
18542    weight: usize,
18543    reasons: Vec<String>,
18544    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18545    shared_files: Vec<String>,
18546    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18547    shared_symbols: Vec<String>,
18548    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18549    shared_tests: Vec<String>,
18550    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18551    shared_config_files: Vec<String>,
18552    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18553    shared_semantic_refs: Vec<String>,
18554}
18555
18556#[derive(Clone, Debug, Serialize)]
18557struct DependencyDagTopoBatch {
18558    batch: usize,
18559    targets: Vec<String>,
18560}
18561
18562#[derive(Clone, Debug, Serialize)]
18563struct DependencyDagCycleDiagnostics {
18564    has_cycles: bool,
18565    blocked_nodes: Vec<String>,
18566    cycle_edges: Vec<DependencyDagEdge>,
18567}
18568
18569#[derive(Serialize)]
18570struct DependencyDagSummary {
18571    nodes: usize,
18572    edges: usize,
18573    topo_batches: usize,
18574    has_cycles: bool,
18575}
18576
18577#[derive(Serialize)]
18578struct DependencyDagReport {
18579    contract_version: &'static str,
18580    root: String,
18581    #[serde(skip_serializing_if = "Option::is_none")]
18582    scope: Option<String>,
18583    path: String,
18584    targets: Vec<String>,
18585    projection_freshness: GraphDbFreshnessReport,
18586    projection_hashes: Vec<String>,
18587    nodes: Vec<DependencyDagNode>,
18588    edges: Vec<DependencyDagEdge>,
18589    topo_batches: Vec<DependencyDagTopoBatch>,
18590    cycle_diagnostics: DependencyDagCycleDiagnostics,
18591    summary: DependencyDagSummary,
18592    replay_commands: Vec<String>,
18593    repair_commands: Vec<String>,
18594    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18595    warnings: Vec<String>,
18596}
18597
18598fn dependency_dag_backlog_node_for_target(
18599    store: &impl GraphStore,
18600    target: &str,
18601) -> Result<SubstrateGraphNode> {
18602    let resolved = graph_db_resolve_evidence_target(store, target)?
18603        .with_context(|| format!("dependency-dag target not found: {target}"))?;
18604    if resolved.kind == "backlog" {
18605        return Ok(resolved);
18606    }
18607    let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
18608        bail!(
18609            "dependency-dag target {} resolved to {} without a backlog ref_id",
18610            target,
18611            resolved.kind
18612        );
18613    };
18614    store
18615        .nodes_by_kind("backlog")?
18616        .into_iter()
18617        .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
18618        .min_by(|left, right| {
18619            left.properties
18620                .get("line")
18621                .and_then(|value| value.parse::<i64>().ok())
18622                .cmp(
18623                    &right
18624                        .properties
18625                        .get("line")
18626                        .and_then(|value| value.parse::<i64>().ok()),
18627                )
18628                .then(left.id.cmp(&right.id))
18629        })
18630        .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
18631}
18632
18633fn dependency_dag_resolve_backlog_nodes(
18634    root: &Path,
18635    path: &Path,
18636    store: &impl GraphStore,
18637    raw_targets: &[String],
18638) -> Result<Vec<SubstrateGraphNode>> {
18639    let mut nodes = Vec::new();
18640    let mut seen = BTreeSet::new();
18641    if raw_targets.is_empty() {
18642        let hinted_path = if path.is_absolute() {
18643            path.to_path_buf()
18644        } else {
18645            root.join(path)
18646        };
18647        let hinted_markdown = hinted_path
18648            .extension()
18649            .and_then(|ext| ext.to_str())
18650            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
18651        let hinted_rel = hinted_markdown.then(|| {
18652            relativize_pathbuf(&hinted_path, root)
18653                .to_string_lossy()
18654                .replace('\\', "/")
18655        });
18656        for node in store.nodes_by_kind("backlog")? {
18657            if let Some(expected_path) = &hinted_rel
18658                && node.properties.get("path") != Some(expected_path)
18659            {
18660                continue;
18661            }
18662            if seen.insert(node.id.clone()) {
18663                nodes.push(node);
18664            }
18665        }
18666        if nodes.is_empty() && hinted_rel.is_some() {
18667            for node in store.nodes_by_kind("backlog")? {
18668                if seen.insert(node.id.clone()) {
18669                    nodes.push(node);
18670                }
18671            }
18672        }
18673    } else {
18674        for target in raw_targets {
18675            let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
18676            let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
18677            if seen.insert(node.id.clone()) {
18678                nodes.push(node);
18679            }
18680        }
18681    }
18682    if nodes.is_empty() {
18683        bail!("dependency-dag needs at least one resolvable backlog id");
18684    }
18685    nodes.sort_by(|left, right| {
18686        left.properties
18687            .get("line")
18688            .and_then(|value| value.parse::<i64>().ok())
18689            .cmp(
18690                &right
18691                    .properties
18692                    .get("line")
18693                    .and_then(|value| value.parse::<i64>().ok()),
18694            )
18695            .then(left.id.cmp(&right.id))
18696    });
18697    Ok(nodes)
18698}
18699
18700fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
18701    node.properties
18702        .get("ref_id")
18703        .cloned()
18704        .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
18705}
18706
18707fn dependency_dag_node_profile(
18708    root: &Path,
18709    store: &impl GraphStore,
18710    node: &SubstrateGraphNode,
18711    graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
18712    graph_edges: &[SubstrateGraphEdge],
18713    depth: usize,
18714    limit: usize,
18715) -> Result<DependencyDagProfile> {
18716    let id = dependency_dag_node_id(node);
18717    let mut source_files = BTreeSet::new();
18718    let mut source_symbols = BTreeSet::new();
18719    for edge in graph_edges
18720        .iter()
18721        .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
18722    {
18723        let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
18724            continue;
18725        };
18726        match target.kind.as_str() {
18727            "file" | "route" => {
18728                if let Some(path) = target.properties.get("path") {
18729                    source_files.insert(path.clone());
18730                }
18731            }
18732            "symbol" => {
18733                source_symbols.insert(target.label.clone());
18734                if let Some(path) = target.properties.get("path") {
18735                    source_files.insert(path.clone());
18736                }
18737            }
18738            _ => {}
18739        }
18740    }
18741
18742    let max_rows = if limit == 0 { usize::MAX } else { limit };
18743    for (source, _) in
18744        graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
18745    {
18746        let terse: SubstrateTerseGraphNode = (&source).into();
18747        if let Some(handle) = conflict_matrix_source_handle(&terse) {
18748            source_files.insert(handle.file);
18749        }
18750    }
18751
18752    let worker_results = graph_nodes_by_id
18753        .values()
18754        .filter(|candidate| {
18755            candidate.kind == "worker_result"
18756                && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
18757        })
18758        .map(SubstrateTerseGraphNode::from)
18759        .collect::<Vec<_>>();
18760    let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
18761    let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
18762    let config_files = source_files
18763        .iter()
18764        .filter(|file| is_planner_config_path(file))
18765        .cloned()
18766        .collect();
18767
18768    let mut semantic_refs = BTreeMap::new();
18769    for kind in ["semantic_concept", "semantic_entity"] {
18770        for (semantic, _) in
18771            graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
18772        {
18773            let terse: SubstrateTerseGraphNode = (&semantic).into();
18774            let item = conflict_matrix_semantic_ref(root, &terse);
18775            semantic_refs
18776                .entry(format!("{}:{}", item.kind, item.label))
18777                .or_insert(item);
18778        }
18779    }
18780
18781    Ok(DependencyDagProfile {
18782        id,
18783        graph_node_id: node.id.clone(),
18784        label: node.label.clone(),
18785        path: node.properties.get("path").cloned(),
18786        line: node
18787            .properties
18788            .get("line")
18789            .and_then(|value| value.parse::<i64>().ok()),
18790        detail: node.properties.get("detail").cloned(),
18791        source_files,
18792        source_symbols,
18793        config_files,
18794        expected_tests,
18795        semantic_refs,
18796        worker_feedback,
18797    })
18798}
18799
18800fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
18801    let lower = text.to_ascii_lowercase();
18802    let mut refs = Vec::new();
18803    for marker in markers {
18804        let mut offset = 0usize;
18805        while let Some(pos) = lower[offset..].find(marker) {
18806            let start = offset + pos + marker.len();
18807            let segment = text[start..]
18808                .split(['\n', '.'])
18809                .next()
18810                .unwrap_or(&text[start..]);
18811            refs.extend(extract_conflict_target_refs(segment));
18812            offset = start;
18813        }
18814    }
18815    dedupe_preserve_order(refs)
18816}
18817
18818fn dependency_dag_push_edge(
18819    edges: &mut Vec<DependencyDagEdge>,
18820    seen: &mut BTreeSet<(String, String, String)>,
18821    edge: DependencyDagEdge,
18822) {
18823    if edge.from == edge.to {
18824        return;
18825    }
18826    if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
18827        edges.push(edge);
18828    }
18829}
18830
18831fn dependency_dag_explicit_edges(
18832    profiles: &[DependencyDagProfile],
18833    target_ids: &BTreeSet<String>,
18834    edges: &mut Vec<DependencyDagEdge>,
18835    seen: &mut BTreeSet<(String, String, String)>,
18836) {
18837    for profile in profiles {
18838        let detail = profile.detail.as_deref().unwrap_or_default();
18839        for dep in dependency_dag_marker_refs(
18840            detail,
18841            &[
18842                "depends on",
18843                "depends-on",
18844                "deps:",
18845                "after",
18846                "blocked by",
18847                "requires",
18848            ],
18849        ) {
18850            if target_ids.contains(&dep) {
18851                dependency_dag_push_edge(
18852                    edges,
18853                    seen,
18854                    DependencyDagEdge {
18855                        from: dep.clone(),
18856                        to: profile.id.clone(),
18857                        kind: "explicit_depends_on".to_string(),
18858                        weight: 1000,
18859                        reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
18860                        shared_files: Vec::new(),
18861                        shared_symbols: Vec::new(),
18862                        shared_tests: Vec::new(),
18863                        shared_config_files: Vec::new(),
18864                        shared_semantic_refs: Vec::new(),
18865                    },
18866                );
18867            }
18868        }
18869        for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
18870            if target_ids.contains(&downstream) {
18871                dependency_dag_push_edge(
18872                    edges,
18873                    seen,
18874                    DependencyDagEdge {
18875                        from: profile.id.clone(),
18876                        to: downstream.clone(),
18877                        kind: "explicit_before".to_string(),
18878                        weight: 900,
18879                        reasons: vec![format!(
18880                            "{} declares it should run before #{downstream}",
18881                            profile.id
18882                        )],
18883                        shared_files: Vec::new(),
18884                        shared_symbols: Vec::new(),
18885                        shared_tests: Vec::new(),
18886                        shared_config_files: Vec::new(),
18887                        shared_semantic_refs: Vec::new(),
18888                    },
18889                );
18890            }
18891        }
18892    }
18893}
18894
18895fn dependency_dag_worker_follow_up_edges(
18896    profiles: &[DependencyDagProfile],
18897    target_ids: &BTreeSet<String>,
18898    edges: &mut Vec<DependencyDagEdge>,
18899    seen: &mut BTreeSet<(String, String, String)>,
18900) {
18901    for profile in profiles {
18902        for follow_up in &profile.worker_feedback.follow_up_ids {
18903            if target_ids.contains(follow_up) {
18904                dependency_dag_push_edge(
18905                    edges,
18906                    seen,
18907                    DependencyDagEdge {
18908                        from: profile.id.clone(),
18909                        to: follow_up.clone(),
18910                        kind: "worker_result_follow_up".to_string(),
18911                        weight: 700,
18912                        reasons: vec![format!(
18913                            "worker_result for #{} references follow-up #{}",
18914                            profile.id, follow_up
18915                        )],
18916                        shared_files: Vec::new(),
18917                        shared_symbols: Vec::new(),
18918                        shared_tests: Vec::new(),
18919                        shared_config_files: Vec::new(),
18920                        shared_semantic_refs: Vec::new(),
18921                    },
18922                );
18923            }
18924        }
18925    }
18926}
18927
18928fn dependency_dag_overlap_edges(
18929    profiles: &[DependencyDagProfile],
18930    edges: &mut Vec<DependencyDagEdge>,
18931    seen: &mut BTreeSet<(String, String, String)>,
18932) {
18933    for left_idx in 0..profiles.len() {
18934        for right_idx in (left_idx + 1)..profiles.len() {
18935            let left = &profiles[left_idx];
18936            let right = &profiles[right_idx];
18937            let shared_files = sorted_intersection(&left.source_files, &right.source_files);
18938            let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
18939            let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
18940            let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
18941            let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
18942            let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
18943            let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
18944            if shared_files.is_empty()
18945                && shared_symbols.is_empty()
18946                && shared_tests.is_empty()
18947                && shared_config_files.is_empty()
18948                && shared_semantic_refs.is_empty()
18949            {
18950                continue;
18951            }
18952            let kind = if shared_files.is_empty()
18953                && shared_symbols.is_empty()
18954                && shared_tests.is_empty()
18955                && shared_config_files.is_empty()
18956            {
18957                "semantic_relation"
18958            } else {
18959                "shared_resource"
18960            };
18961            let mut reasons = Vec::new();
18962            if !shared_files.is_empty() {
18963                reasons.push(format!("shared files: {}", shared_files.join(", ")));
18964            }
18965            if !shared_symbols.is_empty() {
18966                reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
18967            }
18968            if !shared_tests.is_empty() {
18969                reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
18970            }
18971            if !shared_config_files.is_empty() {
18972                reasons.push(format!(
18973                    "shared config files: {}",
18974                    shared_config_files.join(", ")
18975                ));
18976            }
18977            if !shared_semantic_refs.is_empty() {
18978                reasons.push(format!(
18979                    "shared semantic refs: {}",
18980                    shared_semantic_refs.join(", ")
18981                ));
18982            }
18983            let weight = shared_files.len() * 100
18984                + shared_config_files.len() * 100
18985                + shared_symbols.len() * 40
18986                + shared_tests.len() * 10
18987                + shared_semantic_refs.len() * 5;
18988            dependency_dag_push_edge(
18989                edges,
18990                seen,
18991                DependencyDagEdge {
18992                    from: left.id.clone(),
18993                    to: right.id.clone(),
18994                    kind: kind.to_string(),
18995                    weight,
18996                    reasons,
18997                    shared_files,
18998                    shared_symbols,
18999                    shared_tests,
19000                    shared_config_files,
19001                    shared_semantic_refs,
19002                },
19003            );
19004        }
19005    }
19006}
19007
19008fn dependency_dag_topo_batches(
19009    targets: &[String],
19010    edges: &[DependencyDagEdge],
19011) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19012    let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19013    let order = targets
19014        .iter()
19015        .enumerate()
19016        .map(|(idx, id)| (id.clone(), idx))
19017        .collect::<BTreeMap<_, _>>();
19018    let mut indegree = targets
19019        .iter()
19020        .map(|id| (id.clone(), 0usize))
19021        .collect::<BTreeMap<_, _>>();
19022    let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19023    let mut seen_pairs = BTreeSet::<(String, String)>::new();
19024    for edge in edges {
19025        if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19026            continue;
19027        }
19028        if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19029            continue;
19030        }
19031        *indegree.entry(edge.to.clone()).or_default() += 1;
19032        outgoing
19033            .entry(edge.from.clone())
19034            .or_default()
19035            .push(edge.to.clone());
19036    }
19037    for values in outgoing.values_mut() {
19038        values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19039        values.dedup();
19040    }
19041
19042    let mut processed = BTreeSet::new();
19043    let mut batches = Vec::new();
19044    loop {
19045        let mut ready = targets
19046            .iter()
19047            .filter(|id| !processed.contains(*id))
19048            .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19049            .cloned()
19050            .collect::<Vec<_>>();
19051        ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19052        if ready.is_empty() {
19053            break;
19054        }
19055        for id in &ready {
19056            processed.insert(id.clone());
19057            for next in outgoing.get(id).into_iter().flatten() {
19058                if let Some(value) = indegree.get_mut(next) {
19059                    *value = value.saturating_sub(1);
19060                }
19061            }
19062        }
19063        batches.push(DependencyDagTopoBatch {
19064            batch: batches.len() + 1,
19065            targets: ready,
19066        });
19067    }
19068
19069    let blocked_nodes = targets
19070        .iter()
19071        .filter(|id| !processed.contains(*id))
19072        .cloned()
19073        .collect::<Vec<_>>();
19074    let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19075    let cycle_edges = edges
19076        .iter()
19077        .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19078        .cloned()
19079        .collect::<Vec<_>>();
19080    (
19081        batches,
19082        DependencyDagCycleDiagnostics {
19083            has_cycles: !blocked_nodes.is_empty(),
19084            blocked_nodes,
19085            cycle_edges,
19086        },
19087    )
19088}
19089
19090fn dependency_dag_replay_commands(
19091    path: &Path,
19092    scope: Option<&str>,
19093    targets: &[String],
19094    depth: usize,
19095    limit: usize,
19096) -> Vec<String> {
19097    let target_args = targets
19098        .iter()
19099        .map(|target| shell_quote(target))
19100        .collect::<Vec<_>>()
19101        .join(" ");
19102    let mut command = format!(
19103        "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19104        shell_quote(path.to_string_lossy().as_ref()),
19105        scope
19106            .map(|scope| format!(" --scope {}", shell_quote(scope)))
19107            .unwrap_or_default(),
19108        depth,
19109        limit
19110    );
19111    if !target_args.is_empty() {
19112        command.push(' ');
19113        command.push_str(&target_args);
19114    }
19115    vec![command]
19116}
19117
19118fn build_dependency_dag_report(
19119    path: &Path,
19120    scope: Option<&str>,
19121    raw_targets: &[String],
19122    depth: usize,
19123    limit: usize,
19124) -> Result<DependencyDagReport> {
19125    let root = lint::resolve_project_root_or_canonical_path(path)?;
19126    write_traversal_graph_store(&root, path, scope)
19127        .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19128    let graph_db = graph_substrate_db_path(&root, scope);
19129    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19130        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19131    let mut warnings = Vec::new();
19132    if let Some(recovery) = store.read_only_recovery() {
19133        warnings.push(graph_db_read_recovery_diagnostic(recovery));
19134    }
19135    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19136    if freshness.fail_closed {
19137        bail!(
19138            "dependency-dag graph projection failed closed: {}; repair: {}",
19139            freshness.diagnostics.join("; "),
19140            graph_db_repair_commands(&root, scope).join("; ")
19141        );
19142    }
19143
19144    let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19145    let graph_nodes = store.all_nodes()?;
19146    let graph_edges = store.all_edges()?;
19147    let graph_nodes_by_id = graph_nodes
19148        .into_iter()
19149        .map(|node| (node.id.clone(), node))
19150        .collect::<BTreeMap<_, _>>();
19151    let profiles = target_nodes
19152        .iter()
19153        .map(|node| {
19154            dependency_dag_node_profile(
19155                &root,
19156                &store,
19157                node,
19158                &graph_nodes_by_id,
19159                &graph_edges,
19160                depth,
19161                limit,
19162            )
19163        })
19164        .collect::<Result<Vec<_>>>()?;
19165    let targets = profiles
19166        .iter()
19167        .map(|profile| profile.id.clone())
19168        .collect::<Vec<_>>();
19169    let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19170
19171    let mut edges = Vec::new();
19172    let mut seen_edges = BTreeSet::new();
19173    dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19174    dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19175    dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19176    edges.sort_by(|left, right| {
19177        left.from
19178            .cmp(&right.from)
19179            .then(left.to.cmp(&right.to))
19180            .then(left.kind.cmp(&right.kind))
19181    });
19182    let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19183
19184    let nodes = profiles
19185        .into_iter()
19186        .map(|profile| DependencyDagNode {
19187            id: profile.id,
19188            graph_node_id: profile.graph_node_id,
19189            label: profile.label,
19190            path: profile.path,
19191            line: profile.line,
19192            detail: profile.detail,
19193            source_files: sorted_set(&profile.source_files),
19194            source_symbols: sorted_set(&profile.source_symbols),
19195            config_files: sorted_set(&profile.config_files),
19196            expected_tests: sorted_set(&profile.expected_tests),
19197            semantic_refs: profile.semantic_refs.into_values().collect(),
19198            worker_feedback: profile.worker_feedback,
19199        })
19200        .collect::<Vec<_>>();
19201    let projection_hashes = freshness
19202        .content_hash
19203        .clone()
19204        .into_iter()
19205        .collect::<Vec<_>>();
19206    let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19207    let repair_commands = graph_db_repair_commands(&root, scope);
19208    let summary = DependencyDagSummary {
19209        nodes: nodes.len(),
19210        edges: edges.len(),
19211        topo_batches: topo_batches.len(),
19212        has_cycles: cycle_diagnostics.has_cycles,
19213    };
19214
19215    Ok(DependencyDagReport {
19216        contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19217        root: root.to_string_lossy().to_string(),
19218        scope: scope.map(str::to_string),
19219        path: path.to_string_lossy().to_string(),
19220        targets,
19221        projection_freshness: freshness,
19222        projection_hashes,
19223        nodes,
19224        edges,
19225        topo_batches,
19226        cycle_diagnostics,
19227        summary,
19228        replay_commands,
19229        repair_commands,
19230        warnings,
19231    })
19232}
19233
19234fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
19235    if compact {
19236        println!(
19237            "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
19238            report.targets.len(),
19239            report.edges.len(),
19240            report.topo_batches.len(),
19241            report.cycle_diagnostics.has_cycles
19242        );
19243    } else {
19244        println!("Dependency DAG");
19245        println!("  targets: {}", report.targets.join(", "));
19246        println!("  edges:   {}", report.edges.len());
19247        println!("  cycles:  {}", report.cycle_diagnostics.has_cycles);
19248    }
19249    for batch in &report.topo_batches {
19250        println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
19251    }
19252    for edge in &report.edges {
19253        println!(
19254            "edge {} -> {} kind:{} weight:{}",
19255            edge.from, edge.to, edge.kind, edge.weight
19256        );
19257        for reason in &edge.reasons {
19258            println!("  reason: {reason}");
19259        }
19260    }
19261    if report.cycle_diagnostics.has_cycles {
19262        println!(
19263            "cycle blocked nodes: {}",
19264            report.cycle_diagnostics.blocked_nodes.join(", ")
19265        );
19266    }
19267    for command in &report.replay_commands {
19268        println!("replay: {command}");
19269    }
19270    for command in &report.repair_commands {
19271        println!("repair: {command}");
19272    }
19273    for warning in &report.warnings {
19274        println!("warning: {warning}");
19275    }
19276}
19277
19278fn cmd_dependency_dag(
19279    path: &Path,
19280    scope: Option<&str>,
19281    raw_targets: &[String],
19282    depth: usize,
19283    limit: usize,
19284    format: OutputFormat,
19285) -> Result<()> {
19286    let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
19287    if format.json_output {
19288        print_json_or_envelope(
19289            &report,
19290            &format,
19291            "dependency-dag",
19292            "topological-planning",
19293            ToolEnvelopeSummary {
19294                text: format!(
19295                    "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
19296                    report.targets.len(),
19297                    report.edges.len(),
19298                    report.topo_batches.len(),
19299                    report.cycle_diagnostics.has_cycles
19300                ),
19301                metrics: vec![
19302                    envelope_metric("targets", report.targets.len()),
19303                    envelope_metric("edges", report.edges.len()),
19304                    envelope_metric("topo_batches", report.topo_batches.len()),
19305                    envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
19306                ],
19307            },
19308            report.cycle_diagnostics.has_cycles,
19309            report.replay_commands.clone(),
19310        )
19311    } else {
19312        print_dependency_dag_human(&report, format.compact);
19313        Ok(())
19314    }
19315}
19316
19317pub(crate) fn render_log_digest_from_input(
19318    path: &Path,
19319    input: &str,
19320    format: OutputFormat,
19321) -> Result<()> {
19322    let report = log_digest::compute(path, input)?;
19323    if format.json_output {
19324        println!(
19325            "{}",
19326            to_json_schema(
19327                &report,
19328                format.pretty,
19329                format.terse,
19330                format.ultra_terse,
19331                format.schema
19332            )?
19333        );
19334        return Ok(());
19335    }
19336
19337    if format.compact {
19338        println!(
19339            "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
19340            report.non_empty_lines,
19341            report.signal_groups,
19342            report.repeated_line_groups,
19343            report.file_ref_groups,
19344            report.symbol_ref_groups,
19345            report.stack_groups
19346        );
19347        for signal in &report.signals {
19348            let location = match (&signal.path, signal.line) {
19349                (Some(path), Some(line)) => format!("{path}:{line}"),
19350                (Some(path), None) => path.clone(),
19351                _ => "-".to_string(),
19352            };
19353            println!(
19354                "{} sev:{} count:{} sums:{} msg:{}",
19355                location,
19356                signal.severity,
19357                signal.occurrences,
19358                log_digest_summary_label(signal.summary_state),
19359                truncate_for_compact(&signal.message, 80)
19360            );
19361        }
19362        for repeated in &report.repeated_lines {
19363            println!(
19364                "repeat count:{} line:{}",
19365                repeated.occurrences,
19366                truncate_for_compact(&repeated.line, 80)
19367            );
19368        }
19369        for symbol in &report.symbol_refs {
19370            println!(
19371                "sym:{} count:{} sums:{}",
19372                symbol.symbol,
19373                symbol.occurrences,
19374                log_digest_summary_label(symbol.summary_state)
19375            );
19376        }
19377        for warning in &report.warnings {
19378            println!("warning: {warning}");
19379        }
19380        return Ok(());
19381    }
19382
19383    println!("Log digest");
19384    println!("  lines:                    {}", report.total_lines);
19385    println!("  non-empty lines:          {}", report.non_empty_lines);
19386    println!("  signal groups:            {}", report.signal_groups);
19387    println!(
19388        "  repeated lines:           {}",
19389        report.repeated_line_groups
19390    );
19391    println!(
19392        "  repeated line instances:  {}",
19393        report.repeated_line_occurrences
19394    );
19395    println!("  file refs:                {}", report.file_ref_groups);
19396    println!("  symbol refs:              {}", report.symbol_ref_groups);
19397    println!("  stack groups:             {}", report.stack_groups);
19398
19399    if !report.signals.is_empty() {
19400        println!();
19401        println!("Signals:");
19402        for signal in &report.signals {
19403            match (&signal.path, signal.line, signal.column) {
19404                (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
19405                (Some(path), Some(line), None) => println!("{path}:{line}"),
19406                (Some(path), None, _) => println!("{path}"),
19407                (None, _, _) => println!("(no file anchor)"),
19408            }
19409            println!("  severity: {}", signal.severity);
19410            println!("  occurrences: {}", signal.occurrences);
19411            println!("  message: {}", signal.message);
19412            println!(
19413                "  cached summaries: {}",
19414                log_digest_summary_label(signal.summary_state)
19415            );
19416            for summary in &signal.current_summaries {
19417                println!(
19418                    "    - {}: {}",
19419                    summary.symbol,
19420                    truncate_for_compact(&summary.summary, 160)
19421                );
19422            }
19423        }
19424    }
19425
19426    if !report.repeated_lines.is_empty() {
19427        println!();
19428        println!("Repeated lines:");
19429        for repeated in &report.repeated_lines {
19430            println!(
19431                "  {}x {}",
19432                repeated.occurrences,
19433                truncate_for_compact(&repeated.line, 180)
19434            );
19435        }
19436    }
19437
19438    if !report.file_refs.is_empty() {
19439        println!();
19440        println!("Anchored files:");
19441        for file_ref in &report.file_refs {
19442            match (file_ref.line, file_ref.column) {
19443                (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
19444                (Some(line), None) => println!("{}:{}", file_ref.path, line),
19445                (None, _) => println!("{}", file_ref.path),
19446            }
19447            println!("  occurrences: {}", file_ref.occurrences);
19448            println!(
19449                "  cached summaries: {}",
19450                log_digest_summary_label(file_ref.summary_state)
19451            );
19452            for summary in &file_ref.current_summaries {
19453                println!(
19454                    "    - {}: {}",
19455                    summary.symbol,
19456                    truncate_for_compact(&summary.summary, 160)
19457                );
19458            }
19459        }
19460    }
19461
19462    if !report.symbol_refs.is_empty() {
19463        println!();
19464        println!("Symbol candidates:");
19465        for symbol in &report.symbol_refs {
19466            println!("{}", symbol.symbol);
19467            println!("  occurrences: {}", symbol.occurrences);
19468            println!(
19469                "  cached summaries: {}",
19470                log_digest_summary_label(symbol.summary_state)
19471            );
19472            for summary in &symbol.current_summaries {
19473                println!(
19474                    "    - {}: {}",
19475                    summary.symbol,
19476                    truncate_for_compact(&summary.summary, 160)
19477                );
19478            }
19479        }
19480    }
19481
19482    if !report.stack_traces.is_empty() {
19483        println!();
19484        println!("Stack groups:");
19485        for stack in &report.stack_traces {
19486            println!("  occurrences: {}", stack.occurrences);
19487            for frame in &stack.frames {
19488                println!("    - {}", frame);
19489            }
19490        }
19491    }
19492
19493    for warning in &report.warnings {
19494        println!("warning: {warning}");
19495    }
19496    Ok(())
19497}
19498
19499pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
19500    match trend {
19501        metric_digest::MetricDigestTrend::Improved => "improved",
19502        metric_digest::MetricDigestTrend::Regressed => "regressed",
19503        metric_digest::MetricDigestTrend::Flat => "flat",
19504        metric_digest::MetricDigestTrend::Unknown => "changed",
19505    }
19506}
19507
19508pub(crate) fn metric_digest_gate_label(
19509    decision: metric_digest::CommunitySearchGateDecision,
19510) -> &'static str {
19511    match decision {
19512        metric_digest::CommunitySearchGateDecision::Pass => "pass",
19513        metric_digest::CommunitySearchGateDecision::Block => "block",
19514    }
19515}
19516
19517pub(crate) fn memgraphrag_metric_digest_gate_label(
19518    decision: metric_digest::MemGraphRagPerformanceGateDecision,
19519) -> &'static str {
19520    match decision {
19521        metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
19522        metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
19523    }
19524}
19525
19526fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
19527    let input = fs::read_to_string(fixture_path)
19528        .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
19529    let report = dci_benchmark::compute(&input)?;
19530
19531    if format.json_output {
19532        println!(
19533            "{}",
19534            to_json_schema(
19535                &report,
19536                format.pretty,
19537                format.terse,
19538                format.ultra_terse,
19539                format.schema
19540            )?
19541        );
19542        return Ok(());
19543    }
19544
19545    if format.compact {
19546        println!(
19547            "dci tasks:{} strategies:{} warnings:{}",
19548            report.tasks_loaded,
19549            report.strategies_compared,
19550            report.warnings.len()
19551        );
19552        for summary in &report.strategy_summaries {
19553            println!(
19554                "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
19555                summary.strategy,
19556                summary.rank,
19557                summary.localized,
19558                summary.task_runs,
19559                dci_benchmark::format_number(summary.localization_rate * 100.0),
19560                dci_benchmark::format_number(summary.avg_useful_hits),
19561                dci_benchmark::format_number(summary.zero_output_rate * 100.0),
19562                dci_benchmark::format_number(summary.avg_tool_calls),
19563                dci_benchmark::format_number(summary.avg_latency_ms),
19564                dci_benchmark::format_number(summary.avg_estimated_tokens),
19565                dci_benchmark::format_number(summary.avg_output_tokens)
19566            );
19567        }
19568        if let Some(gate) = &report.memory_retrieval_gate {
19569            println!(
19570                "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
19571                gate.decision,
19572                gate.baseline_strategy,
19573                dci_benchmark::format_number(gate.min_avg_useful_hits),
19574                gate.max_zero_output_failures,
19575                gate.diagnostics.len()
19576            );
19577        }
19578        for warning in &report.warnings {
19579            println!("warning: {warning}");
19580        }
19581        return Ok(());
19582    }
19583
19584    println!("DCI benchmark");
19585    if let Some(description) = &report.description {
19586        println!("  description: {}", description);
19587    }
19588    println!("  tasks loaded:        {}", report.tasks_loaded);
19589    println!("  strategies compared: {}", report.strategies_compared);
19590
19591    println!();
19592    println!("Strategy summary:");
19593    for summary in &report.strategy_summaries {
19594        println!(
19595            "  #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
19596            summary.rank,
19597            summary.strategy,
19598            summary.localized,
19599            summary.task_runs,
19600            summary.localization_rate * 100.0,
19601            dci_benchmark::format_number(summary.avg_useful_hits),
19602            summary.zero_output_rate * 100.0,
19603            dci_benchmark::format_number(summary.avg_tool_calls),
19604            dci_benchmark::format_number(summary.avg_latency_ms),
19605            dci_benchmark::format_number(summary.avg_estimated_tokens),
19606            dci_benchmark::format_number(summary.avg_output_tokens)
19607        );
19608    }
19609
19610    if let Some(gate) = &report.memory_retrieval_gate {
19611        println!();
19612        println!("Memory retrieval gate:");
19613        println!("  decision: {}", gate.decision);
19614        println!(
19615            "  baseline: {}, min avg useful hits {}, max zero-output failures {}",
19616            gate.baseline_strategy,
19617            dci_benchmark::format_number(gate.min_avg_useful_hits),
19618            gate.max_zero_output_failures
19619        );
19620        for row in &gate.rows {
19621            println!(
19622                "  {}: status {}, avg useful hits {}, zero-output failures {}",
19623                row.strategy,
19624                row.status,
19625                dci_benchmark::format_number(row.avg_useful_hits),
19626                row.zero_output_failures
19627            );
19628        }
19629        for diagnostic in &gate.diagnostics {
19630            println!("  diagnostic: {diagnostic}");
19631        }
19632    }
19633
19634    println!();
19635    println!("Task winners:");
19636    for row in &report.task_rows {
19637        let label = row
19638            .label
19639            .as_ref()
19640            .map(|value| format!(" ({value})"))
19641            .unwrap_or_default();
19642        println!("  {}{}", row.task_id, label);
19643        println!("    localized: {}", row.best_localization.join(", "));
19644        println!("    most useful hits: {}", row.most_useful_hits.join(", "));
19645        println!(
19646            "    lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
19647            row.lowest_tool_calls.as_deref().unwrap_or("-"),
19648            row.lowest_latency.as_deref().unwrap_or("-"),
19649            row.lowest_token_budget.as_deref().unwrap_or("-"),
19650            row.lowest_output_tokens.as_deref().unwrap_or("-")
19651        );
19652        if !row.zero_output_failures.is_empty() {
19653            println!("    zero output: {}", row.zero_output_failures.join(", "));
19654        }
19655    }
19656
19657    for warning in &report.warnings {
19658        println!("warning: {warning}");
19659    }
19660    Ok(())
19661}
19662
19663pub(crate) fn format_compact_count(value: u64) -> String {
19664    if value >= 1_000_000 {
19665        format!("{:.1}M", value as f64 / 1_000_000.0)
19666    } else if value >= 1_000 {
19667        format!("{:.1}K", value as f64 / 1_000.0)
19668    } else {
19669        value.to_string()
19670    }
19671}
19672
19673fn cmd_digest_runner(
19674    kind: &str,
19675    path: &Path,
19676    runner: Option<&str>,
19677    shell_command: &str,
19678    format: OutputFormat,
19679) -> Result<()> {
19680    let digest_kind = DigestRunnerKind::parse(kind)?;
19681    let root = transcript_artifact_root(path)?;
19682    let execution = run_digest_runner_command(shell_command)?;
19683    let output = &execution.output;
19684    let captured = String::from_utf8_lossy(&output.stdout).into_owned();
19685    let exit_code = output.status.code().unwrap_or(-1);
19686    if format.json_output && format.envelope {
19687        let artifact_key = format!(
19688            "{}:{}:{}:{}",
19689            digest_kind.as_str(),
19690            shell_command,
19691            execution.executed_command,
19692            captured
19693        );
19694        let artifact = if captured.trim().is_empty() {
19695            None
19696        } else {
19697            let (suffix, expand) = match digest_kind {
19698                DigestRunnerKind::Test => (
19699                    "test.log",
19700                    format!(
19701                        "tsift test-digest --path {} --input {}{} --json",
19702                        shell_quote(root.to_string_lossy().as_ref()),
19703                        shell_quote(
19704                            root.join(".tsift/artifacts")
19705                                .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
19706                                .to_string_lossy()
19707                                .as_ref()
19708                        ),
19709                        runner
19710                            .map(|value| format!(" --runner {}", shell_quote(value)))
19711                            .unwrap_or_default()
19712                    ),
19713                ),
19714                DigestRunnerKind::Log => (
19715                    "log",
19716                    format!(
19717                        "tsift log-digest --path {} --input {} --json",
19718                        shell_quote(root.to_string_lossy().as_ref()),
19719                        shell_quote(
19720                            root.join(".tsift/artifacts")
19721                                .join(format!("{}.log", stable_handle("tart", &artifact_key)))
19722                                .to_string_lossy()
19723                                .as_ref()
19724                        )
19725                    ),
19726                ),
19727            };
19728            Some(persist_transcript_artifact(
19729                &root,
19730                "tart",
19731                suffix,
19732                &artifact_key,
19733                &captured,
19734                expand,
19735            )?)
19736        };
19737        let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
19738
19739        match digest_kind {
19740            DigestRunnerKind::Test => {
19741                let digest_report = test_digest::compute(path, &captured, runner)?;
19742                let report = serde_json::json!({
19743                    "kind": digest_kind.as_str(),
19744                    "command": shell_command,
19745                    "executed_command": execution.executed_command,
19746                    "exit_code": exit_code,
19747                    "success": output.status.success(),
19748                    "filter": filter_report,
19749                    "artifact": artifact,
19750                    "digest": digest_report,
19751                });
19752                let mut follow_up = artifact
19753                    .as_ref()
19754                    .map(|entry| vec![entry.expand.clone()])
19755                    .unwrap_or_default();
19756                follow_up.push(format!(
19757                    "tsift rewrite --run {}",
19758                    shell_quote(shell_command)
19759                ));
19760                let summary_text = if output.status.success() && digest_report.failures == 0 {
19761                    format!("test run passed for {}", runner.unwrap_or("auto"))
19762                } else {
19763                    format!("test run captured {} failure(s)", digest_report.failures)
19764                };
19765                print_json_or_envelope(
19766                    &report,
19767                    &format,
19768                    "digest-runner",
19769                    "test-run",
19770                    ToolEnvelopeSummary {
19771                        text: summary_text,
19772                        metrics: vec![
19773                            envelope_metric("runner", &digest_report.runner),
19774                            envelope_metric("exit_code", exit_code),
19775                            envelope_metric("filter", execution.filter_label()),
19776                            envelope_metric("failures", digest_report.failures),
19777                            envelope_metric("groups", digest_report.grouped_failures),
19778                            envelope_metric(
19779                                "artifact",
19780                                artifact
19781                                    .as_ref()
19782                                    .map(|entry| entry.handle.as_str())
19783                                    .unwrap_or("-"),
19784                            ),
19785                        ],
19786                    },
19787                    false,
19788                    follow_up,
19789                )?;
19790            }
19791            DigestRunnerKind::Log => {
19792                let digest_report = log_digest::compute(path, &captured)?;
19793                let report = serde_json::json!({
19794                    "kind": digest_kind.as_str(),
19795                    "command": shell_command,
19796                    "executed_command": execution.executed_command,
19797                    "exit_code": exit_code,
19798                    "success": output.status.success(),
19799                    "filter": filter_report,
19800                    "artifact": artifact,
19801                    "digest": digest_report,
19802                });
19803                let mut follow_up = artifact
19804                    .as_ref()
19805                    .map(|entry| vec![entry.expand.clone()])
19806                    .unwrap_or_default();
19807                follow_up.push(format!(
19808                    "tsift rewrite --run {}",
19809                    shell_quote(shell_command)
19810                ));
19811                let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
19812                    "command finished without log signals".to_string()
19813                } else {
19814                    format!(
19815                        "command emitted {} log signal group(s)",
19816                        digest_report.signal_groups
19817                    )
19818                };
19819                print_json_or_envelope(
19820                    &report,
19821                    &format,
19822                    "digest-runner",
19823                    "command-run",
19824                    ToolEnvelopeSummary {
19825                        text: summary_text,
19826                        metrics: vec![
19827                            envelope_metric("exit_code", exit_code),
19828                            envelope_metric("filter", execution.filter_label()),
19829                            envelope_metric("signals", digest_report.signal_groups),
19830                            envelope_metric("file_refs", digest_report.file_ref_groups),
19831                            envelope_metric(
19832                                "artifact",
19833                                artifact
19834                                    .as_ref()
19835                                    .map(|entry| entry.handle.as_str())
19836                                    .unwrap_or("-"),
19837                            ),
19838                        ],
19839                    },
19840                    false,
19841                    follow_up,
19842                )?;
19843            }
19844        }
19845
19846        if output.status.success() {
19847            return Ok(());
19848        }
19849        if let Some(code) = output.status.code() {
19850            std::process::exit(code);
19851        }
19852        bail!("digest-wrapped command terminated by signal: {shell_command}");
19853    }
19854
19855    if captured.trim().is_empty() {
19856        let label = match digest_kind {
19857            DigestRunnerKind::Test => "test",
19858            DigestRunnerKind::Log => "log",
19859        };
19860        println!("No {label} output captured.");
19861    } else {
19862        match digest_kind {
19863            DigestRunnerKind::Test => {
19864                render_test_digest_from_input(path, &captured, runner, format)?
19865            }
19866            DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
19867        }
19868    }
19869
19870    if output.status.success() {
19871        return Ok(());
19872    }
19873    if let Some(code) = output.status.code() {
19874        std::process::exit(code);
19875    }
19876    bail!("digest-wrapped command terminated by signal: {shell_command}");
19877}
19878
19879struct DigestRunnerExecution {
19880    output: std::process::Output,
19881    executed_command: String,
19882    filter: Option<DigestRunnerFilter>,
19883}
19884
19885impl DigestRunnerExecution {
19886    fn filter_label(&self) -> &'static str {
19887        self.filter
19888            .as_ref()
19889            .map(|filter| filter.tool)
19890            .unwrap_or("none")
19891    }
19892}
19893
19894struct DigestRunnerFilter {
19895    tool: &'static str,
19896    command: String,
19897}
19898
19899impl DigestRunnerFilter {
19900    fn to_json(&self) -> serde_json::Value {
19901        serde_json::json!({
19902            "tool": self.tool,
19903            "command": self.command,
19904        })
19905    }
19906}
19907
19908fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
19909    let filter = rtk_rewrite_for_digest_runner(shell_command);
19910    let executed_command = filter
19911        .as_ref()
19912        .map(|filter| filter.command.as_str())
19913        .unwrap_or(shell_command);
19914    let output = Command::new("sh")
19915        .arg("-lc")
19916        .arg(format!("({executed_command}) 2>&1"))
19917        .stdout(Stdio::piped())
19918        .output()
19919        .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
19920
19921    Ok(DigestRunnerExecution {
19922        output,
19923        executed_command: executed_command.to_string(),
19924        filter,
19925    })
19926}
19927
19928fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
19929    if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
19930        return None;
19931    }
19932    let output = Command::new("rtk")
19933        .arg("rewrite")
19934        .arg(shell_command)
19935        .output()
19936        .ok()?;
19937    if !output.status.success() {
19938        return None;
19939    }
19940    let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
19941    if rewritten.is_empty() || rewritten == shell_command {
19942        return None;
19943    }
19944    Some(DigestRunnerFilter {
19945        tool: "rtk",
19946        command: rewritten,
19947    })
19948}
19949
19950fn find_command_on_path(command: &str) -> Option<PathBuf> {
19951    let path_var = std::env::var_os("PATH")?;
19952    std::env::split_paths(&path_var)
19953        .map(|dir| dir.join(command))
19954        .find(|candidate| candidate.is_file())
19955}
19956
19957pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
19958    if !db_path.exists() {
19959        bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
19960    }
19961    summarize::SummaryDb::open_read_only_resilient(db_path)
19962}
19963
19964fn status_index_needs_fix(report: &status::StatusReport) -> bool {
19965    !matches!(report.index, status::IndexStatus::Fresh { .. })
19966}
19967
19968fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
19969    !matches!(report.instructions, init::InstructionStatus::Current { .. })
19970}
19971
19972pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
19973    if status_instructions_need_fix(report) {
19974        eprintln!("status fix: refreshing tsift instructions");
19975        init::init(root, false, false)?;
19976    }
19977
19978    let eviction = cycle_packet_cache::cycle_packet_cache_evict(
19979        root,
19980        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
19981        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
19982    );
19983    if eviction.evicted_entries > 0 {
19984        eprintln!(
19985            "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
19986            eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
19987        );
19988    }
19989
19990    if !status_index_needs_fix(report) {
19991        return Ok(());
19992    }
19993
19994    let scopes = config::Config::submodule_dirs(root)?;
19995    if scopes.is_empty() {
19996        eprintln!("status fix: refreshing index");
19997        run_index_update(
19998            &root.join(".tsift/index.db"),
19999            root,
20000            "status --fix refreshing index".to_string(),
20001            root,
20002            None,
20003            false,
20004            false,
20005        )?;
20006        return Ok(());
20007    }
20008
20009    let cfg = config::Config::load(root)?;
20010    for scope in scopes {
20011        if !scope.source_root.exists() {
20012            eprintln!(
20013                "status fix: skipping missing submodule `{}` ({})",
20014                scope.id,
20015                scope.source_root.display()
20016            );
20017            continue;
20018        }
20019        eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20020        run_index_update(
20021            &cfg.db_path_for(root, &scope.id),
20022            &scope.source_root,
20023            format!("status --fix refreshing submodule `{}` index", scope.id),
20024            root,
20025            Some(scope.id.as_str()),
20026            false,
20027            false,
20028        )?;
20029    }
20030
20031    Ok(())
20032}
20033
20034pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20035    match &report.index {
20036        status::IndexStatus::Fresh { missing_scopes, .. }
20037        | status::IndexStatus::Stale { missing_scopes, .. }
20038        | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20039    }
20040}
20041
20042pub(crate) fn autoindex_missing_workspace_scopes(
20043    root: &Path,
20044    report: &status::StatusReport,
20045) -> Result<()> {
20046    let missing_scopes = match &report.index {
20047        status::IndexStatus::Fresh { missing_scopes, .. }
20048        | status::IndexStatus::Stale { missing_scopes, .. }
20049        | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20050    };
20051    if missing_scopes.is_empty() {
20052        return Ok(());
20053    }
20054
20055    let missing_scope_ids = missing_scopes
20056        .iter()
20057        .map(|scope| scope.scope.as_str())
20058        .collect::<std::collections::HashSet<_>>();
20059    let cfg = config::Config::load(root)?;
20060    for scope in config::Config::submodule_dirs(root)? {
20061        if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20062            continue;
20063        }
20064        let db_path = cfg.db_path_for(root, &scope.id);
20065        run_index_update(
20066            &db_path,
20067            &scope.source_root,
20068            format!(
20069                "autoindexing missing submodule `{}` during status",
20070                scope.id
20071            ),
20072            root,
20073            Some(scope.id.as_str()),
20074            false,
20075            false,
20076        )?;
20077    }
20078    Ok(())
20079}
20080
20081pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
20082    for warning in &stats.warnings {
20083        let rel_path = relativize_pathbuf(&warning.path, root);
20084        eprintln!(
20085            "warning: summarize stats {}: {}",
20086            rel_path.display(),
20087            warning.message
20088        );
20089    }
20090}
20091
20092fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
20093    Result::<(), anyhow::Error>::Err(err)
20094        .context(context)
20095        .unwrap_err()
20096}
20097
20098fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
20099    let message = err.to_string();
20100    message.contains("another tsift index writer is already active")
20101        || substrate::error_mentions_locked_db(err)
20102}
20103
20104fn add_write_lock_context(
20105    err: anyhow::Error,
20106    action: String,
20107    root: &std::path::Path,
20108    scope: Option<&str>,
20109) -> anyhow::Error {
20110    if !should_attach_lock_diagnostics(&err) {
20111        return contextualize_error(err, action);
20112    }
20113
20114    let Ok(report) = status::check_locks(root, None, scope) else {
20115        return contextualize_error(err, action);
20116    };
20117
20118    contextualize_error(
20119        err,
20120        format!(
20121            "{}\n\nlock diagnostics:\n{}",
20122            action,
20123            status::format_locks_human(&report, false).trim_end()
20124        ),
20125    )
20126}
20127
20128pub(crate) fn run_index_update(
20129    db_path: &std::path::Path,
20130    source_root: &std::path::Path,
20131    action: String,
20132    root: &std::path::Path,
20133    scope: Option<&str>,
20134    rebuild: bool,
20135    prune: bool,
20136) -> Result<index::IndexSummary> {
20137    let result = (|| {
20138        let db = index::IndexDb::open(db_path)?;
20139        if rebuild {
20140            db.rebuild(source_root)
20141        } else if prune {
20142            db.apply_changes_pruned(source_root)
20143        } else {
20144            db.apply_changes(source_root)
20145        }
20146    })();
20147
20148    let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
20149    emit_index_warnings(&summary, source_root, scope);
20150    Ok(summary)
20151}
20152
20153pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
20154    for change in &mut summary.changes {
20155        change.path = relativize_pathbuf(&change.path, root);
20156    }
20157    for warning in &mut summary.warnings {
20158        warning.path = relativize_pathbuf(&warning.path, root);
20159    }
20160}
20161
20162fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
20163    for warning in &summary.warnings {
20164        let rel_path = relativize_pathbuf(&warning.path, root);
20165        let stage = match warning.stage {
20166            index::IndexWarningStage::ReadSource => "read failed",
20167            index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
20168            index::IndexWarningStage::ExtractCallSites => "call extraction failed",
20169            index::IndexWarningStage::ExtractRoutes => "route extraction failed",
20170        };
20171        let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
20172        let lang_suffix = warning
20173            .language
20174            .as_deref()
20175            .map(|lang| format!(" [{}]", lang))
20176            .unwrap_or_default();
20177        eprintln!(
20178            "warning: {}{}{}: {}: {}",
20179            scope_prefix,
20180            rel_path.display(),
20181            lang_suffix,
20182            stage,
20183            warning.message
20184        );
20185    }
20186}
20187
20188pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
20189    let config_path = root.join(".tsift/config.toml");
20190    if !config_path.exists() {
20191        return summarize::SummarizeConfig::default();
20192    }
20193    #[derive(serde::Deserialize, Default)]
20194    struct RawConfig {
20195        #[serde(default)]
20196        summarize: Option<RawSummarize>,
20197    }
20198    #[derive(serde::Deserialize)]
20199    struct RawSummarize {
20200        model: Option<String>,
20201        max_file_tokens: Option<usize>,
20202        api_key_env: Option<String>,
20203    }
20204    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
20205    let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
20206    let defaults = summarize::SummarizeConfig::default();
20207    match raw.summarize {
20208        Some(s) => summarize::SummarizeConfig {
20209            model: s.model.unwrap_or(defaults.model),
20210            max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
20211            api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
20212        },
20213        None => defaults,
20214    }
20215}
20216
20217#[derive(Debug, Clone, PartialEq, Eq)]
20218struct ExtractSymbolContext {
20219    db_path: PathBuf,
20220    source_root: PathBuf,
20221}
20222
20223pub(crate) fn find_symbols_db_for_file(
20224    root: &Path,
20225    file_path: &Path,
20226) -> Result<Option<ExtractSymbolContext>> {
20227    let cfg = config::Config::load(root)?;
20228    let mut submodules = config::Config::submodule_dirs(root)?;
20229    submodules.sort_by(|left, right| {
20230        right
20231            .source_root
20232            .components()
20233            .count()
20234            .cmp(&left.source_root.components().count())
20235    });
20236
20237    for scope in submodules {
20238        if !file_path.starts_with(&scope.source_root) {
20239            continue;
20240        }
20241        let db_path = cfg.db_path_for(root, &scope.id);
20242        if db_path.exists() {
20243            return Ok(Some(ExtractSymbolContext {
20244                db_path,
20245                source_root: scope.source_root,
20246            }));
20247        }
20248    }
20249
20250    let single = root.join(".tsift/index.db");
20251    if single.exists() && file_path.starts_with(root) {
20252        return Ok(Some(ExtractSymbolContext {
20253            db_path: single,
20254            source_root: root.to_path_buf(),
20255        }));
20256    }
20257
20258    Ok(None)
20259}
20260
20261pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
20262    let canonical = path
20263        .canonicalize()
20264        .with_context(|| format!("canonicalizing {}", path.display()))?;
20265
20266    Ok(if canonical.is_dir() {
20267        canonical
20268    } else {
20269        canonical
20270            .parent()
20271            .map(Path::to_path_buf)
20272            .unwrap_or(canonical)
20273    })
20274}
20275
20276fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
20277    if path.exists() {
20278        return path
20279            .canonicalize()
20280            .with_context(|| format!("canonicalizing extract scope {}", path.display()));
20281    }
20282
20283    Ok(summarize::normalize_lexical_path(path))
20284}
20285
20286pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
20287    let scope = if extract_path.is_absolute() {
20288        extract_path.to_path_buf()
20289    } else {
20290        root.join(extract_path)
20291    };
20292    normalize_extract_scope_path(&scope)
20293}
20294
20295pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
20296    normalize_extract_scope_path(changed_path)
20297        .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
20298        .starts_with(extract_scope)
20299}
20300
20301pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
20302    summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
20303}
20304
20305pub(crate) fn summarize_full_extract_deleted_summary_paths(
20306    summary_db: &summarize::SummaryDb,
20307    root: &Path,
20308    extract_scope: &Path,
20309    files_to_extract: &[PathBuf],
20310) -> Result<BTreeSet<String>> {
20311    let live_paths = files_to_extract
20312        .iter()
20313        .map(|file_path| summarize_relative_file_path(root, file_path))
20314        .collect::<BTreeSet<_>>();
20315    let mut deleted = BTreeSet::new();
20316
20317    for cached_path in summary_db.cached_file_paths()? {
20318        if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
20319            continue;
20320        }
20321        if !live_paths.contains(&cached_path) {
20322            deleted.insert(cached_path);
20323        }
20324    }
20325
20326    Ok(deleted)
20327}
20328
20329#[derive(Debug, Clone)]
20330struct SearchIndexTarget {
20331    label: String,
20332    db_path: PathBuf,
20333    source_root: PathBuf,
20334    scope_name: Option<String>,
20335    reindex_cmd: String,
20336}
20337
20338fn cargo_package_index_target(
20339    root: &Path,
20340    package: multiplicity::CargoPackageInfo,
20341) -> SearchIndexTarget {
20342    SearchIndexTarget {
20343        label: format!("cargo package `{}` index", package.scope_id),
20344        db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
20345        source_root: package.package_root.clone(),
20346        scope_name: Some(package.scope_id.clone()),
20347        reindex_cmd: format!(
20348            "tsift index --submodule {} {}",
20349            package.scope_id,
20350            root.display()
20351        ),
20352    }
20353}
20354
20355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20356enum SearchIndexState {
20357    Missing,
20358    Fresh,
20359    Stale { stale_files: usize },
20360}
20361
20362fn resolve_search_index_targets(
20363    root: &Path,
20364    path_hint: &Path,
20365    scope: Option<&str>,
20366    federated: bool,
20367) -> Result<Vec<SearchIndexTarget>> {
20368    if let Some(scope_name) = scope {
20369        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
20370            let cfg = config::Config::load(root)?;
20371            return Ok(vec![SearchIndexTarget {
20372                label: format!("submodule `{}` index", scope.id),
20373                db_path: cfg.db_path_for(root, &scope.id),
20374                source_root: scope.source_root.clone(),
20375                scope_name: Some(scope.id.clone()),
20376                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20377            }]);
20378        }
20379        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
20380            return Ok(vec![cargo_package_index_target(root, package)]);
20381        }
20382        config::Config::resolve_submodule(root, scope_name)?;
20383    }
20384
20385    if federated {
20386        let cfg = config::Config::load(root)?;
20387        let mut targets = Vec::new();
20388        for scope in config::Config::submodule_dirs(root)? {
20389            if !cfg.federation_for_scope(&scope) {
20390                continue;
20391            }
20392            targets.push(SearchIndexTarget {
20393                label: format!("submodule `{}` index", scope.id),
20394                db_path: cfg.db_path_for(root, &scope.id),
20395                source_root: scope.source_root.clone(),
20396                scope_name: Some(scope.id.clone()),
20397                reindex_cmd: format!("tsift index --workspace {}", root.display()),
20398            });
20399        }
20400        return Ok(targets);
20401    }
20402
20403    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
20404        let cfg = config::Config::load(root)?;
20405        return Ok(vec![SearchIndexTarget {
20406            label: format!("submodule `{}` index", scope.id),
20407            db_path: cfg.db_path_for(root, &scope.id),
20408            source_root: scope.source_root.clone(),
20409            scope_name: Some(scope.id.clone()),
20410            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20411        }]);
20412    }
20413
20414    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
20415        return Ok(vec![cargo_package_index_target(root, package)]);
20416    }
20417
20418    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
20419        let cfg = config::Config::load(root)?;
20420        return Ok(vec![SearchIndexTarget {
20421            label: format!("submodule `{}` index", scope.id),
20422            db_path: cfg.db_path_for(root, &scope.id),
20423            source_root: scope.source_root.clone(),
20424            scope_name: Some(scope.id.clone()),
20425            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20426        }]);
20427    }
20428
20429    let scopes = config::Config::submodule_dirs(root)?;
20430    if !scopes.is_empty() {
20431        let root_db = root.join(".tsift/index.db");
20432        if !root_db.exists() {
20433            let available_scopes = scopes
20434                .iter()
20435                .map(|scope| scope.id.as_str())
20436                .collect::<Vec<_>>()
20437                .join(", ");
20438            let cfg = config::Config::load(root)?;
20439            let indexed_scopes = scopes
20440                .iter()
20441                .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
20442                .map(|scope| scope.id.as_str())
20443                .collect::<Vec<_>>();
20444            let indexed_label = if indexed_scopes.is_empty() {
20445                "none".to_string()
20446            } else {
20447                indexed_scopes.join(", ")
20448            };
20449            bail!(
20450                "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: {}.",
20451                root.display(),
20452                root_db.display(),
20453                available_scopes,
20454                indexed_label,
20455            );
20456        }
20457    }
20458
20459    Ok(vec![SearchIndexTarget {
20460        label: "index".to_string(),
20461        db_path: root.join(".tsift/index.db"),
20462        source_root: root.to_path_buf(),
20463        scope_name: None,
20464        reindex_cmd: format!("tsift index {}", root.display()),
20465    }])
20466}
20467
20468fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
20469    if !target.source_root.exists() || !target.db_path.exists() {
20470        return Ok(SearchIndexState::Missing);
20471    }
20472
20473    let inspection =
20474        index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
20475    let stale_files =
20476        inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
20477    if stale_files == 0 {
20478        Ok(SearchIndexState::Fresh)
20479    } else {
20480        Ok(SearchIndexState::Stale { stale_files })
20481    }
20482}
20483
20484#[derive(Debug, Clone, PartialEq, Eq)]
20485struct RebuildSearchTarget {
20486    label: String,
20487    reason: RebuildSearchReason,
20488    reindex_cmd: String,
20489}
20490
20491#[derive(Debug, Clone, PartialEq, Eq)]
20492enum RebuildSearchReason {
20493    Missing,
20494    Stale { stale_files: usize },
20495}
20496
20497#[derive(Debug, Clone, PartialEq, Eq)]
20498struct DegradedSearchTarget {
20499    label: String,
20500    reason: RebuildSearchReason,
20501    reindex_cmd: String,
20502}
20503
20504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20505pub(crate) enum DegradedSearchMode {
20506    ReadOnly,
20507    Exact,
20508}
20509
20510#[derive(Debug)]
20511struct SearchPrecheck {
20512    targets: Vec<SearchIndexTarget>,
20513    degraded_targets: Vec<DegradedSearchTarget>,
20514}
20515
20516fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
20517    err.chain().any(|cause| {
20518        cause
20519            .to_string()
20520            .contains("another tsift index writer is already active")
20521    })
20522}
20523
20524fn infer_agent_doc_task_submodule(
20525    root: &Path,
20526    path_hint: &Path,
20527) -> Result<Option<config::WorkspaceScope>> {
20528    let hinted_path = if path_hint.is_absolute() {
20529        path_hint.to_path_buf()
20530    } else {
20531        root.join(path_hint)
20532    };
20533    let Ok(relative) = hinted_path.strip_prefix(root) else {
20534        return Ok(None);
20535    };
20536    let mut components = relative.components();
20537    let Some(std::path::Component::Normal(first)) = components.next() else {
20538        return Ok(None);
20539    };
20540    if first != "tasks" {
20541        return Ok(None);
20542    }
20543    let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
20544        return Ok(None);
20545    };
20546    config::Config::find_submodule(root, file_stem)
20547}
20548
20549fn degraded_search_target(
20550    target: &SearchIndexTarget,
20551    reason: RebuildSearchReason,
20552) -> DegradedSearchTarget {
20553    DegradedSearchTarget {
20554        label: target.label.clone(),
20555        reason,
20556        reindex_cmd: target.reindex_cmd.clone(),
20557    }
20558}
20559
20560fn apply_search_index_update(
20561    root: &Path,
20562    target: &SearchIndexTarget,
20563) -> Result<index::IndexSummary> {
20564    run_index_update(
20565        &target.db_path,
20566        &target.source_root,
20567        format!("autoindexing {}", target.label),
20568        root,
20569        target.scope_name.as_deref(),
20570        false,
20571        false,
20572    )
20573}
20574
20575fn collect_rebuild_search_targets(
20576    targets: &[SearchIndexTarget],
20577) -> Result<Vec<RebuildSearchTarget>> {
20578    let mut rebuild_targets = Vec::new();
20579    for target in targets {
20580        let reason = match inspect_search_index(target)? {
20581            SearchIndexState::Missing => RebuildSearchReason::Missing,
20582            SearchIndexState::Fresh => continue,
20583            SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
20584        };
20585        rebuild_targets.push(RebuildSearchTarget {
20586            label: target.label.clone(),
20587            reason,
20588            reindex_cmd: target.reindex_cmd.clone(),
20589        });
20590    }
20591    Ok(rebuild_targets)
20592}
20593
20594fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
20595    match target.reason {
20596        RebuildSearchReason::Missing => format!("{} is missing", target.label),
20597        RebuildSearchReason::Stale { stale_files } => {
20598            let file_suffix = if stale_files == 1 { "" } else { "s" };
20599            format!(
20600                "{} is stale ({} file{})",
20601                target.label, stale_files, file_suffix
20602            )
20603        }
20604    }
20605}
20606
20607fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
20608    if rebuild_targets.len() == 1 {
20609        let target = &rebuild_targets[0];
20610        return format!(
20611            "{}. Run `{}` to rebuild before retrying.",
20612            rebuild_search_target_detail(target),
20613            target.reindex_cmd
20614        );
20615    }
20616
20617    let summary: Vec<String> = rebuild_targets
20618        .iter()
20619        .take(3)
20620        .map(rebuild_search_target_detail)
20621        .collect();
20622    let overflow = rebuild_targets.len().saturating_sub(summary.len());
20623    let mut details = summary.join(", ");
20624    if overflow > 0 {
20625        details.push_str(&format!(", +{} more", overflow));
20626    }
20627    let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
20628    format!(
20629        "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
20630        rebuild_targets.len(),
20631        details,
20632        reindex_cmd
20633    )
20634}
20635
20636pub(crate) fn precheck_search_indexes(
20637    root: &Path,
20638    path_hint: &Path,
20639    scope: Option<&str>,
20640    federated: bool,
20641    autoindex: bool,
20642) -> Result<SearchPrecheck> {
20643    let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
20644    let mut stale_targets = Vec::new();
20645    let mut degraded_targets = Vec::new();
20646
20647    for target in &targets {
20648        match inspect_search_index(target)? {
20649            SearchIndexState::Missing => {
20650                if autoindex && let Err(err) = apply_search_index_update(root, target) {
20651                    if is_active_writer_lock_error(&err) {
20652                        degraded_targets
20653                            .push(degraded_search_target(target, RebuildSearchReason::Missing));
20654                    } else {
20655                        return Err(err);
20656                    }
20657                }
20658            }
20659            SearchIndexState::Fresh => {}
20660            SearchIndexState::Stale { stale_files } => {
20661                if autoindex {
20662                    if let Err(err) = apply_search_index_update(root, target) {
20663                        if is_active_writer_lock_error(&err) {
20664                            degraded_targets.push(degraded_search_target(
20665                                target,
20666                                RebuildSearchReason::Stale { stale_files },
20667                            ));
20668                        } else {
20669                            return Err(err);
20670                        }
20671                    }
20672                } else {
20673                    stale_targets.push(RebuildSearchTarget {
20674                        label: target.label.clone(),
20675                        reason: RebuildSearchReason::Stale { stale_files },
20676                        reindex_cmd: target.reindex_cmd.clone(),
20677                    });
20678                }
20679            }
20680        }
20681    }
20682
20683    if stale_targets.is_empty() {
20684        return Ok(SearchPrecheck {
20685            targets,
20686            degraded_targets,
20687        });
20688    }
20689
20690    bail!(
20691        "tsift search aborted: {} \
20692         or re-run without `--no-autoindex`.",
20693        rebuild_search_targets_message(&stale_targets),
20694    );
20695}
20696
20697pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
20698    if targets.is_empty() {
20699        return None;
20700    }
20701
20702    if targets
20703        .iter()
20704        .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
20705    {
20706        Some(DegradedSearchMode::Exact)
20707    } else {
20708        Some(DegradedSearchMode::ReadOnly)
20709    }
20710}
20711
20712fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
20713    if targets.len() == 1 {
20714        let target = &targets[0];
20715        return match target.reason {
20716            RebuildSearchReason::Missing => format!("{} is missing", target.label),
20717            RebuildSearchReason::Stale { stale_files } => {
20718                let file_suffix = if stale_files == 1 { "" } else { "s" };
20719                format!(
20720                    "{} is stale ({} file{})",
20721                    target.label, stale_files, file_suffix
20722                )
20723            }
20724        };
20725    }
20726
20727    let missing = targets
20728        .iter()
20729        .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
20730        .count();
20731    let stale = targets.len().saturating_sub(missing);
20732    let mut parts = Vec::new();
20733    if stale > 0 {
20734        let suffix = if stale == 1 { "" } else { "es" };
20735        parts.push(format!("{stale} stale index{suffix}"));
20736    }
20737    if missing > 0 {
20738        let suffix = if missing == 1 { "" } else { "es" };
20739        parts.push(format!("{missing} missing index{suffix}"));
20740    }
20741    parts.join(", ")
20742}
20743
20744pub(crate) fn emit_degraded_search_note(
20745    targets: &[DegradedSearchTarget],
20746    mode: DegradedSearchMode,
20747) {
20748    let summary = degraded_search_targets_summary(targets);
20749    let reindex_cmd = &targets[0].reindex_cmd;
20750    match mode {
20751        DegradedSearchMode::ReadOnly => eprintln!(
20752            "note: active tsift writer detected; skipping autoindex because {}. \
20753             Continuing with read-only search and the current index snapshot; symbol hits may lag. \
20754             Retry `{}` after the active writer finishes for fresh index results.",
20755            summary, reindex_cmd
20756        ),
20757        DegradedSearchMode::Exact => eprintln!(
20758            "note: active tsift writer detected; skipping autoindex because {}. \
20759             Continuing with exact live-file search. Retry `{}` after the active writer finishes \
20760             for indexed symbol hits.",
20761            summary, reindex_cmd
20762        ),
20763    }
20764}
20765
20766fn search_timeout_message(
20767    timeout_secs: u64,
20768    strategy: &str,
20769    targets: &[SearchIndexTarget],
20770) -> Result<String> {
20771    let rebuild_targets = collect_rebuild_search_targets(targets)?;
20772    if rebuild_targets.is_empty() {
20773        return Ok(format!(
20774            "tsift search timed out after {}s (strategy: {}). \
20775             The search root looks fresh, so reindexing is unlikely to help. \
20776             Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
20777             or try a different strategy.",
20778            timeout_secs, strategy,
20779        ));
20780    }
20781
20782    Ok(format!(
20783        "tsift search timed out after {}s (strategy: {}). {}",
20784        timeout_secs,
20785        strategy,
20786        rebuild_search_targets_message(&rebuild_targets),
20787    ))
20788}
20789
20790fn is_exact_preferring_query_char(ch: char) -> bool {
20791    matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
20792}
20793
20794fn query_prefers_exact_search(query: &str) -> bool {
20795    let trimmed = query.trim();
20796    !trimmed.is_empty()
20797        && !trimmed.chars().any(char::is_whitespace)
20798        && trimmed.chars().any(|ch| ch.is_alphanumeric())
20799        && trimmed.chars().any(is_exact_preferring_query_char)
20800        && trimmed
20801            .chars()
20802            .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
20803}
20804
20805pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
20806    strategy.unwrap_or_else(|| {
20807        if query_prefers_exact_search(query) {
20808            "exact".to_string()
20809        } else {
20810            "lexical".to_string()
20811        }
20812    })
20813}
20814
20815pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
20816    let mut files = Vec::new();
20817    if path.is_file() {
20818        files.push(path.to_path_buf());
20819        return Ok(files);
20820    }
20821    let walker = ignore::WalkBuilder::new(path)
20822        .hidden(true)
20823        .git_ignore(true)
20824        .build();
20825    for entry in walker {
20826        let entry = entry?;
20827        if entry.file_type().is_some_and(|ft| ft.is_file()) {
20828            let p = entry.path();
20829            if let Some(ext) = p.extension() {
20830                let ext = ext.to_string_lossy();
20831                if matches!(
20832                    ext.as_ref(),
20833                    "rs" | "py"
20834                        | "ts"
20835                        | "tsx"
20836                        | "js"
20837                        | "jsx"
20838                        | "kt"
20839                        | "kts"
20840                        | "zig"
20841                        | "sh"
20842                        | "bash"
20843                        | "zsh"
20844                ) {
20845                    files.push(p.to_path_buf());
20846                }
20847            }
20848        }
20849    }
20850    Ok(files)
20851}
20852
20853#[cfg(test)]
20854mod tests {
20855    use super::semantic_edit::{
20856        EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
20857        markdown_section_spans,
20858    };
20859    use super::*;
20860    use tsift_memory::{MemoryEventKind, MemoryStore};
20861
20862    use std::cell::RefCell;
20863    use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
20864    fn parse_cli<I, T>(itr: I) -> Cli
20865    where
20866        I: IntoIterator<Item = T> + Send + 'static,
20867        T: Into<std::ffi::OsString> + Clone + Send + 'static,
20868    {
20869        std::thread::Builder::new()
20870            .name("cli-parse".to_string())
20871            .stack_size(16 * 1024 * 1024)
20872            .spawn(move || Cli::parse_from(itr))
20873            .unwrap()
20874            .join()
20875            .unwrap()
20876    }
20877
20878    fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
20879    where
20880        I: IntoIterator<Item = T> + Send + 'static,
20881        T: Into<std::ffi::OsString> + Clone + Send + 'static,
20882    {
20883        std::thread::Builder::new()
20884            .name("cli-try-parse".to_string())
20885            .stack_size(16 * 1024 * 1024)
20886            .spawn(move || Cli::try_parse_from(itr))
20887            .unwrap()
20888            .join()
20889            .unwrap()
20890    }
20891
20892    fn build_relative_search_budget_report(
20893        query: &str,
20894        strategy: &str,
20895        root: &Path,
20896        response: &sift::SearchResponse,
20897        symbol_hits: &[index::SymbolHit],
20898        budget: ResponseBudget,
20899        filters: &SearchFacetFilters,
20900    ) -> SearchBudgetReport {
20901        build_search_budget_report(SearchBudgetReportInput {
20902            query,
20903            strategy,
20904            root,
20905            response,
20906            symbol_hits,
20907            absolute: false,
20908            budget,
20909            filters,
20910        })
20911    }
20912
20913    #[derive(Default)]
20914    struct MemoryConvexGraphClient {
20915        nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
20916        edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
20917    }
20918
20919    impl ConvexGraphClient for MemoryConvexGraphClient {
20920        fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
20921            self.nodes
20922                .borrow_mut()
20923                .insert(row.external_id.clone(), row.clone());
20924            Ok(())
20925        }
20926
20927        fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
20928            self.edges
20929                .borrow_mut()
20930                .insert(row.edge_key.clone(), row.clone());
20931            Ok(())
20932        }
20933
20934        fn delete_node_row(&self, external_id: &str) -> Result<usize> {
20935            Ok(usize::from(
20936                self.nodes.borrow_mut().remove(external_id).is_some(),
20937            ))
20938        }
20939
20940        fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
20941            Ok(usize::from(
20942                self.edges.borrow_mut().remove(edge_key).is_some(),
20943            ))
20944        }
20945
20946        fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
20947            Ok(self.nodes.borrow().get(external_id).cloned())
20948        }
20949
20950        fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
20951            Ok(self.nodes.borrow().values().cloned().collect())
20952        }
20953
20954        fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
20955            Ok(self.edges.borrow().values().cloned().collect())
20956        }
20957
20958        fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
20959            Ok(self
20960                .nodes
20961                .borrow()
20962                .values()
20963                .filter(|row| row.kind == kind)
20964                .cloned()
20965                .collect())
20966        }
20967
20968        fn outgoing_edge_rows(
20969            &self,
20970            from_external_id: &str,
20971            kind: Option<&str>,
20972        ) -> Result<Vec<ConvexEdgeRow>> {
20973            Ok(self
20974                .edges
20975                .borrow()
20976                .values()
20977                .filter(|row| row.from_external_id == from_external_id)
20978                .filter(|row| kind.is_none_or(|kind| row.kind == kind))
20979                .cloned()
20980                .collect())
20981        }
20982    }
20983
20984    fn init_git_repo(path: &Path) {
20985        let status = std::process::Command::new("git")
20986            .args(["init"])
20987            .current_dir(path)
20988            .status()
20989            .unwrap();
20990        assert!(status.success(), "git init failed");
20991
20992        let status = std::process::Command::new("git")
20993            .args(["add", "."])
20994            .current_dir(path)
20995            .status()
20996            .unwrap();
20997        assert!(status.success(), "git add failed");
20998
20999        let status = std::process::Command::new("git")
21000            .args([
21001                "-c",
21002                "user.name=tsift-tests",
21003                "-c",
21004                "user.email=tsift-tests@example.com",
21005                "commit",
21006                "--quiet",
21007                "-m",
21008                "init",
21009            ])
21010            .current_dir(path)
21011            .status()
21012            .unwrap();
21013        assert!(status.success(), "git commit failed");
21014    }
21015
21016    fn write_empty_root_index(root: &Path) {
21017        let index_dir = root.join(".tsift");
21018        fs::create_dir_all(&index_dir).unwrap();
21019        fs::write(index_dir.join("index.db"), "").unwrap();
21020    }
21021
21022    fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
21023        if let Some(parent) = path.parent() {
21024            fs::create_dir_all(parent).unwrap();
21025        }
21026        let body = std::iter::repeat_n(line, lines)
21027            .collect::<Vec<_>>()
21028            .join("\n");
21029        fs::write(path, format!("{body}\n")).unwrap();
21030        path.to_path_buf()
21031    }
21032
21033    // --- build_token_capped_preview ---
21034
21035    #[test]
21036    fn token_capped_preview_returns_all_lines_when_under_cap() {
21037        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 1", "}"];
21038        let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
21039        assert!(!result.was_capped);
21040        assert_eq!(result.preview.len(), 3);
21041        assert_eq!(result.capped_end, 3);
21042    }
21043
21044    #[test]
21045    fn token_capped_preview_truncates_when_over_cap() {
21046        let lines: Vec<&str> = (0..200)
21047            .map(|_| "    let x = some_very_long_expression_here();")
21048            .collect();
21049        let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
21050        assert!(result.was_capped);
21051        assert!(result.preview.len() < 200);
21052        assert!(result.capped_end < 200);
21053    }
21054
21055    #[test]
21056    fn token_capped_preview_keeps_at_least_one_line() {
21057        let long_line: String = "x".repeat(8000);
21058        let lines: Vec<&str> = vec![&long_line];
21059        let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
21060        assert!(!result.was_capped);
21061        assert_eq!(result.preview.len(), 1);
21062    }
21063
21064    #[test]
21065    fn token_capped_preview_cap_at_boundary() {
21066        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21067        let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
21068        assert!(!result.was_capped);
21069        assert_eq!(result.preview.len(), 4);
21070    }
21071
21072    #[test]
21073    fn token_capped_preview_cap_just_over_boundary() {
21074        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21075        let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
21076        assert!(result.was_capped);
21077        assert_eq!(result.preview.len(), 3);
21078        assert_eq!(result.capped_end, 3);
21079    }
21080
21081    #[test]
21082    fn token_capped_preview_empty_lines() {
21083        let lines: Vec<&str> = vec![];
21084        let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
21085        assert!(!result.was_capped);
21086        assert!(result.preview.is_empty());
21087    }
21088
21089    #[test]
21090    fn token_capped_preview_per_line_truncation_applied() {
21091        let long_line = "x".repeat(500);
21092        let lines: Vec<&str> = vec![&long_line, "short"];
21093        let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
21094        assert!(!result.was_capped);
21095        assert_eq!(result.preview.len(), 2);
21096        assert!(result.preview[0].text.len() <= 23);
21097        assert!(result.preview[0].text.ends_with("..."));
21098    }
21099
21100    // --- classify_task ---
21101
21102    #[test]
21103    fn route_search_defaults_to_haiku() {
21104        let (tier, model) = classify_task("find all uses of authenticate");
21105        assert_eq!(tier, "haiku");
21106        assert!(
21107            model.contains("haiku"),
21108            "expected haiku model, got {}",
21109            model
21110        );
21111    }
21112
21113    #[test]
21114    fn route_edit_keywords_to_sonnet() {
21115        for kw in &[
21116            "edit the file",
21117            "fix the bug",
21118            "update the config",
21119            "remove dead code",
21120            "create a new module",
21121        ] {
21122            let (tier, _) = classify_task(kw);
21123            assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
21124        }
21125    }
21126
21127    #[test]
21128    fn route_architecture_keywords_to_opus() {
21129        for kw in &[
21130            "design the API",
21131            "architecture review",
21132            "plan the migration",
21133            "analyze the system",
21134            "evaluate trade-offs",
21135        ] {
21136            let (tier, _) = classify_task(kw);
21137            assert_eq!(tier, "opus", "expected opus for {:?}", kw);
21138        }
21139    }
21140
21141    #[test]
21142    fn route_architecture_beats_edit() {
21143        // "design and implement" — architecture signal wins (checked first)
21144        let (tier, _) = classify_task("design and implement the new auth service");
21145        assert_eq!(tier, "opus");
21146    }
21147
21148    #[test]
21149    fn cli_accepts_global_compact_flag() {
21150        let cli = parse_cli(["tsift", "--compact", "status"]);
21151        assert!(cli.compact);
21152        assert!(matches!(cli.command, Some(Commands::Status { .. })));
21153    }
21154
21155    #[test]
21156    fn summarize_diff_scope_matches_relative_directory() {
21157        let root = Path::new("/repo");
21158        let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
21159
21160        assert!(summarize_diff_matches_scope(
21161            Path::new("/repo/src/feature/main.rs"),
21162            &extract_scope
21163        ));
21164        assert!(!summarize_diff_matches_scope(
21165            Path::new("/repo/src/other/main.rs"),
21166            &extract_scope
21167        ));
21168    }
21169
21170    #[test]
21171    fn summarize_diff_scope_matches_relative_file() {
21172        let root = Path::new("/repo");
21173        let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
21174
21175        assert!(summarize_diff_matches_scope(
21176            Path::new("/repo/src/feature/main.rs"),
21177            &extract_scope
21178        ));
21179        assert!(!summarize_diff_matches_scope(
21180            Path::new("/repo/src/feature/lib.rs"),
21181            &extract_scope
21182        ));
21183    }
21184
21185    #[test]
21186    fn summarize_extract_scope_walks_relative_paths_from_root() {
21187        let dir = tempfile::tempdir().unwrap();
21188        let source_dir = dir.path().join("src");
21189        std::fs::create_dir_all(&source_dir).unwrap();
21190        let main_rs = source_dir.join("main.rs");
21191        std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
21192
21193        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
21194        let files = collect_source_files(&extract_scope).unwrap();
21195
21196        assert_eq!(files, vec![main_rs]);
21197    }
21198
21199    #[test]
21200    fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
21201        let dir = tempfile::tempdir().unwrap();
21202        let nested = dir.path().join("src/nested");
21203        std::fs::create_dir_all(&nested).unwrap();
21204        std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
21205        let nested_file = nested.join("main.rs");
21206        std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
21207
21208        let extract_base = resolve_extract_base(&nested).unwrap();
21209        let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
21210        let files = collect_source_files(&extract_scope).unwrap();
21211
21212        assert_eq!(extract_scope, nested);
21213        assert_eq!(files, vec![nested_file]);
21214    }
21215
21216    #[test]
21217    fn summarize_extract_base_uses_parent_of_file_path() {
21218        let dir = tempfile::tempdir().unwrap();
21219        let nested = dir.path().join("src/nested");
21220        std::fs::create_dir_all(&nested).unwrap();
21221        let file_path = nested.join("main.rs");
21222        std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
21223
21224        let extract_base = resolve_extract_base(&file_path).unwrap();
21225
21226        assert_eq!(extract_base, nested);
21227    }
21228
21229    #[test]
21230    fn summarize_extract_scope_normalizes_dotdot_segments() {
21231        let dir = tempfile::tempdir().unwrap();
21232        let source_dir = dir.path().join("src");
21233        std::fs::create_dir_all(&source_dir).unwrap();
21234
21235        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
21236
21237        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21238        assert!(summarize_diff_matches_scope(
21239            &source_dir.join("main.rs"),
21240            &extract_scope
21241        ));
21242    }
21243
21244    #[cfg(unix)]
21245    #[test]
21246    fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
21247        use std::os::unix::fs::symlink;
21248
21249        let dir = tempfile::tempdir().unwrap();
21250        let real_root = dir.path().join("real");
21251        let source_dir = real_root.join("src");
21252        std::fs::create_dir_all(&source_dir).unwrap();
21253        let symlink_scope = dir.path().join("scope-link");
21254        symlink(&source_dir, &symlink_scope).unwrap();
21255
21256        let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
21257
21258        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21259        assert!(summarize_diff_matches_scope(
21260            &source_dir.join("lib.rs"),
21261            &extract_scope
21262        ));
21263    }
21264
21265    #[test]
21266    fn summarize_diff_extract_includes_untracked_files() {
21267        let dir = tempfile::tempdir().unwrap();
21268        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21269        init_git_repo(dir.path());
21270
21271        let source_dir = dir.path().join("src");
21272        std::fs::create_dir_all(&source_dir).unwrap();
21273        let new_file = source_dir.join("new.rs");
21274        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21275
21276        let files = summarize::git_changed_files(dir.path()).unwrap();
21277
21278        assert_eq!(files.existing, vec![new_file]);
21279        assert!(files.deleted.is_empty());
21280    }
21281
21282    #[test]
21283    fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
21284        let dir = tempfile::tempdir().unwrap();
21285        let status = std::process::Command::new("git")
21286            .args(["init"])
21287            .current_dir(dir.path())
21288            .status()
21289            .unwrap();
21290        assert!(status.success(), "git init failed");
21291
21292        let source_dir = dir.path().join("src");
21293        std::fs::create_dir_all(&source_dir).unwrap();
21294        let new_file = source_dir.join("new.rs");
21295        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21296
21297        let files = summarize::git_changed_files(dir.path()).unwrap();
21298
21299        assert_eq!(files.existing, vec![new_file]);
21300        assert!(files.deleted.is_empty());
21301    }
21302
21303    #[test]
21304    fn summarize_diff_extract_tracks_deleted_files() {
21305        let dir = tempfile::tempdir().unwrap();
21306        let source_dir = dir.path().join("src");
21307        std::fs::create_dir_all(&source_dir).unwrap();
21308        let deleted_file = source_dir.join("gone.rs");
21309        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21310        init_git_repo(dir.path());
21311
21312        std::fs::remove_file(&deleted_file).unwrap();
21313
21314        let files = summarize::git_changed_files(dir.path()).unwrap();
21315
21316        assert!(files.existing.is_empty());
21317        assert_eq!(files.deleted, vec![deleted_file]);
21318    }
21319
21320    #[test]
21321    fn summarize_diff_extract_tracks_git_renames() {
21322        let dir = tempfile::tempdir().unwrap();
21323        let source_dir = dir.path().join("src");
21324        std::fs::create_dir_all(&source_dir).unwrap();
21325        let old_file = source_dir.join("old.rs");
21326        let new_file = source_dir.join("new.rs");
21327        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21328        init_git_repo(dir.path());
21329
21330        let status = std::process::Command::new("git")
21331            .args(["mv", "src/old.rs", "src/new.rs"])
21332            .current_dir(dir.path())
21333            .status()
21334            .unwrap();
21335        assert!(status.success(), "git mv failed");
21336
21337        let files = summarize::git_changed_files(dir.path()).unwrap();
21338
21339        assert_eq!(files.existing, vec![new_file]);
21340        assert_eq!(files.deleted, vec![old_file]);
21341    }
21342
21343    #[test]
21344    fn summarize_diff_extract_deletes_removed_summary_rows() {
21345        let dir = tempfile::tempdir().unwrap();
21346        let source_dir = dir.path().join("src");
21347        std::fs::create_dir_all(&source_dir).unwrap();
21348        let deleted_file = source_dir.join("gone.rs");
21349        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21350        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21351        init_git_repo(dir.path());
21352
21353        let summary_db =
21354            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21355        summary_db
21356            .insert(&summarize::Summary {
21357                id: 0,
21358                symbol_name: "stale".to_string(),
21359                file_path: "src/gone.rs".to_string(),
21360                content_hash: "hash1".to_string(),
21361                summary: "stale summary".to_string(),
21362                entities: None,
21363                relationships: None,
21364                concept_labels: None,
21365                extracted_at: "1700000000".to_string(),
21366                model: "test".to_string(),
21367                tokens_input: Some(100),
21368                tokens_output: Some(50),
21369            })
21370            .unwrap();
21371
21372        std::fs::remove_file(&deleted_file).unwrap();
21373
21374        cmd_summarize(
21375            None,
21376            None,
21377            Some(PathBuf::from("src")),
21378            true,
21379            false,
21380            dir.path(),
21381            false,
21382            true,
21383            false,
21384            false,
21385            false,
21386        )
21387        .unwrap();
21388
21389        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21390    }
21391
21392    #[test]
21393    fn summarize_diff_extract_deletes_renamed_summary_rows() {
21394        let dir = tempfile::tempdir().unwrap();
21395        let source_dir = dir.path().join("src");
21396        std::fs::create_dir_all(&source_dir).unwrap();
21397        let old_file = source_dir.join("old.rs");
21398        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21399        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21400        init_git_repo(dir.path());
21401
21402        let summary_db =
21403            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21404        summary_db
21405            .insert(&summarize::Summary {
21406                id: 0,
21407                symbol_name: "stale".to_string(),
21408                file_path: "src/old.rs".to_string(),
21409                content_hash: "hash1".to_string(),
21410                summary: "stale summary".to_string(),
21411                entities: None,
21412                relationships: None,
21413                concept_labels: None,
21414                extracted_at: "1700000000".to_string(),
21415                model: "test".to_string(),
21416                tokens_input: Some(100),
21417                tokens_output: Some(50),
21418            })
21419            .unwrap();
21420
21421        let status = std::process::Command::new("git")
21422            .args(["mv", "src/old.rs", "src/new.rs"])
21423            .current_dir(dir.path())
21424            .status()
21425            .unwrap();
21426        assert!(status.success(), "git mv failed");
21427
21428        cmd_summarize(
21429            None,
21430            None,
21431            Some(PathBuf::from("src")),
21432            true,
21433            false,
21434            dir.path(),
21435            false,
21436            true,
21437            false,
21438            false,
21439            false,
21440        )
21441        .unwrap();
21442
21443        assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
21444    }
21445
21446    #[test]
21447    fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
21448        let dir = tempfile::tempdir().unwrap();
21449        let source_dir = dir.path().join("src");
21450        std::fs::create_dir_all(&source_dir).unwrap();
21451        let deleted_file = source_dir.join("gone.rs");
21452        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21453
21454        let summary_db =
21455            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21456        summary_db
21457            .insert(&summarize::Summary {
21458                id: 0,
21459                symbol_name: "stale".to_string(),
21460                file_path: "src/gone.rs".to_string(),
21461                content_hash: "hash1".to_string(),
21462                summary: "stale summary".to_string(),
21463                entities: None,
21464                relationships: None,
21465                concept_labels: None,
21466                extracted_at: "1700000000".to_string(),
21467                model: "test".to_string(),
21468                tokens_input: Some(100),
21469                tokens_output: Some(50),
21470            })
21471            .unwrap();
21472
21473        std::fs::remove_file(&deleted_file).unwrap();
21474
21475        cmd_summarize(
21476            None,
21477            None,
21478            Some(PathBuf::from("src")),
21479            false,
21480            false,
21481            dir.path(),
21482            false,
21483            true,
21484            false,
21485            false,
21486            false,
21487        )
21488        .unwrap();
21489
21490        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21491    }
21492
21493    #[test]
21494    fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
21495        let dir = tempfile::tempdir().unwrap();
21496        let source_dir = dir.path().join("src");
21497        std::fs::create_dir_all(&source_dir).unwrap();
21498        let file = source_dir.join("lib.rs");
21499        std::fs::write(&file, "fn helper() {}\n").unwrap();
21500
21501        let content = std::fs::read(&file).unwrap();
21502        let summary_db =
21503            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21504        summary_db
21505            .insert(&summarize::Summary {
21506                id: 0,
21507                symbol_name: "lib.rs".to_string(),
21508                file_path: "src/lib.rs".to_string(),
21509                content_hash: summarize::content_hash(&content),
21510                summary: "cached summary".to_string(),
21511                entities: None,
21512                relationships: None,
21513                concept_labels: None,
21514                extracted_at: "1700000000".to_string(),
21515                model: "test".to_string(),
21516                tokens_input: Some(100),
21517                tokens_output: Some(50),
21518            })
21519            .unwrap();
21520        drop(summary_db);
21521
21522        let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
21523        let _lock = hold_writer_lock(&lock_path);
21524
21525        let err = cmd_summarize(
21526            None,
21527            None,
21528            Some(PathBuf::from("src")),
21529            false,
21530            false,
21531            dir.path(),
21532            false,
21533            true,
21534            false,
21535            false,
21536            false,
21537        )
21538        .unwrap_err();
21539        let message = err.to_string();
21540
21541        assert!(message.contains("another tsift summarize extractor is already active"));
21542        assert!(message.contains("tsift summarize --extract"));
21543    }
21544
21545    #[test]
21546    fn summarize_stats_fails_closed_when_cache_missing() {
21547        let dir = tempfile::tempdir().unwrap();
21548        let err = cmd_summarize(
21549            None,
21550            None,
21551            None,
21552            false,
21553            true,
21554            dir.path(),
21555            false,
21556            false,
21557            false,
21558            false,
21559            false,
21560        )
21561        .unwrap_err();
21562
21563        assert!(
21564            err.to_string().contains("no summaries.db found"),
21565            "got: {err}"
21566        );
21567        assert!(!dir.path().join(".tsift/summaries.db").exists());
21568    }
21569
21570    #[test]
21571    fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21572        let dir = tempfile::tempdir().unwrap();
21573        let summary_db =
21574            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21575        summary_db
21576            .insert(&summarize::Summary {
21577                id: 0,
21578                symbol_name: "alpha_helper".to_string(),
21579                file_path: "src/lib.rs".to_string(),
21580                content_hash: "hash1".to_string(),
21581                summary: "cached summary".to_string(),
21582                entities: None,
21583                relationships: None,
21584                concept_labels: None,
21585                extracted_at: "1700000000".to_string(),
21586                model: "claude-haiku-4-5-20251001".to_string(),
21587                tokens_input: Some(100),
21588                tokens_output: Some(40),
21589            })
21590            .unwrap();
21591        drop(summary_db);
21592        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21593
21594        let result = cmd_summarize(
21595            None,
21596            None,
21597            None,
21598            false,
21599            true,
21600            dir.path(),
21601            false,
21602            false,
21603            false,
21604            false,
21605            false,
21606        );
21607
21608        assert!(result.is_ok());
21609    }
21610
21611    #[test]
21612    fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21613        let dir = tempfile::tempdir().unwrap();
21614        let summary_db =
21615            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21616        summary_db
21617            .insert(&summarize::Summary {
21618                id: 0,
21619                symbol_name: "alpha_helper".to_string(),
21620                file_path: "src/lib.rs".to_string(),
21621                content_hash: "hash1".to_string(),
21622                summary: "cached summary".to_string(),
21623                entities: None,
21624                relationships: None,
21625                concept_labels: None,
21626                extracted_at: "1700000000".to_string(),
21627                model: "claude-haiku-4-5-20251001".to_string(),
21628                tokens_input: Some(100),
21629                tokens_output: Some(40),
21630            })
21631            .unwrap();
21632        drop(summary_db);
21633        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21634
21635        let result = cmd_summarize(
21636            Some("alpha_helper".to_string()),
21637            None,
21638            None,
21639            false,
21640            false,
21641            dir.path(),
21642            false,
21643            true,
21644            false,
21645            false,
21646            false,
21647        );
21648
21649        assert!(result.is_ok());
21650    }
21651
21652    #[test]
21653    fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
21654        let dir = tempfile::tempdir().unwrap();
21655        let nested = dir.path().join("src/nested");
21656        std::fs::create_dir_all(&nested).unwrap();
21657
21658        let summary_db =
21659            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21660        summary_db
21661            .insert(&summarize::Summary {
21662                id: 0,
21663                symbol_name: "alpha_helper".to_string(),
21664                file_path: "src/lib.rs".to_string(),
21665                content_hash: "hash1".to_string(),
21666                summary: "cached summary".to_string(),
21667                entities: None,
21668                relationships: None,
21669                concept_labels: None,
21670                extracted_at: "1700000000".to_string(),
21671                model: "claude-haiku-4-5-20251001".to_string(),
21672                tokens_input: Some(100),
21673                tokens_output: Some(40),
21674            })
21675            .unwrap();
21676
21677        let result = cmd_summarize(
21678            Some("alpha_helper".to_string()),
21679            None,
21680            None,
21681            false,
21682            false,
21683            &nested,
21684            false,
21685            true,
21686            false,
21687            false,
21688            false,
21689        );
21690
21691        assert!(result.is_ok());
21692        assert!(!nested.join(".tsift/summaries.db").exists());
21693    }
21694
21695    #[test]
21696    fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
21697        let dir = tempfile::tempdir().unwrap();
21698        std::fs::write(
21699            dir.path().join(".gitmodules"),
21700            r#"[submodule "src/alpha"]
21701	path = src/alpha
21702	url = https://example.com/alpha
21703[submodule "src/beta"]
21704	path = src/beta
21705	url = https://example.com/beta
21706"#,
21707        )
21708        .unwrap();
21709
21710        let alpha_root = dir.path().join("src/alpha");
21711        let beta_root = dir.path().join("src/beta");
21712        std::fs::create_dir_all(alpha_root.join("src")).unwrap();
21713        std::fs::create_dir_all(beta_root.join("src")).unwrap();
21714        std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
21715        std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
21716        std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
21717        let beta_file = beta_root.join("src/lib.rs");
21718        std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
21719        std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
21720        std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
21721
21722        let context = find_symbols_db_for_file(dir.path(), &beta_file)
21723            .unwrap()
21724            .expect("expected matching scoped index");
21725
21726        assert_eq!(
21727            context.db_path,
21728            dir.path().join(".tsift/indexes/beta/index.db")
21729        );
21730        assert_eq!(context.source_root, beta_root);
21731    }
21732
21733    // --- apply_edit_op ---
21734
21735    fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
21736        EditOp {
21737            file: PathBuf::from("dummy.txt"),
21738            old: old.to_string(),
21739            new: new.to_string(),
21740            replace_all,
21741        }
21742    }
21743
21744    #[test]
21745    fn edit_replaces_single_occurrence() {
21746        let content = "hello world";
21747        let op = make_op("world", "rust", false);
21748        let (result, count) = apply_edit_op(content, &op).unwrap();
21749        assert_eq!(result, "hello rust");
21750        assert_eq!(count, 1);
21751    }
21752
21753    #[test]
21754    fn edit_replace_all_replaces_every_occurrence() {
21755        let content = "foo foo foo";
21756        let op = make_op("foo", "bar", true);
21757        let (result, count) = apply_edit_op(content, &op).unwrap();
21758        assert_eq!(result, "bar bar bar");
21759        assert_eq!(count, 3);
21760    }
21761
21762    #[test]
21763    fn edit_fails_when_old_not_found() {
21764        let content = "hello world";
21765        let op = make_op("missing", "x", false);
21766        assert!(apply_edit_op(content, &op).is_err());
21767    }
21768
21769    #[test]
21770    fn edit_fails_when_ambiguous_without_replace_all() {
21771        let content = "foo foo";
21772        let op = make_op("foo", "bar", false);
21773        let err = apply_edit_op(content, &op).unwrap_err();
21774        assert!(err.to_string().contains("2 times"), "got: {}", err);
21775    }
21776
21777    #[test]
21778    fn edit_fails_when_old_equals_new() {
21779        let content = "hello";
21780        let op = make_op("hello", "hello", false);
21781        assert!(apply_edit_op(content, &op).is_err());
21782    }
21783
21784    #[test]
21785    fn edit_batch_rolls_back_when_later_swap_fails() {
21786        let dir = tempfile::tempdir().unwrap();
21787        let alpha = dir.path().join("alpha.txt");
21788        let beta = dir.path().join("beta.txt");
21789        fs::write(&alpha, "alpha old\n").unwrap();
21790        fs::write(&beta, "beta old\n").unwrap();
21791
21792        let batch = EditBatch {
21793            edits: vec![
21794                EditOp {
21795                    file: alpha.clone(),
21796                    old: "old".to_string(),
21797                    new: "new".to_string(),
21798                    replace_all: false,
21799                },
21800                EditOp {
21801                    file: beta.clone(),
21802                    old: "old".to_string(),
21803                    new: "new".to_string(),
21804                    replace_all: false,
21805                },
21806            ],
21807        };
21808
21809        let plan = build_edit_plan(&batch).unwrap();
21810        let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
21811            if commit_index == 1 {
21812                bail!("simulated swap failure");
21813            }
21814            Ok(())
21815        }) {
21816            Ok(_) => panic!("expected simulated swap failure"),
21817            Err(err) => err,
21818        };
21819
21820        assert!(err.to_string().contains("simulated swap failure"));
21821        assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
21822        assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
21823    }
21824
21825    // --- SQL introspection ---
21826
21827    fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
21828        let tmp = tempfile::NamedTempFile::new().unwrap();
21829        let conn = Connection::open(tmp.path()).unwrap();
21830        conn.execute_batch(
21831            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
21832             INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
21833             INSERT INTO users VALUES (2, 'Bob', NULL);
21834             CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
21835                 FOREIGN KEY(user_id) REFERENCES users(id));
21836             INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
21837             INSERT INTO posts VALUES (2, 1, 'Second', NULL);
21838             INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
21839        ).unwrap();
21840        (tmp, conn)
21841    }
21842
21843    // --- rewrite_command ---
21844
21845    #[test]
21846    fn rewrite_rg_simple_pattern() {
21847        let result = rewrite_command("rg authenticate");
21848        assert_eq!(
21849            result,
21850            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
21851        );
21852    }
21853
21854    #[test]
21855    fn rewrite_rg_with_path() {
21856        let result = rewrite_command("rg authenticate src/");
21857        assert_eq!(
21858            result,
21859            Some(
21860                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
21861                    .to_string()
21862            )
21863        );
21864    }
21865
21866    #[test]
21867    fn rewrite_rg_with_flags_ignored() {
21868        let result = rewrite_command("rg -i authenticate src/");
21869        assert_eq!(
21870            result,
21871            Some(
21872                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
21873                    .to_string()
21874            )
21875        );
21876    }
21877
21878    #[test]
21879    fn rewrite_rg_with_type_flag() {
21880        // -t rs takes a value, should be skipped; pattern is next positional
21881        let result = rewrite_command("rg -t rs authenticate");
21882        assert_eq!(
21883            result,
21884            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
21885        );
21886    }
21887
21888    #[test]
21889    fn rewrite_rg_pipe_passthrough() {
21890        // Pipe chains can't be translated — pass through
21891        let result = rewrite_command("rg authenticate | head -5");
21892        assert_eq!(result, None);
21893    }
21894
21895    #[test]
21896    fn rewrite_rg_files_passthrough() {
21897        let result = rewrite_command("rg --files src/tsift .agent-doc logs");
21898        assert_eq!(result, None);
21899    }
21900
21901    #[test]
21902    fn rewrite_find_passthrough() {
21903        let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
21904        assert_eq!(result, None);
21905    }
21906
21907    #[test]
21908    fn rewrite_grep_recursive() {
21909        let result = rewrite_command("grep -r authenticate src/");
21910        assert_eq!(
21911            result,
21912            Some(
21913                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
21914                    .to_string()
21915            )
21916        );
21917    }
21918
21919    #[test]
21920    fn rewrite_grep_non_recursive_passthrough() {
21921        let result = rewrite_command("grep authenticate file.txt");
21922        assert_eq!(result, None);
21923    }
21924
21925    #[test]
21926    fn rewrite_tsift_passthrough() {
21927        let result = rewrite_command("tsift search \"foo\"");
21928        assert_eq!(result, Some("tsift search \"foo\"".to_string()));
21929    }
21930
21931    #[test]
21932    fn rewrite_run_tsift_search_disables_timeout_by_default() {
21933        let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
21934        assert_eq!(
21935            result,
21936            "tsift search hookcaps --exact --path /tmp/x --timeout 0"
21937        );
21938    }
21939
21940    #[test]
21941    fn rewrite_run_preserves_explicit_search_timeout() {
21942        let result = effective_rewrite_run_command(
21943            "tsift search hookcaps --exact --path /tmp/x --timeout 5",
21944        );
21945        assert_eq!(
21946            result,
21947            "tsift search hookcaps --exact --path /tmp/x --timeout 5"
21948        );
21949    }
21950
21951    #[test]
21952    fn rewrite_unrelated_passthrough() {
21953        let result = rewrite_command("echo cargo build");
21954        assert_eq!(result, None);
21955    }
21956
21957    #[test]
21958    fn rewrite_rg_quoted_pattern() {
21959        let result = rewrite_command("rg \"fn main\"");
21960        assert_eq!(
21961            result,
21962            Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
21963        );
21964    }
21965
21966    #[test]
21967    fn rewrite_git_diff_to_diff_digest() {
21968        let result = rewrite_command("git diff");
21969        assert_eq!(result, Some("tsift diff-digest .".to_string()));
21970    }
21971
21972    #[test]
21973    fn rewrite_git_diff_cached_to_diff_digest() {
21974        let result = rewrite_command("git diff --cached");
21975        assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
21976    }
21977
21978    #[test]
21979    fn rewrite_git_diff_with_path_to_diff_digest() {
21980        let result = rewrite_command("git diff -- src/");
21981        assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
21982    }
21983
21984    #[test]
21985    fn rewrite_git_diff_with_revision_passthrough() {
21986        let result = rewrite_command("git diff HEAD~1");
21987        assert_eq!(result, None);
21988    }
21989
21990    #[test]
21991    fn rewrite_git_show_to_revision_diff_digest() {
21992        let result = rewrite_command("git show HEAD~1");
21993        assert_eq!(
21994            result,
21995            Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
21996        );
21997    }
21998
21999    #[test]
22000    fn rewrite_git_log_patch_history_to_revision_diff_digest() {
22001        let result = rewrite_command("git log -p -1 HEAD~2");
22002        assert_eq!(
22003            result,
22004            Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
22005        );
22006    }
22007
22008    #[test]
22009    fn rewrite_cat_long_agent_doc_session_to_session_digest() {
22010        let dir = tempfile::tempdir().unwrap();
22011        let session = dir.path().join("tsift.md");
22012        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22013        for index in 0..90 {
22014            body.push_str(&format!("❯ prompt {index}?\n"));
22015        }
22016        fs::write(&session, body).unwrap();
22017
22018        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22019        assert_eq!(
22020            result,
22021            Some(format!(
22022                "tsift session-digest --path {} --input {} --source markdown",
22023                shell_quote(&resolve_digest_context_path(&session)),
22024                shell_quote(session.to_str().unwrap())
22025            ))
22026        );
22027    }
22028
22029    #[test]
22030    fn rewrite_head_long_claude_jsonl_to_session_digest() {
22031        let dir = tempfile::tempdir().unwrap();
22032        let session = dir.path().join("session.jsonl");
22033        let line =
22034            r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
22035        let body = std::iter::repeat_n(line, 120)
22036            .collect::<Vec<_>>()
22037            .join("\n");
22038        fs::write(&session, format!("{body}\n")).unwrap();
22039
22040        let result = rewrite_command(&format!(
22041            "head -n 120 {}",
22042            shell_quote(session.to_str().unwrap())
22043        ));
22044        assert_eq!(
22045            result,
22046            Some(format!(
22047                "tsift session-digest --path {} --input {} --source claude-jsonl",
22048                shell_quote(&resolve_digest_context_path(&session)),
22049                shell_quote(session.to_str().unwrap())
22050            ))
22051        );
22052    }
22053
22054    #[test]
22055    fn rewrite_head_long_codex_jsonl_to_session_digest() {
22056        let dir = tempfile::tempdir().unwrap();
22057        let session = dir.path().join("codex.jsonl");
22058        let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
22059        let body = std::iter::repeat_n(line, 120)
22060            .collect::<Vec<_>>()
22061            .join("\n");
22062        fs::write(&session, format!("{body}\n")).unwrap();
22063
22064        let result = rewrite_command(&format!(
22065            "head -n 120 {}",
22066            shell_quote(session.to_str().unwrap())
22067        ));
22068        assert_eq!(
22069            result,
22070            Some(format!(
22071                "tsift session-digest --path {} --input {} --source codex-jsonl",
22072                shell_quote(&resolve_digest_context_path(&session)),
22073                shell_quote(session.to_str().unwrap())
22074            ))
22075        );
22076    }
22077
22078    #[test]
22079    fn rewrite_small_transcript_window_passthrough() {
22080        let dir = tempfile::tempdir().unwrap();
22081        let session = dir.path().join("session.jsonl");
22082        let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
22083        let body = std::iter::repeat_n(line, 120)
22084            .collect::<Vec<_>>()
22085            .join("\n");
22086        fs::write(&session, format!("{body}\n")).unwrap();
22087
22088        let result = rewrite_command(&format!(
22089            "tail -n 20 {}",
22090            shell_quote(session.to_str().unwrap())
22091        ));
22092        assert_eq!(result, None);
22093    }
22094
22095    #[test]
22096    fn rewrite_sed_large_agent_doc_range_to_session_digest() {
22097        let dir = tempfile::tempdir().unwrap();
22098        let session = dir.path().join("tsift.md");
22099        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22100        for index in 0..120 {
22101            body.push_str(&format!("### Re: topic {index}\n"));
22102        }
22103        fs::write(&session, body).unwrap();
22104
22105        let result = rewrite_command(&format!(
22106            "sed -n '1,120p' {}",
22107            shell_quote(session.to_str().unwrap())
22108        ));
22109        assert_eq!(
22110            result,
22111            Some(format!(
22112                "tsift session-digest --path {} --input {} --source markdown",
22113                shell_quote(&resolve_digest_context_path(&session)),
22114                shell_quote(session.to_str().unwrap())
22115            ))
22116        );
22117    }
22118
22119    #[test]
22120    fn rewrite_cat_large_agent_doc_log_to_session_digest() {
22121        let dir = tempfile::tempdir().unwrap();
22122        let session = dir.path().join("tsift.log");
22123        let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
22124        let body = std::iter::repeat_n(line, 120)
22125            .collect::<Vec<_>>()
22126            .join("\n");
22127        fs::write(&session, format!("{body}\n")).unwrap();
22128
22129        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22130        assert_eq!(
22131            result,
22132            Some(format!(
22133                "tsift session-digest --path {} --input {} --source agent-doc-log",
22134                shell_quote(&resolve_digest_context_path(&session)),
22135                shell_quote(session.to_str().unwrap())
22136            ))
22137        );
22138    }
22139
22140    #[test]
22141    fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
22142        let dir = tempfile::tempdir().unwrap();
22143        fs::write(
22144            dir.path().join(".gitmodules"),
22145            r#"[submodule "src/tsift"]
22146	path = src/tsift
22147	url = https://example.com/tsift
22148"#,
22149        )
22150        .unwrap();
22151        let submodule = dir.path().join("src/tsift");
22152        fs::create_dir_all(submodule.join("tasks")).unwrap();
22153        fs::write(
22154            submodule.join(".git"),
22155            "gitdir: ../../.git/modules/src/tsift\n",
22156        )
22157        .unwrap();
22158        let session = submodule.join("tasks/plan.md");
22159        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22160        for index in 0..90 {
22161            body.push_str(&format!("❯ prompt {index}?\n"));
22162        }
22163        fs::write(&session, body).unwrap();
22164
22165        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22166
22167        assert_eq!(
22168            result,
22169            Some(format!(
22170                "tsift session-digest --path {} --input {} --source markdown",
22171                shell_quote(submodule.to_str().unwrap()),
22172                shell_quote(session.to_str().unwrap())
22173            ))
22174        );
22175    }
22176
22177    #[test]
22178    fn rewrite_regular_markdown_read_passthrough() {
22179        let dir = tempfile::tempdir().unwrap();
22180        let readme = dir.path().join("README.md");
22181        let body = std::iter::repeat_n("plain markdown", 120)
22182            .collect::<Vec<_>>()
22183            .join("\n");
22184        fs::write(&readme, format!("{body}\n")).unwrap();
22185
22186        let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
22187        assert_eq!(result, None);
22188    }
22189
22190    #[test]
22191    fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
22192        let dir = tempfile::tempdir().unwrap();
22193        write_empty_root_index(dir.path());
22194        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22195
22196        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22197
22198        assert_eq!(
22199            result,
22200            Some(format!(
22201                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
22202                shell_quote(&dir.path().to_string_lossy())
22203            ))
22204        );
22205    }
22206
22207    #[test]
22208    fn rewrite_head_small_source_window_passthrough() {
22209        let dir = tempfile::tempdir().unwrap();
22210        write_empty_root_index(dir.path());
22211        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22212
22213        let result = rewrite_command(&format!(
22214            "head -n 20 {}",
22215            shell_quote(source.to_str().unwrap())
22216        ));
22217
22218        assert_eq!(result, None);
22219    }
22220
22221    #[test]
22222    fn rewrite_sed_large_source_range_to_source_read() {
22223        let dir = tempfile::tempdir().unwrap();
22224        write_empty_root_index(dir.path());
22225        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22226
22227        let result = rewrite_command(&format!(
22228            "sed -n '40,160p' {}",
22229            shell_quote(source.to_str().unwrap())
22230        ));
22231
22232        assert_eq!(
22233            result,
22234            Some(format!(
22235                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
22236                shell_quote(&dir.path().to_string_lossy())
22237            ))
22238        );
22239    }
22240
22241    #[test]
22242    fn rewrite_tail_large_source_window_preserves_tail_anchor() {
22243        let dir = tempfile::tempdir().unwrap();
22244        write_empty_root_index(dir.path());
22245        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22246
22247        let result = rewrite_command(&format!(
22248            "tail -n 120 {}",
22249            shell_quote(source.to_str().unwrap())
22250        ));
22251
22252        assert_eq!(
22253            result,
22254            Some(format!(
22255                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
22256                shell_quote(&dir.path().to_string_lossy())
22257            ))
22258        );
22259    }
22260
22261    #[test]
22262    fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
22263        let dir = tempfile::tempdir().unwrap();
22264        write_empty_root_index(dir.path());
22265        let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
22266
22267        let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
22268
22269        assert_eq!(result, None);
22270    }
22271
22272    #[test]
22273    fn rewrite_large_source_read_passthrough_without_index() {
22274        let dir = tempfile::tempdir().unwrap();
22275        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22276
22277        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22278
22279        assert_eq!(result, None);
22280    }
22281
22282    #[test]
22283    fn rewrite_cargo_test_to_digest_runner() {
22284        let result = rewrite_command("cargo test --lib");
22285        assert_eq!(
22286            result,
22287            Some(
22288                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
22289            )
22290        );
22291    }
22292
22293    #[test]
22294    fn rewrite_pytest_to_digest_runner() {
22295        let result = rewrite_command("pytest -q tests/test_cli.py");
22296        assert_eq!(
22297            result,
22298            Some(
22299                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
22300            )
22301        );
22302    }
22303
22304    #[test]
22305    fn rewrite_python_m_pytest_to_digest_runner() {
22306        let result = rewrite_command("python -m pytest tests/test_cli.py");
22307        assert_eq!(
22308            result,
22309            Some(
22310                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
22311            )
22312        );
22313    }
22314
22315    #[test]
22316    fn rewrite_cargo_build_to_log_digest_runner() {
22317        let result = rewrite_command("cargo build --release");
22318        assert_eq!(
22319            result,
22320            Some(
22321                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
22322            )
22323        );
22324    }
22325
22326    #[test]
22327    fn rewrite_cargo_install_to_log_digest_runner() {
22328        let result = rewrite_command("cargo install --path . --force");
22329        assert_eq!(
22330            result,
22331            Some(
22332                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
22333            )
22334        );
22335    }
22336
22337    #[test]
22338    fn rewrite_metacharacter_command_passthrough() {
22339        let result = rewrite_command("cargo test | head");
22340        assert_eq!(result, None);
22341    }
22342
22343    #[test]
22344    fn rewrite_output_cap_detects_search_even_with_global_flag() {
22345        let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
22346        assert_eq!(cap.max_lines, 50);
22347        assert_eq!(cap.strip_prefix, Some("Strategy:"));
22348    }
22349
22350    #[test]
22351    fn rewrite_output_cap_skips_structured_output() {
22352        assert!(rewrite_output_cap("tsift search foo --json").is_none());
22353        assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
22354        assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
22355    }
22356
22357    #[test]
22358    fn rewrite_output_format_forwards_envelope_to_digest_runner() {
22359        let command = rewrite_command("cargo test --lib").expect("rewrite");
22360        let forwarded = apply_rewrite_output_format(
22361            &command,
22362            OutputFormat {
22363                json_output: true,
22364                compact: false,
22365                pretty: false,
22366                terse: false,
22367                ultra_terse: false,
22368                schema: false,
22369                envelope: true,
22370            },
22371        );
22372        assert_eq!(
22373            forwarded,
22374            "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
22375        );
22376    }
22377
22378    #[test]
22379    fn rewrite_output_format_forwards_json_when_requested() {
22380        let command = rewrite_command("cargo build --release").expect("rewrite");
22381        let forwarded = apply_rewrite_output_format(
22382            &command,
22383            OutputFormat {
22384                json_output: true,
22385                compact: false,
22386                pretty: true,
22387                terse: false,
22388                ultra_terse: false,
22389                schema: false,
22390                envelope: false,
22391            },
22392        );
22393        assert_eq!(
22394            forwarded,
22395            "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
22396        );
22397    }
22398
22399    #[test]
22400    fn output_cap_strips_search_header_and_truncates() {
22401        let capped = apply_output_cap(
22402            b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
22403            OutputCap {
22404                max_lines: 2,
22405                strip_prefix: Some("Strategy:"),
22406            },
22407        );
22408        assert_eq!(
22409            capped,
22410            "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
22411        );
22412    }
22413
22414    #[test]
22415    fn sql_schema_overview_lists_tables() {
22416        let (_tmp, conn) = setup_test_db();
22417        let tables = schema_overview(&conn).unwrap();
22418        let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
22419        assert_eq!(names, &["posts", "users"]);
22420    }
22421
22422    #[test]
22423    fn sql_schema_overview_row_counts() {
22424        let (_tmp, conn) = setup_test_db();
22425        let tables = schema_overview(&conn).unwrap();
22426        let users = tables.iter().find(|t| t.name == "users").unwrap();
22427        let posts = tables.iter().find(|t| t.name == "posts").unwrap();
22428        assert_eq!(users.row_count, 2);
22429        assert_eq!(posts.row_count, 3);
22430    }
22431
22432    #[test]
22433    fn sql_table_columns_metadata() {
22434        let (_tmp, conn) = setup_test_db();
22435        let cols = table_columns(&conn, "users").unwrap();
22436        assert_eq!(cols.len(), 3);
22437        assert_eq!(cols[0].name, "id");
22438        assert!(cols[0].pk);
22439        assert_eq!(cols[1].name, "name");
22440        assert!(cols[1].notnull);
22441        assert_eq!(cols[2].name, "email");
22442        assert!(!cols[2].notnull);
22443    }
22444
22445    #[test]
22446    fn sql_execute_query_returns_rows() {
22447        let (_tmp, conn) = setup_test_db();
22448        let (columns, rows) =
22449            execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
22450        assert_eq!(columns, &["name", "email"]);
22451        assert_eq!(rows.len(), 2);
22452        assert_eq!(rows[0][0], serde_json::json!("Alice"));
22453        assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
22454        assert_eq!(rows[1][1], serde_json::Value::Null);
22455    }
22456
22457    #[test]
22458    fn sql_execute_query_aggregate() {
22459        let (_tmp, conn) = setup_test_db();
22460        let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
22461        assert_eq!(columns, &["cnt"]);
22462        assert_eq!(rows[0][0], serde_json::json!(3));
22463    }
22464
22465    #[test]
22466    fn sql_execute_query_join() {
22467        let (_tmp, conn) = setup_test_db();
22468        let (_cols, rows) = execute_query(
22469            &conn,
22470            "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
22471        )
22472        .unwrap();
22473        assert_eq!(rows.len(), 3);
22474        assert_eq!(rows[0][0], serde_json::json!("Alice"));
22475        assert_eq!(rows[2][0], serde_json::json!("Bob"));
22476    }
22477
22478    #[test]
22479    fn sql_open_db_read_only() {
22480        let (tmp, _conn) = setup_test_db();
22481        drop(_conn);
22482        let ro_conn = open_db(tmp.path()).unwrap();
22483        let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
22484        assert!(result.is_err(), "read-only connection should reject writes");
22485    }
22486
22487    #[test]
22488    fn sql_empty_table_schema() {
22489        let tmp = tempfile::NamedTempFile::new().unwrap();
22490        let conn = Connection::open(tmp.path()).unwrap();
22491        conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
22492            .unwrap();
22493        let tables = schema_overview(&conn).unwrap();
22494        assert_eq!(tables[0].row_count, 0);
22495        assert_eq!(tables[0].columns.len(), 2);
22496    }
22497
22498    // --- graph command ---
22499
22500    fn setup_graph_index() -> tempfile::TempDir {
22501        let dir = tempfile::tempdir().unwrap();
22502        std::fs::write(
22503            dir.path().join("main.rs"),
22504            "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
22505        )
22506        .unwrap();
22507        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22508        db.apply_changes(dir.path()).unwrap();
22509        dir
22510    }
22511
22512    fn setup_traversal_project() -> tempfile::TempDir {
22513        let dir = setup_graph_index();
22514        let task_dir = dir.path().join("tasks/software");
22515        std::fs::create_dir_all(&task_dir).unwrap();
22516        std::fs::write(
22517            task_dir.join("tsift.md"),
22518            r#"---
22519agent_doc_session: tsift-v0.1
22520agent_doc_format: template
22521---
22522
22523## Exchange
22524
22525<!-- agent:exchange patch=append -->
22526❯ do [#kgnv]
22527Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
22528<!-- /agent:exchange -->
22529
22530<!-- agent:queue -->
22531dispatch #spec-test-build-install-commit-push
22532- do [#kgnv]
22533<!-- /agent:queue -->
22534
22535## Backlog
22536
22537<!-- agent:backlog -->
22538- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
22539<!-- /agent:backlog -->
22540"#,
22541        )
22542        .unwrap();
22543        dir
22544    }
22545
22546    fn resolve_ast_span_node<'a>(
22547        graph: &'a TraversalGraphBuild,
22548        label: &str,
22549        symbol_kind: &str,
22550    ) -> &'a TraversalNode {
22551        graph
22552            .nodes
22553            .values()
22554            .find(|node| {
22555                node.kind == "ast_span"
22556                    && node.label == label
22557                    && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
22558            })
22559            .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
22560    }
22561
22562    fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
22563        let dir = tempfile::tempdir().unwrap();
22564        std::fs::write(
22565            dir.path().join("rust.rs"),
22566            r#"mod fixture_nav_rust_mod {
22567    pub fn fixture_nav_rust_helper() {}
22568    pub fn fixture_nav_rust_entry() {
22569        fixture_nav_rust_helper();
22570    }
22571}
22572"#,
22573        )
22574        .unwrap();
22575        std::fs::write(
22576            dir.path().join("python.py"),
22577            r#"def fixture_nav_python_helper():
22578    return 1
22579
22580def fixture_nav_python_entry():
22581    return fixture_nav_python_helper()
22582"#,
22583        )
22584        .unwrap();
22585        std::fs::write(
22586            dir.path().join("typescript.ts"),
22587            r#"export function fixture_nav_typescript_entry(): number {
22588    return fixtureNavTsHelper();
22589}
22590
22591function fixtureNavTsHelper(): number {
22592    return 1;
22593}
22594"#,
22595        )
22596        .unwrap();
22597        std::fs::write(
22598            dir.path().join("javascript.js"),
22599            r#"function fixture_nav_javascript_entry() {
22600    return fixtureNavJsHelper();
22601}
22602
22603function fixtureNavJsHelper() {
22604    return 1;
22605}
22606"#,
22607        )
22608        .unwrap();
22609        std::fs::write(
22610            dir.path().join("kotlin.kt"),
22611            r#"fun fixture_nav_kotlin_entry(): Int {
22612    return fixtureNavKotlinHelper()
22613}
22614
22615fun fixtureNavKotlinHelper(): Int = 1
22616"#,
22617        )
22618        .unwrap();
22619        std::fs::write(
22620            dir.path().join("zig.zig"),
22621            r#"pub fn fixture_nav_zig_entry() i32 {
22622    return fixtureNavZigHelper();
22623}
22624
22625fn fixtureNavZigHelper() i32 {
22626    return 1;
22627}
22628"#,
22629        )
22630        .unwrap();
22631        std::fs::write(
22632            dir.path().join("bash.sh"),
22633            r#"#!/usr/bin/env bash
22634fixture_nav_bash_entry() {
22635    fixture_nav_bash_helper
22636}
22637
22638fixture_nav_bash_helper() {
22639    echo ok
22640}
22641
22642alias fixture_nav_bash_alias='echo alias'
22643"#,
22644        )
22645        .unwrap();
22646        std::fs::write(
22647            dir.path().join("README.md"),
22648            r#"# Fixture Guide
22649
22650## Fixture Section
22651
22652- Fixture step
22653  - Nested fixture step
22654
22655```python
22656def fixture_nav_markdown_embedded():
22657    return 1
22658```
22659"#,
22660        )
22661        .unwrap();
22662
22663        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22664        db.apply_changes(dir.path()).unwrap();
22665        dir
22666    }
22667
22668    fn assert_cli_expand_command_parses(command: &str) {
22669        let args = shell_split(command)
22670            .into_iter()
22671            .map(str::to_string)
22672            .collect::<Vec<_>>();
22673        assert!(
22674            try_parse_cli(args).is_ok(),
22675            "expand command should parse as a tsift CLI command: {command}"
22676        );
22677    }
22678
22679    fn setup_multiplicity_project() -> tempfile::TempDir {
22680        let dir = tempfile::tempdir().unwrap();
22681        std::fs::write(
22682            dir.path().join("Cargo.toml"),
22683            r#"[workspace]
22684members = ["crates/core-lib", "crates/cli-app"]
22685"#,
22686        )
22687        .unwrap();
22688        std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
22689        std::fs::write(
22690            dir.path().join("crates/core-lib/Cargo.toml"),
22691            r#"[package]
22692name = "core-lib"
22693
22694[lib]
22695name = "core_lib"
22696
22697[features]
22698default = []
22699"#,
22700        )
22701        .unwrap();
22702        std::fs::write(
22703            dir.path().join("crates/core-lib/src/lib.rs"),
22704            "pub fn run() {}\n",
22705        )
22706        .unwrap();
22707        std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
22708        std::fs::write(
22709            dir.path().join("crates/cli-app/Cargo.toml"),
22710            r#"[package]
22711name = "cli-app"
22712
22713[[bin]]
22714name = "cli-app"
22715
22716[dependencies]
22717core-lib = { path = "../core-lib" }
22718"#,
22719        )
22720        .unwrap();
22721        std::fs::write(
22722            dir.path().join("crates/cli-app/src/main.rs"),
22723            "use core_lib::run;\nfn main() { run(); }\n",
22724        )
22725        .unwrap();
22726        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22727        db.apply_changes(dir.path()).unwrap();
22728
22729        let task_dir = dir.path().join("tasks/software");
22730        std::fs::create_dir_all(&task_dir).unwrap();
22731        std::fs::write(
22732            task_dir.join("tsift.md"),
22733            r#"---
22734agent_doc_session: tsift-multiplicity
22735agent_doc_format: template
22736---
22737
22738## Backlog
22739
22740<!-- agent:backlog -->
22741- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
22742<!-- /agent:backlog -->
22743"#,
22744        )
22745        .unwrap();
22746        init_git_repo(dir.path());
22747        dir
22748    }
22749
22750    fn setup_dependency_dag_project() -> tempfile::TempDir {
22751        let dir = tempfile::tempdir().unwrap();
22752        std::fs::write(
22753            dir.path().join("main.rs"),
22754            "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
22755        )
22756        .unwrap();
22757        std::fs::write(
22758            dir.path().join("Cargo.toml"),
22759            "[package]\nname = \"dag-fixture\"\n",
22760        )
22761        .unwrap();
22762        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22763        db.apply_changes(dir.path()).unwrap();
22764
22765        let task_dir = dir.path().join("tasks/software");
22766        std::fs::create_dir_all(&task_dir).unwrap();
22767        std::fs::write(
22768            task_dir.join("tsift.md"),
22769            r#"---
22770agent_doc_session: tsift-dag
22771agent_doc_format: template
22772---
22773
22774## Exchange
22775
22776<!-- agent:exchange patch=append -->
22777Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
22778<!-- /agent:exchange -->
22779
22780## Backlog
22781
22782<!-- agent:backlog -->
22783- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
22784- [ ] [#alpha] Update shared_helper in main.rs after #prep.
22785- [ ] [#beta] Refactor shared_helper tests in main.rs.
22786- [ ] [#gamma] Follow-up review for graph navigation.
22787<!-- /agent:backlog -->
22788"#,
22789        )
22790        .unwrap();
22791        dir
22792    }
22793
22794    fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
22795        let dir = setup_graph_index();
22796        let task_dir = dir.path().join("tasks/software");
22797        std::fs::create_dir_all(&task_dir).unwrap();
22798        std::fs::write(
22799            task_dir.join("tsift.md"),
22800            r#"---
22801agent_doc_session: tsift-dag-cycle
22802agent_doc_format: template
22803---
22804
22805## Backlog
22806
22807<!-- agent:backlog -->
22808- [ ] [#left] Left side depends on #right.
22809- [ ] [#right] Right side depends on #left.
22810<!-- /agent:backlog -->
22811"#,
22812        )
22813        .unwrap();
22814        dir
22815    }
22816
22817    fn seed_traversal_semantic_summaries(dir: &Path) {
22818        let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
22819        summary_db
22820            .insert(&summarize::Summary {
22821                id: 0,
22822                symbol_name: "helper".to_string(),
22823                file_path: "main.rs".to_string(),
22824                content_hash: "hash-main".to_string(),
22825                summary: "helper builds graph navigation handles for traversal.".to_string(),
22826                entities: Some(vec![
22827                    summarize::Entity {
22828                        name: "helper".to_string(),
22829                        kind: "function".to_string(),
22830                        description: "Builds graph navigation handles.".to_string(),
22831                    },
22832                    summarize::Entity {
22833                        name: "TraversalGraph".to_string(),
22834                        kind: "type".to_string(),
22835                        description: "Carries GraphStore-backed traversal rows.".to_string(),
22836                    },
22837                ]),
22838                relationships: Some(vec![summarize::Relationship {
22839                    from: "helper".to_string(),
22840                    to: "TraversalGraph".to_string(),
22841                    kind: "uses".to_string(),
22842                }]),
22843                concept_labels: Some(vec![
22844                    "graph navigation".to_string(),
22845                    "semantic extraction".to_string(),
22846                ]),
22847                extracted_at: "1700000000".to_string(),
22848                model: "test-model".to_string(),
22849                tokens_input: Some(10),
22850                tokens_output: Some(5),
22851            })
22852            .unwrap();
22853    }
22854
22855    fn seed_tsift_memory_graph_db(dir: &Path) {
22856        let db = dir.join(".tsift").join("memory.db");
22857        let store = MemoryStore::open_or_create(&db).unwrap();
22858        let project = dir.to_string_lossy().to_string();
22859        let observation = MemoryEvent::new(
22860            MemoryEventKind::ImportedObservation,
22861            "claude-mem:observations:1",
22862            [
22863                "Graph memory adapter",
22864                "read-only projection",
22865                "graph-db should retrieve tsift memory observations",
22866                "Project memory is queried from .tsift/memory.db",
22867                "graph memory, tsift memory, semantic query",
22868            ]
22869            .join("\n\n"),
22870        )
22871        .with_session_id("claude-session-a")
22872        .with_observed_at_unix(1_700_000_000)
22873        .with_import("claude-mem", "observations:1")
22874        .with_metadata("project", project.clone())
22875        .with_metadata("observation_type", "fact")
22876        .with_metadata("prompt_number", "7")
22877        .with_metadata("discovery_tokens", "42")
22878        .with_metadata("content_hash", "hash-observation-1");
22879        store.insert_event(&observation).unwrap();
22880
22881        let summary = MemoryEvent::new(
22882            MemoryEventKind::ImportedSessionSummary,
22883            "claude-mem:session_summaries:2",
22884            [
22885                "Query old memory from graph-db",
22886                "Read-only tsift memory SQLite projection",
22887                "Semantic graph rows can point at existing memory",
22888                "Projected source and session nodes",
22889                "Keep capture ownership inside tsift-memory",
22890                "summary note",
22891            ]
22892            .join("\n\n"),
22893        )
22894        .with_session_id("claude-session-a")
22895        .with_observed_at_unix(1_700_000_010)
22896        .with_import("claude-mem", "session_summaries:2")
22897        .with_metadata("project", project)
22898        .with_metadata("prompt_number", "8")
22899        .with_metadata("discovery_tokens", "36");
22900        store.insert_event(&summary).unwrap();
22901
22902        let prompt = MemoryEvent::new(
22903            MemoryEventKind::ImportedUserPrompt,
22904            "claude-mem:user_prompts:3",
22905            "How can graph-db query tsift memory semantic history?",
22906        )
22907        .with_session_id("claude-session-a")
22908        .with_observed_at_unix(1_700_000_020)
22909        .with_import("claude-mem", "user_prompts:3")
22910        .with_metadata("prompt_number", "9");
22911        store.insert_event(&prompt).unwrap();
22912    }
22913
22914    #[test]
22915    fn graph_callers_query() {
22916        let dir = setup_graph_index();
22917        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22918        let callers = db.callers_of("helper").unwrap();
22919        assert_eq!(callers.len(), 1);
22920        assert_eq!(callers[0].caller_name, "main");
22921    }
22922
22923    #[test]
22924    fn graph_callees_query() {
22925        let dir = setup_graph_index();
22926        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22927        let callees = db.callees_of("main").unwrap();
22928        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
22929        assert!(names.contains(&"helper"));
22930        assert!(names.contains(&"new"));
22931    }
22932
22933    #[test]
22934    fn graph_no_callers_returns_empty() {
22935        let dir = setup_graph_index();
22936        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22937        let callers = db.callers_of("nonexistent").unwrap();
22938        assert!(callers.is_empty());
22939    }
22940
22941    #[test]
22942    fn graph_cmd_autoindexes_missing_index_by_default() {
22943        let dir = tempfile::tempdir().unwrap();
22944        std::fs::write(
22945            dir.path().join("main.rs"),
22946            "fn helper() {}\nfn main() { helper(); }\n",
22947        )
22948        .unwrap();
22949        let result = cmd_graph(
22950            "helper",
22951            dir.path(),
22952            true,
22953            false,
22954            None,
22955            20,
22956            false,
22957            true,
22958            false,
22959            false,
22960            false,
22961            false,
22962            false,
22963            TagpathSearchOpts::default(),
22964        );
22965
22966        assert!(result.is_ok());
22967        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
22968        let summary = db.compute_changes(dir.path()).unwrap();
22969        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
22970    }
22971
22972    #[test]
22973    fn traversal_graph_has_stable_typed_handles() {
22974        let dir = setup_traversal_project();
22975        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
22976        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
22977
22978        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
22979        let symbol = resolve_traversal_node(&graph, "helper").unwrap();
22980        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
22981        let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
22982
22983        assert!(file.handle.starts_with("gfil-"));
22984        assert!(symbol.handle.starts_with("gsym-"));
22985        assert!(backlog.handle.starts_with("gbak-"));
22986        assert!(session.handle.starts_with("gses-"));
22987
22988        assert_eq!(
22989            symbol.handle,
22990            resolve_traversal_node(&graph_again, "helper")
22991                .unwrap()
22992                .handle
22993        );
22994        assert_eq!(
22995            backlog.handle,
22996            resolve_traversal_node(&graph_again, "#kgnv")
22997                .unwrap()
22998                .handle
22999        );
23000    }
23001
23002    #[test]
23003    fn traversal_graph_links_backlog_items_to_code_tokens() {
23004        let dir = setup_traversal_project();
23005        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23006        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23007        let helper = resolve_traversal_node(&graph, "helper").unwrap();
23008
23009        assert!(graph.edges.iter().any(|edge| {
23010            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23011        }));
23012    }
23013
23014    #[test]
23015    fn session_hinted_traversal_skips_global_call_edges() {
23016        let dir = setup_traversal_project();
23017        let session = dir.path().join("tasks/software/tsift.md");
23018        let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
23019        let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
23020        let helper = resolve_traversal_node(&bounded, "helper").unwrap();
23021
23022        assert!(bounded.edges.iter().any(|edge| {
23023            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23024        }));
23025        assert!(
23026            !bounded.edges.iter().any(|edge| edge.relation == "calls"),
23027            "session-hinted graph-db projections should not materialize unrelated global call edges"
23028        );
23029
23030        let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
23031        assert!(
23032            full.edges.iter().any(|edge| edge.relation == "calls"),
23033            "root/full projections still carry the complete indexed call graph"
23034        );
23035    }
23036
23037    #[test]
23038    fn agent_doc_task_path_infers_matching_workspace_scope() {
23039        let dir = tempfile::tempdir().unwrap();
23040        std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
23041        std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
23042        std::fs::write(
23043            dir.path().join(".gitmodules"),
23044            "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
23045        )
23046        .unwrap();
23047        let task = dir.path().join("tasks/software/tsift.md");
23048        std::fs::write(&task, "# tsift\n").unwrap();
23049
23050        let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
23051        let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
23052        let cfg = config::Config::load(dir.path()).unwrap();
23053
23054        assert_eq!(targets.len(), 1);
23055        assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
23056        assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
23057        assert!(
23058            targets[0]
23059                .db_path
23060                .ends_with(".tsift/indexes/tsift/index.db")
23061        );
23062        assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
23063    }
23064
23065    #[test]
23066    fn cargo_package_scope_selector_indexes_package_db() {
23067        let dir = setup_multiplicity_project();
23068        let targets =
23069            resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
23070
23071        assert_eq!(targets.len(), 1);
23072        assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
23073        assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
23074        assert!(
23075            targets[0]
23076                .db_path
23077                .ends_with(".tsift/indexes/cargo/core-lib/index.db")
23078        );
23079
23080        cmd_index(
23081            dir.path(),
23082            false,
23083            false,
23084            false,
23085            false,
23086            true,
23087            false,
23088            Some("core_lib"),
23089            false,
23090            true,
23091            false,
23092            false,
23093            false,
23094            false,
23095        )
23096        .unwrap();
23097        assert!(targets[0].db_path.exists());
23098    }
23099
23100    #[test]
23101    fn path_inference_prefers_nested_cargo_package_without_submodule() {
23102        let dir = setup_multiplicity_project();
23103        let source = dir.path().join("crates/cli-app/src/main.rs");
23104        let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
23105
23106        assert_eq!(targets.len(), 1);
23107        assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
23108        assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
23109    }
23110
23111    #[test]
23112    fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
23113        let dir = setup_multiplicity_project();
23114        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23115        let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
23116        let core = resolve_traversal_node(&graph, "core-lib").unwrap();
23117        let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
23118        let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
23119
23120        assert_eq!(workspace.kind, "cargo_workspace");
23121        assert_eq!(core.kind, "cargo_package");
23122        assert_eq!(
23123            core.properties.get("features"),
23124            Some(&"default".to_string())
23125        );
23126        assert!(graph.edges.iter().any(|edge| {
23127            edge.from == workspace.handle
23128                && edge.to == core.handle
23129                && edge.relation == "contains_package"
23130        }));
23131        assert!(graph.edges.iter().any(|edge| {
23132            edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
23133        }));
23134        assert!(graph.edges.iter().any(|edge| {
23135            edge.from == cli.handle
23136                && edge.to == core.handle
23137                && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
23138        }));
23139    }
23140
23141    #[test]
23142    fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
23143        let dir = setup_multiplicity_project();
23144        let session = dir.path().join("tasks/software/tsift.md");
23145        let report =
23146            build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
23147                .unwrap();
23148
23149        assert!(report.per_target_fail_closed.is_empty());
23150        let candidate = report
23151            .candidates
23152            .iter()
23153            .find(|candidate| candidate.target == "corepkg")
23154            .unwrap();
23155        assert!(
23156            candidate
23157                .owned_files
23158                .iter()
23159                .any(|file| file == "crates/core-lib/Cargo.toml"),
23160            "{:?}",
23161            candidate.owned_files
23162        );
23163    }
23164
23165    #[test]
23166    fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
23167        let dir = setup_traversal_project();
23168        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23169        let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
23170        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23171
23172        assert_eq!(job.kind, "job_packet");
23173        assert!(job.handle.starts_with("gjob-"));
23174        assert!(graph.edges.iter().any(|edge| {
23175            edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
23176        }));
23177
23178        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23179        let jobs = store.nodes_by_kind("job_packet").unwrap();
23180        assert!(
23181            jobs.iter()
23182                .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23183            "expected queued job packet in graph store, got {jobs:?}"
23184        );
23185    }
23186
23187    #[test]
23188    fn traversal_graph_includes_routes_and_handler_edges() {
23189        let dir = tempfile::tempdir().unwrap();
23190        std::fs::write(
23191            dir.path().join("api.py"),
23192            r#"@router.get("/items")
23193def list_items():
23194    return []
23195"#,
23196        )
23197        .unwrap();
23198        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23199        db.apply_changes(dir.path()).unwrap();
23200
23201        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23202        let route = resolve_traversal_node(&graph, "/items").unwrap();
23203        let handler = resolve_traversal_node(&graph, "list_items").unwrap();
23204
23205        assert_eq!(route.kind, "route");
23206        assert!(graph.edges.iter().any(|edge| {
23207            edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
23208        }));
23209    }
23210
23211    #[test]
23212    fn traversal_graph_projects_rust_ast_navigation_edges() {
23213        let dir = tempfile::tempdir().unwrap();
23214        std::fs::write(
23215            dir.path().join("main.rs"),
23216            r#"mod api {
23217    pub fn helper() {}
23218    pub fn handler() { helper(); }
23219}
23220
23221fn main() { api::handler(); }
23222"#,
23223        )
23224        .unwrap();
23225        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23226        db.apply_changes(dir.path()).unwrap();
23227
23228        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23229        let api = resolve_ast_span_node(&graph, "api", "mod");
23230        let helper = resolve_ast_span_node(&graph, "helper", "function");
23231        let handler = resolve_ast_span_node(&graph, "handler", "function");
23232
23233        assert_eq!(helper.kind, "ast_span");
23234        assert!(helper.handle.starts_with("span-"));
23235        assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
23236        assert!(graph.edges.iter().any(|edge| {
23237            edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
23238        }));
23239        assert!(graph.edges.iter().any(|edge| {
23240            edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
23241        }));
23242        assert!(graph.edges.iter().any(|edge| {
23243            edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
23244        }));
23245        assert!(graph.edges.iter().any(|edge| {
23246            edge.from == helper.handle
23247                && edge.to == handler.handle
23248                && edge.relation == "next_sibling"
23249        }));
23250        assert!(graph.edges.iter().any(|edge| {
23251            edge.from == handler.handle
23252                && edge.to == helper.handle
23253                && edge.relation == "previous_sibling"
23254        }));
23255        assert!(graph.edges.iter().any(|edge| {
23256            edge.from == helper.handle
23257                && edge.to == api.handle
23258                && edge.relation == "enclosing_module"
23259        }));
23260        assert!(graph.edges.iter().any(|edge| {
23261            edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
23262        }));
23263
23264        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23265        let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
23266        assert!(
23267            ast_nodes.iter().any(|node| node.id == helper.handle
23268                && node.properties.get("symbol_kind") == Some(&"function".to_string())),
23269            "expected helper AST span in graph store, got {ast_nodes:?}"
23270        );
23271        assert!(
23272            store
23273                .outgoing_edges(&helper.handle, Some("parent"))
23274                .unwrap()
23275                .iter()
23276                .any(|edge| edge.to_id == api.handle),
23277            "expected persisted AST parent edge"
23278        );
23279    }
23280
23281    #[test]
23282    fn traversal_graph_projects_markdown_section_block_edges() {
23283        let dir = tempfile::tempdir().unwrap();
23284        std::fs::write(
23285            dir.path().join("README.md"),
23286            "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
23287        )
23288        .unwrap();
23289        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23290        db.apply_changes(dir.path()).unwrap();
23291
23292        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23293        let guide = resolve_ast_span_node(&graph, "Guide", "heading");
23294        let code = resolve_ast_span_node(&graph, "rust", "code_block");
23295        let embedded = resolve_ast_span_node(&graph, "demo", "function");
23296        let list_item = graph
23297            .nodes
23298            .values()
23299            .find(|node| {
23300                node.kind == "ast_span"
23301                    && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
23302                    && node.properties.get("section_handle") == Some(&guide.handle)
23303            })
23304            .expect("missing Markdown list item AST span");
23305
23306        assert_eq!(
23307            code.properties.get("markdown_block_kind"),
23308            Some(&"fenced_code_block".to_string())
23309        );
23310        assert_eq!(
23311            guide.properties.get("heading_level"),
23312            Some(&"1".to_string())
23313        );
23314        assert_eq!(
23315            embedded.properties.get("embedded"),
23316            Some(&"true".to_string())
23317        );
23318        assert_eq!(
23319            embedded.properties.get("language"),
23320            Some(&"rust".to_string())
23321        );
23322        assert_eq!(
23323            embedded.properties.get("markdown_block_handle"),
23324            Some(&code.handle)
23325        );
23326        assert!(graph.edges.iter().any(|edge| {
23327            edge.from == guide.handle
23328                && edge.to == code.handle
23329                && edge.relation == "contains_markdown_block"
23330        }));
23331        assert!(graph.edges.iter().any(|edge| {
23332            edge.from == code.handle
23333                && edge.to == guide.handle
23334                && edge.relation == "enclosing_section"
23335        }));
23336        assert!(graph.edges.iter().any(|edge| {
23337            edge.from == guide.handle
23338                && edge.to == list_item.handle
23339                && edge.relation == "contains_markdown_block"
23340        }));
23341        assert!(graph.edges.iter().any(|edge| {
23342            edge.from == code.handle
23343                && edge.to == embedded.handle
23344                && edge.relation == "contains_embedded_symbol"
23345        }));
23346        assert!(graph.edges.iter().any(|edge| {
23347            edge.from == embedded.handle
23348                && edge.to == code.handle
23349                && edge.relation == "embedded_in_fence"
23350        }));
23351        assert!(graph.edges.iter().any(|edge| {
23352            edge.from == guide.handle
23353                && edge.to == embedded.handle
23354                && edge.relation == "contains_embedded_code"
23355        }));
23356
23357        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23358        assert!(
23359            store
23360                .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
23361                .unwrap()
23362                .iter()
23363                .any(|edge| edge.to_id == code.handle),
23364            "expected persisted Markdown section/block edge"
23365        );
23366        assert!(
23367            store
23368                .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
23369                .unwrap()
23370                .iter()
23371                .any(|edge| edge.to_id == embedded.handle),
23372            "expected persisted Markdown fence/embedded symbol edge"
23373        );
23374    }
23375
23376    #[test]
23377    fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
23378        let dir = setup_multilingual_ast_navigation_project();
23379        let db =
23380            index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
23381        let symbols = db.all_symbols().unwrap();
23382        let expected_symbols = [
23383            ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
23384            (
23385                "python",
23386                "fixture_nav_python_entry",
23387                "function",
23388                "python.py",
23389            ),
23390            (
23391                "typescript",
23392                "fixture_nav_typescript_entry",
23393                "function",
23394                "typescript.ts",
23395            ),
23396            (
23397                "javascript",
23398                "fixture_nav_javascript_entry",
23399                "function",
23400                "javascript.js",
23401            ),
23402            (
23403                "kotlin",
23404                "fixture_nav_kotlin_entry",
23405                "function",
23406                "kotlin.kt",
23407            ),
23408            ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
23409            ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
23410            ("markdown", "Fixture Section", "heading", "README.md"),
23411            ("markdown", "Fixture step", "list_item", "README.md"),
23412            ("markdown", "python", "code_block", "README.md"),
23413        ];
23414
23415        for (language, name, kind, file) in expected_symbols {
23416            let symbol = symbols
23417                .iter()
23418                .find(|symbol| {
23419                    symbol.language == language
23420                        && symbol.name == name
23421                        && symbol.kind == kind
23422                        && symbol.file.ends_with(file)
23423                })
23424                .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
23425            assert!(
23426                symbol.start_byte.is_some() && symbol.end_byte.is_some(),
23427                "{language} {name} should carry AST byte spans"
23428            );
23429        }
23430
23431        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23432        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23433        let expected_ast_nodes = [
23434            ("fixture_nav_rust_entry", "function", "rust"),
23435            ("fixture_nav_python_entry", "function", "python"),
23436            ("fixture_nav_typescript_entry", "function", "typescript"),
23437            ("fixture_nav_javascript_entry", "function", "javascript"),
23438            ("fixture_nav_kotlin_entry", "function", "kotlin"),
23439            ("fixture_nav_zig_entry", "function", "zig"),
23440            ("fixture_nav_bash_entry", "function", "bash"),
23441            ("Fixture Section", "heading", "markdown"),
23442            ("Fixture step", "list_item", "markdown"),
23443            ("python", "code_block", "markdown"),
23444            ("fixture_nav_markdown_embedded", "function", "python"),
23445        ];
23446
23447        for (name, kind, language) in expected_ast_nodes {
23448            let node = resolve_ast_span_node(&graph, name, kind);
23449            let repeated = resolve_ast_span_node(&graph_again, name, kind);
23450            assert!(
23451                node.handle.starts_with("span-"),
23452                "{name} handle: {}",
23453                node.handle
23454            );
23455            assert_eq!(
23456                node.handle, repeated.handle,
23457                "{language} {name} handle drifted"
23458            );
23459            assert_eq!(
23460                node.properties.get("language"),
23461                Some(&language.to_string()),
23462                "{name} should keep its language label"
23463            );
23464        }
23465
23466        let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
23467        let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
23468        let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
23469        assert!(graph.edges.iter().any(|edge| {
23470            edge.from == markdown_section.handle
23471                && edge.to == markdown_code.handle
23472                && edge.relation == "contains_markdown_block"
23473        }));
23474        assert!(graph.edges.iter().any(|edge| {
23475            edge.from == markdown_code.handle
23476                && edge.to == embedded.handle
23477                && edge.relation == "contains_embedded_symbol"
23478        }));
23479        assert!(
23480            graph.nodes.len() <= 80,
23481            "multilingual AST fixture should stay bounded, got {} nodes",
23482            graph.nodes.len()
23483        );
23484        assert!(
23485            graph.edges.len() <= 180,
23486            "multilingual AST fixture should stay bounded, got {} edges",
23487            graph.edges.len()
23488        );
23489
23490        let response = empty_search_response(dir.path(), "lexical");
23491        let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
23492        let report = build_relative_search_budget_report(
23493            "fixture_nav_python_entry",
23494            "lexical",
23495            dir.path(),
23496            &response,
23497            &symbol_hits,
23498            ResponseBudget::new(Some(8), Some(120)),
23499            &SearchFacetFilters::default(),
23500        );
23501        let report_again = build_relative_search_budget_report(
23502            "fixture_nav_python_entry",
23503            "lexical",
23504            dir.path(),
23505            &response,
23506            &symbol_hits,
23507            ResponseBudget::new(Some(8), Some(120)),
23508            &SearchFacetFilters::default(),
23509        );
23510
23511        let top = report
23512            .ranked
23513            .first()
23514            .expect("ranked preview should not be empty");
23515        assert_eq!(top.source, "symbol_span");
23516        assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
23517        assert!(top.handle.starts_with("srnk-"));
23518        assert_eq!(top.handle, report_again.ranked[0].handle);
23519        assert!(
23520            top.reasons.iter().any(|reason| reason == "ast_span"),
23521            "expected AST span ranking reason, got {:?}",
23522            top.reasons
23523        );
23524        assert!(report.ranked.len() <= 8);
23525        assert!(report.symbols.len() <= 8);
23526
23527        let symbol = report
23528            .symbols
23529            .iter()
23530            .find(|symbol| symbol.name == "fixture_nav_python_entry")
23531            .expect("missing search preview symbol");
23532        assert_cli_expand_command_parses(&symbol.expand);
23533        let ast = symbol
23534            .ast
23535            .as_ref()
23536            .expect("search symbol should expose AST");
23537        assert_cli_expand_command_parses(&ast.expand.source_window);
23538        assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
23539        assert_cli_expand_command_parses(&ast.expand.symbol_read);
23540
23541        let markdown_hits = db.symbol_search("python", 20).unwrap();
23542        let markdown_report = build_relative_search_budget_report(
23543            "python",
23544            "lexical",
23545            dir.path(),
23546            &response,
23547            &markdown_hits,
23548            ResponseBudget::new(Some(8), Some(120)),
23549            &SearchFacetFilters::default(),
23550        );
23551        let markdown_symbol = markdown_report
23552            .symbols
23553            .iter()
23554            .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
23555            .expect("missing Markdown code-block symbol");
23556        let markdown_ast = markdown_symbol
23557            .ast
23558            .as_ref()
23559            .expect("Markdown code block should expose AST");
23560        assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
23561        assert_eq!(
23562            markdown_ast
23563                .span
23564                .markdown
23565                .as_ref()
23566                .unwrap()
23567                .embedded_symbols[0]
23568                .name,
23569            "fixture_nav_markdown_embedded"
23570        );
23571    }
23572
23573    #[test]
23574    fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
23575        let edges = vec![
23576            TraversalEdge {
23577                from: "origin".to_string(),
23578                to: "aaa_low".to_string(),
23579                relation: "unknown".to_string(),
23580                label: None,
23581                weight: 1,
23582            },
23583            TraversalEdge {
23584                from: "origin".to_string(),
23585                to: "zzz_high".to_string(),
23586                relation: "mentions".to_string(),
23587                label: None,
23588                weight: 1,
23589            },
23590        ];
23591
23592        let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
23593
23594        assert!(handles.contains("origin"));
23595        assert!(handles.contains("zzz_high"), "{handles:?}");
23596        assert!(!handles.contains("aaa_low"), "{handles:?}");
23597    }
23598
23599    #[test]
23600    fn traversal_materializes_provider_neutral_sqlite_graph() {
23601        let dir = setup_traversal_project();
23602        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23603        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23604
23605        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23606        let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
23607        assert!(
23608            backlog_nodes.iter().any(|node| node.id == backlog.handle
23609                && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23610            "expected materialized backlog node, got {backlog_nodes:?}"
23611        );
23612        assert!(
23613            store
23614                .all_nodes()
23615                .unwrap()
23616                .iter()
23617                .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
23618                    && node.properties.get("projection_version")
23619                        == Some(&GRAPH_PROJECTION_VERSION.to_string())),
23620            "expected projection metadata node"
23621        );
23622        let source_handles = store.nodes_by_kind("source_handle").unwrap();
23623        assert!(
23624            source_handles
23625                .iter()
23626                .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
23627            "expected bounded source_handle rows, got {source_handles:?}"
23628        );
23629        let worker_context = store.nodes_by_kind("worker_context").unwrap();
23630        assert!(
23631            worker_context
23632                .iter()
23633                .any(|node| node.properties.get("target")
23634                    == Some(&"tasks/software/tsift.md".to_string())),
23635            "expected bounded worker_context rows, got {worker_context:?}"
23636        );
23637        let worker_results = store.nodes_by_kind("worker_result").unwrap();
23638        assert!(
23639            worker_results.iter().any(|node| {
23640                node.properties.get("ref_id") == Some(&"kgnv".to_string())
23641                    && node.properties.get("status") == Some(&"completed".to_string())
23642                    && node.properties.get("touched_files") == Some(&"main.rs".to_string())
23643                    && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
23644            }),
23645            "expected worker_result rows, got {worker_results:?}"
23646        );
23647    }
23648
23649    #[test]
23650    fn traversal_projection_materializes_cached_semantic_rows() {
23651        let dir = setup_traversal_project();
23652        seed_traversal_semantic_summaries(dir.path());
23653        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23654        let helper = resolve_traversal_node(&graph, "helper").unwrap();
23655        let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
23656        let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
23657
23658        assert_eq!(concept.kind, "semantic_concept");
23659        assert_eq!(entity.kind, "semantic_entity");
23660        assert!(concept.handle.starts_with("gcon-"));
23661        assert!(entity.handle.starts_with("gent-"));
23662
23663        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23664        assert!(
23665            store
23666                .nodes_by_kind("semantic_concept")
23667                .unwrap()
23668                .iter()
23669                .any(|node| node.label == "semantic extraction"
23670                    && node.properties.contains_key("embedding")),
23671            "expected persisted concept embeddings"
23672        );
23673        assert!(
23674            store
23675                .outgoing_edges(&helper.handle, Some("mentions_concept"))
23676                .unwrap()
23677                .iter()
23678                .any(|edge| edge.to_id == concept.handle),
23679            "expected helper symbol to link to cached summary concept"
23680        );
23681        assert!(
23682            store
23683                .outgoing_edges(
23684                    &semantic_entity_handle("helper", "function"),
23685                    Some("semantic_relation")
23686                )
23687                .unwrap()
23688                .iter()
23689                .any(|edge| edge.to_id == entity.handle
23690                    && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
23691            "expected LLM relationship rows projected into GraphStore"
23692        );
23693    }
23694
23695    #[test]
23696    fn traversal_projection_materializes_tsift_memory_rows() {
23697        let dir = setup_traversal_project();
23698        seed_tsift_memory_graph_db(dir.path());
23699        let memory_db = dir.path().join(".tsift").join("memory.db");
23700        let store = MemoryStore::open_or_create(&memory_db).unwrap();
23701        for summary in ["first closeout", "second closeout"] {
23702            let event = MemoryEvent::new(
23703                MemoryEventKind::ResponseSummary,
23704                "tasks/software/tsift.md",
23705                summary,
23706            )
23707            .with_session_id("tasks/software/tsift.md")
23708            .with_observed_at_unix(1_700_000_100);
23709            store.insert_event(&event).unwrap();
23710        }
23711        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23712        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23713
23714        let native_sources = store
23715            .nodes_by_kind("source_handle")
23716            .unwrap()
23717            .into_iter()
23718            .filter(|node| {
23719                node.properties.get("provider") == Some(&"tsift-memory".to_string())
23720                    && node.properties.get("source_ref")
23721                        == Some(&"tasks/software/tsift.md".to_string())
23722            })
23723            .collect::<Vec<_>>();
23724        assert_eq!(
23725            native_sources.len(),
23726            2,
23727            "same-source native memory events must get distinct source handles"
23728        );
23729
23730        let source = store
23731            .nodes_by_kind("source_handle")
23732            .unwrap()
23733            .into_iter()
23734            .find(|node| {
23735                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23736            })
23737            .expect("expected tsift-memory source handle");
23738        let session = store
23739            .nodes_by_kind("memory_session")
23740            .unwrap()
23741            .into_iter()
23742            .find(|node| {
23743                node.properties.get("provider") == Some(&"tsift-memory".to_string())
23744                    && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
23745            })
23746            .expect("expected tsift-memory session node");
23747        let event = store
23748            .nodes_by_kind("memory_event")
23749            .unwrap()
23750            .into_iter()
23751            .find(|node| {
23752                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23753                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
23754                    && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
23755            })
23756            .expect("expected tsift-memory event node");
23757        let concept = store
23758            .nodes_by_kind("semantic_concept")
23759            .unwrap()
23760            .into_iter()
23761            .find(|node| {
23762                node.properties.get("provider") == Some(&"tsift-memory".to_string())
23763                    && node.label.contains("Graph memory adapter")
23764                    && node.properties.contains_key("embedding")
23765            })
23766            .expect("expected tsift-memory semantic concept");
23767
23768        assert!(
23769            store
23770                .outgoing_edges(&session.id, Some("records_memory_source"))
23771                .unwrap()
23772                .iter()
23773                .any(|edge| edge.to_id == source.id),
23774            "expected session to link to source handle"
23775        );
23776        assert!(
23777            store
23778                .outgoing_edges(&session.id, Some("records_memory_event"))
23779                .unwrap()
23780                .iter()
23781                .any(|edge| edge.to_id == event.id),
23782            "expected session to link to memory event"
23783        );
23784        assert!(
23785            store
23786                .outgoing_edges(&event.id, Some("projects_source"))
23787                .unwrap()
23788                .iter()
23789                .any(|edge| edge.to_id == source.id),
23790            "expected memory event to project source handle"
23791        );
23792        assert!(
23793            store
23794                .outgoing_edges(&source.id, Some("mentions_concept"))
23795                .unwrap()
23796                .iter()
23797                .any(|edge| edge.to_id == concept.id),
23798            "expected source handle to seed semantic concept"
23799        );
23800
23801        let related = semantic_related_report_from_store(
23802            dir.path(),
23803            None,
23804            "tsift memory graph adapter",
23805            5,
23806            SemanticRelatedKind::Concept,
23807            &store,
23808        )
23809        .unwrap();
23810        assert!(
23811            related
23812                .items
23813                .iter()
23814                .any(|item| item.handle == concept.id && item.score > 0.0),
23815            "expected semantic query to retrieve tsift-memory concept, got {:?}",
23816            related.items
23817        );
23818
23819        let graph_related = graph_db_report_from_store(
23820            dir.path(),
23821            None,
23822            "sqlite",
23823            GraphDbQuery::Related {
23824                query: "tsift memory graph adapter".to_string(),
23825                kind: SemanticRelatedKind::Concept,
23826                depth: 1,
23827                seed_limit: 5,
23828                limit: 20,
23829            },
23830            &store,
23831            sqlite_graph_freshness(&store, "root").unwrap(),
23832            Vec::new(),
23833        )
23834        .unwrap();
23835        assert_eq!(
23836            graph_related
23837                .readiness
23838                .as_ref()
23839                .map(|readiness| readiness.status.as_str()),
23840            Some("ready"),
23841            "tsift-memory semantic rows should satisfy graph-db related readiness"
23842        );
23843        assert!(
23844            graph_related.nodes.iter().any(|node| {
23845                node.kind == "semantic_concept"
23846                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
23847            }),
23848            "expected related graph output to include tsift-memory semantic rows"
23849        );
23850    }
23851
23852    #[test]
23853    fn semantic_related_query_uses_persisted_graph_embeddings() {
23854        let dir = setup_traversal_project();
23855        seed_traversal_semantic_summaries(dir.path());
23856        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23857        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23858        let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
23859            .unwrap()
23860            .query_row(
23861                "SELECT COUNT(*) FROM graph_node_semantic_vectors",
23862                [],
23863                |row| row.get(0),
23864            )
23865            .unwrap();
23866        assert!(semantic_vector_rows > 0);
23867
23868        let report = semantic_related_report_from_store(
23869            dir.path(),
23870            None,
23871            "graph navigation",
23872            5,
23873            SemanticRelatedKind::Concept,
23874            &store,
23875        )
23876        .unwrap();
23877
23878        assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
23879        assert!(
23880            report
23881                .items
23882                .iter()
23883                .any(|item| item.label == "graph navigation"
23884                    && item.kind == "semantic_concept"
23885                    && item.score > 0.9),
23886            "expected nearest concept match from graph embeddings, got {:?}",
23887            report.items
23888        );
23889    }
23890
23891    #[test]
23892    fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
23893        let dir = setup_traversal_project();
23894        seed_traversal_semantic_summaries(dir.path());
23895        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23896        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23897
23898        let report = graph_db_report_from_store(
23899            dir.path(),
23900            None,
23901            "sqlite",
23902            GraphDbQuery::Related {
23903                query: "graph navigation".to_string(),
23904                kind: SemanticRelatedKind::All,
23905                depth: 1,
23906                seed_limit: 2,
23907                limit: 20,
23908            },
23909            &store,
23910            sqlite_graph_freshness(&store, "root").unwrap(),
23911            Vec::new(),
23912        )
23913        .unwrap();
23914
23915        let knowledge = report.knowledge_retrieval.as_ref().unwrap();
23916        assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
23917        assert_eq!(knowledge.seed_kind, "all");
23918        assert_eq!(knowledge.depth, 1);
23919        assert_eq!(
23920            report
23921                .readiness
23922                .as_ref()
23923                .map(|readiness| readiness.status.as_str()),
23924            Some("ready")
23925        );
23926        assert!(
23927            knowledge
23928                .diagnostics
23929                .iter()
23930                .any(|diagnostic| diagnostic.contains("incident"))
23931        );
23932        assert!(
23933            report
23934                .semantic_related
23935                .iter()
23936                .any(|item| item.label == "graph navigation"
23937                    && item.kind == "semantic_concept"
23938                    && item.score > 0.9),
23939            "expected natural-language query to seed the graph navigation concept, got {:?}",
23940            report.semantic_related
23941        );
23942        assert!(
23943            report
23944                .nodes
23945                .iter()
23946                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
23947        );
23948        assert!(
23949            report
23950                .nodes
23951                .iter()
23952                .any(|node| node.kind == "symbol" && node.label == "helper"),
23953            "incident expansion from semantic seed should recover source symbols, got {:?}",
23954            report
23955                .nodes
23956                .iter()
23957                .map(|node| (&node.kind, &node.label))
23958                .collect::<Vec<_>>()
23959        );
23960        assert!(
23961            report
23962                .edges
23963                .iter()
23964                .any(|edge| edge.kind == "mentions_concept")
23965        );
23966        assert!(
23967            report.output_budget.as_ref().is_some_and(|budget| budget
23968                .diagnostics
23969                .iter()
23970                .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
23971            "expected related output budget diagnostics, got {:?}",
23972            report.output_budget
23973        );
23974    }
23975
23976    #[test]
23977    fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
23978        let dir = setup_graph_index();
23979        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23980        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23981
23982        let report = graph_db_report_from_store(
23983            dir.path(),
23984            None,
23985            "sqlite",
23986            GraphDbQuery::Related {
23987                query: "graph navigation".to_string(),
23988                kind: SemanticRelatedKind::All,
23989                depth: 1,
23990                seed_limit: 2,
23991                limit: 20,
23992            },
23993            &store,
23994            sqlite_graph_freshness(&store, "root").unwrap(),
23995            Vec::new(),
23996        )
23997        .unwrap();
23998
23999        let readiness = report.readiness.as_ref().unwrap();
24000        assert_eq!(readiness.status, "blocked");
24001        assert_eq!(readiness.reason, "summary_cache_empty");
24002        assert!(readiness.fail_closed);
24003        assert_eq!(
24004            readiness.next_commands,
24005            vec![
24006                "tsift summarize --extract .".to_string(),
24007                graph_db_refresh_command(dir.path(), None)
24008            ]
24009        );
24010        assert!(
24011            report
24012                .knowledge_retrieval
24013                .as_ref()
24014                .unwrap()
24015                .diagnostics
24016                .iter()
24017                .any(|diagnostic| diagnostic.contains("summary cache empty")
24018                    && diagnostic.contains("graph-db materialized code/session rows")),
24019            "expected related diagnostics to carry readiness gate, got {:?}",
24020            report.knowledge_retrieval.as_ref().unwrap().diagnostics
24021        );
24022    }
24023
24024    #[test]
24025    fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
24026        let mut nodes = vec![
24027            SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
24028            SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
24029        ];
24030        let mut edges = vec![SubstrateGraphEdge::new(
24031            "zzz_high",
24032            "seed",
24033            "mentions_concept",
24034        )];
24035        for idx in 0..24 {
24036            let id = format!("aaa_low_{idx:02}");
24037            nodes.push(SubstrateGraphNode::new(
24038                id.clone(),
24039                "note",
24040                format!("low {idx}"),
24041            ));
24042            edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
24043        }
24044        let mut store = SqliteGraphStore::in_memory().unwrap();
24045        store
24046            .replace_projection(&GraphProjection { nodes, edges })
24047            .unwrap();
24048
24049        let subgraph =
24050            graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
24051
24052        assert_eq!(subgraph.nodes.len(), 3);
24053        assert_eq!(subgraph.nodes[0].id, "seed");
24054        assert_eq!(
24055            subgraph.nodes[1].id, "zzz_high",
24056            "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
24057            subgraph.nodes
24058        );
24059        assert!(subgraph.truncated);
24060        assert!(
24061            subgraph
24062                .diagnostics
24063                .iter()
24064                .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
24065            "{:?}",
24066            subgraph.diagnostics
24067        );
24068        assert!(
24069            subgraph
24070                .diagnostics
24071                .iter()
24072                .any(|diagnostic| diagnostic.contains("skipped")),
24073            "{:?}",
24074            subgraph.diagnostics
24075        );
24076    }
24077
24078    #[test]
24079    fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
24080        let dir = setup_traversal_project();
24081        seed_traversal_semantic_summaries(dir.path());
24082        init_git_repo(dir.path());
24083        let session = dir.path().join("tasks/software/tsift.md");
24084        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24085        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24086        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24087        let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24088            root: dir.path(),
24089            scope: None,
24090            backend: "sqlite",
24091            target: "kgnv",
24092            depth: 4,
24093            limit: 8,
24094            cursor: None,
24095            store: &store,
24096            freshness,
24097            warnings: Vec::new(),
24098        })
24099        .unwrap();
24100        assert!(
24101            evidence
24102                .semantic_related
24103                .iter()
24104                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
24105            "expected semantic evidence rows, got {:?}",
24106            evidence
24107                .semantic_related
24108                .iter()
24109                .map(|node| (&node.kind, &node.label))
24110                .collect::<Vec<_>>()
24111        );
24112        assert!(
24113            evidence
24114                .output_budget
24115                .as_ref()
24116                .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
24117                    diagnostic.contains("semantic_match")
24118                        && diagnostic.contains("source_handle_coverage")
24119                })),
24120            "expected evidence output budget diagnostics, got {:?}",
24121            evidence.output_budget
24122        );
24123
24124        let cached_diff = diff_digest::compute(
24125            dir.path(),
24126            diff_digest::DiffDigestOptions {
24127                cached: true,
24128                revision: None,
24129                max_parsed_files: None,
24130            },
24131        )
24132        .unwrap();
24133        let impact_report = impact::compute(
24134            dir.path(),
24135            impact::ImpactOptions {
24136                cached: true,
24137                revision: None,
24138                scope: None,
24139                limit: 10,
24140            },
24141        )
24142        .unwrap();
24143        let graph_nodes = store.all_nodes().unwrap();
24144        let graph_index = conflict_matrix_graph_index(&graph_nodes);
24145        let semantic_candidate = conflict_matrix_candidate_from_evidence(
24146            dir.path(),
24147            &evidence,
24148            &graph_index,
24149            &cached_diff,
24150            &impact_report,
24151        );
24152        assert!(semantic_candidate.semantic_dispatch_score > 0);
24153        assert!(
24154            semantic_candidate
24155                .semantic_dispatch_reasons
24156                .iter()
24157                .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
24158            "expected semantic ranking explanations, got {:?}",
24159            semantic_candidate.semantic_dispatch_reasons
24160        );
24161        assert!(
24162            semantic_candidate
24163                .semantic_related
24164                .iter()
24165                .any(|item| item.label == "graph navigation")
24166        );
24167
24168        let mut plain_candidate = semantic_candidate.clone();
24169        plain_candidate.target = "plain".to_string();
24170        plain_candidate.semantic_related.clear();
24171        plain_candidate.semantic_dispatch_score = 0;
24172        plain_candidate.semantic_dispatch_reasons.clear();
24173        let mut ranked = [plain_candidate, semantic_candidate];
24174        ranked.sort_by(|left, right| {
24175            left.risk
24176                .cmp(&right.risk)
24177                .then_with(|| left.risk_score.cmp(&right.risk_score))
24178                .then_with(|| {
24179                    right
24180                        .semantic_dispatch_score
24181                        .cmp(&left.semantic_dispatch_score)
24182                })
24183                .then_with(|| left.target.cmp(&right.target))
24184        });
24185        assert_eq!(ranked[0].target, "kgnv");
24186    }
24187
24188    #[test]
24189    fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
24190        let dir = setup_dependency_dag_project();
24191        let session = dir.path().join("tasks/software/tsift.md");
24192        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24193
24194        assert_eq!(report.contract_version, "dependency-dag-v1");
24195        assert_eq!(
24196            report.targets,
24197            vec![
24198                "prep".to_string(),
24199                "alpha".to_string(),
24200                "beta".to_string(),
24201                "gamma".to_string()
24202            ]
24203        );
24204        assert!(report.edges.iter().any(|edge| {
24205            edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
24206        }));
24207        assert!(report.edges.iter().any(|edge| {
24208            edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
24209        }));
24210        assert!(report.edges.iter().any(|edge| {
24211            edge.from == "alpha"
24212                && edge.to == "beta"
24213                && edge.kind == "shared_resource"
24214                && edge.shared_files.contains(&"main.rs".to_string())
24215                && edge.shared_symbols.contains(&"shared_helper".to_string())
24216        }));
24217        assert!(
24218            !report.cycle_diagnostics.has_cycles,
24219            "{:?}",
24220            report.cycle_diagnostics
24221        );
24222        assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
24223        assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
24224        assert!(
24225            report.replay_commands[0].contains("dependency-dag"),
24226            "{:?}",
24227            report.replay_commands
24228        );
24229
24230        cmd_dependency_dag(
24231            &session,
24232            None,
24233            &["alpha".to_string(), "beta".to_string()],
24234            4,
24235            12,
24236            OutputFormat {
24237                json_output: true,
24238                compact: false,
24239                pretty: false,
24240                terse: false,
24241                ultra_terse: false,
24242                schema: false,
24243                envelope: false,
24244            },
24245        )
24246        .unwrap();
24247    }
24248
24249    #[test]
24250    fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
24251        let dir = setup_dependency_dag_cycle_project();
24252        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24253
24254        assert!(report.cycle_diagnostics.has_cycles);
24255        assert_eq!(
24256            report.cycle_diagnostics.blocked_nodes,
24257            vec!["left".to_string(), "right".to_string()]
24258        );
24259        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24260            edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
24261        }));
24262        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24263            edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
24264        }));
24265    }
24266
24267    #[test]
24268    fn traversal_projection_queries_match_sqlite_and_convex_stores() {
24269        let dir = setup_traversal_project();
24270        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24271        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
24272
24273        let mut sqlite = SqliteGraphStore::in_memory().unwrap();
24274        sqlite.replace_projection(&projection).unwrap();
24275        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24276        projection.upsert_into(&convex).unwrap();
24277
24278        let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
24279        let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
24280        assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
24281        assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
24282
24283        let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
24284        let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
24285        assert!(convex_graph.edges.iter().any(|edge| {
24286            edge.from == sqlite_backlog.handle
24287                && edge.to == convex_helper.handle
24288                && edge.relation == "mentions"
24289        }));
24290    }
24291
24292    #[test]
24293    fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
24294        let dir = setup_traversal_project();
24295        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24296        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24297        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24298        assert_eq!(freshness.status, "current");
24299
24300        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24301        let report = graph_db_report_from_store(
24302            dir.path(),
24303            None,
24304            "sqlite",
24305            GraphDbQuery::Neighborhood {
24306                id: backlog.handle.clone(),
24307                depth: 1,
24308                edge_kind: Some("mentions".to_string()),
24309                cursor: None,
24310                limit: None,
24311                property_filters: Vec::new(),
24312            },
24313            &store,
24314            freshness,
24315            Vec::new(),
24316        )
24317        .unwrap();
24318        assert!(
24319            report
24320                .edges
24321                .iter()
24322                .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
24323            "expected backlog mention edge, got {:?}",
24324            report.edges
24325        );
24326        assert!(
24327            report.ranked_neighbors.iter().any(|neighbor| {
24328                neighbor.depth == Some(1)
24329                    && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
24330                    && neighbor.node_id != backlog.handle
24331                    && neighbor.handle_coverage_pct >= 95.0
24332                    && neighbor.duplicate_name_precision >= 0.99
24333            }),
24334            "expected ranked neighborhood neighbors with quality scores, got {:?}",
24335            report.ranked_neighbors
24336        );
24337        assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
24338        let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
24339        assert!(!ranking_gate.ranked_output_default);
24340        assert_eq!(ranking_gate.default_order, "stable_node_id");
24341        assert!(
24342            ranking_gate
24343                .diagnostics
24344                .iter()
24345                .any(|diagnostic| diagnostic.contains("score-capped")),
24346            "{ranking_gate:?}"
24347        );
24348        assert!(
24349            ranking_gate
24350                .required_metrics
24351                .iter()
24352                .any(|metric| metric == "handle_coverage_pct")
24353        );
24354        assert!(
24355            ranking_gate
24356                .required_metrics
24357                .iter()
24358                .any(|metric| metric == "duplicate_name_precision")
24359        );
24360        assert!(
24361            report
24362                .page
24363                .as_ref()
24364                .unwrap()
24365                .diagnostics
24366                .iter()
24367                .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
24368            "expected SQLite neighborhood query plan diagnostics, got {:?}",
24369            report.page.as_ref().unwrap().diagnostics
24370        );
24371        let edges_report = graph_db_report_from_store(
24372            dir.path(),
24373            None,
24374            "sqlite",
24375            GraphDbQuery::Edges {
24376                edge_kind: Some("mentions".to_string()),
24377                cursor: None,
24378                limit: Some(2),
24379                property_filters: Vec::new(),
24380            },
24381            &store,
24382            sqlite_graph_freshness(&store, "root").unwrap(),
24383            Vec::new(),
24384        )
24385        .unwrap();
24386        let edge_id = edges_report
24387            .edges
24388            .first()
24389            .map(|edge| edge.id.clone())
24390            .expect("expected at least one paged mentions edge");
24391        assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
24392        assert_eq!(
24393            edges_report.page.as_ref().unwrap().returned_edges,
24394            edges_report.edges.len()
24395        );
24396
24397        let edge_report = graph_db_report_from_store(
24398            dir.path(),
24399            None,
24400            "sqlite",
24401            GraphDbQuery::Edge {
24402                id: edge_id.clone(),
24403            },
24404            &store,
24405            sqlite_graph_freshness(&store, "root").unwrap(),
24406            Vec::new(),
24407        )
24408        .unwrap();
24409        assert_eq!(
24410            edge_report
24411                .edge
24412                .as_ref()
24413                .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
24414            Some(edge_id.clone())
24415        );
24416
24417        let incident_report = graph_db_report_from_store(
24418            dir.path(),
24419            None,
24420            "sqlite",
24421            GraphDbQuery::Incident {
24422                id: backlog.handle.clone(),
24423                edge_kind: Some("mentions".to_string()),
24424                cursor: None,
24425                limit: Some(1),
24426                property_filters: Vec::new(),
24427            },
24428            &store,
24429            sqlite_graph_freshness(&store, "root").unwrap(),
24430            Vec::new(),
24431        )
24432        .unwrap();
24433        assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
24434        assert!(
24435            incident_report
24436                .edges
24437                .iter()
24438                .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
24439            "{:?}",
24440            incident_report.edges
24441        );
24442
24443        let schema_report = graph_db_report_from_store(
24444            dir.path(),
24445            None,
24446            "sqlite",
24447            GraphDbQuery::Schema,
24448            &store,
24449            sqlite_graph_freshness(&store, "root").unwrap(),
24450            Vec::new(),
24451        )
24452        .unwrap();
24453        assert!(
24454            schema_report
24455                .schema
24456                .unwrap()
24457                .operations
24458                .iter()
24459                .any(|operation| operation.command.starts_with("neighborhood"))
24460        );
24461    }
24462
24463    #[test]
24464    fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
24465        let mut nodes = vec![SubstrateGraphNode::new(
24466            "origin",
24467            "backlog",
24468            "#budgeted-neighborhood",
24469        )];
24470        let mut edges = Vec::new();
24471        for idx in 0..32 {
24472            let id = format!("src-{idx:02}");
24473            nodes.push(
24474                SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
24475                    .with_property("source_ref", format!("fixture:{idx}"))
24476                    .with_property("detail", "x".repeat(600)),
24477            );
24478            edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
24479        }
24480        let store = SqliteGraphStore::in_memory().unwrap();
24481        GraphProjection { nodes, edges }
24482            .upsert_into(&store)
24483            .unwrap();
24484
24485        let report = graph_db_report_from_store(
24486            Path::new("."),
24487            None,
24488            "fixture",
24489            GraphDbQuery::Neighborhood {
24490                id: "origin".to_string(),
24491                depth: 1,
24492                edge_kind: None,
24493                cursor: None,
24494                limit: None,
24495                property_filters: Vec::new(),
24496            },
24497            &store,
24498            current_graph_db_freshness(),
24499            Vec::new(),
24500        )
24501        .unwrap();
24502        let budget = report.output_budget.as_ref().unwrap();
24503        assert!(budget.selected_nodes < budget.candidate_nodes);
24504        assert!(
24505            budget.dropped_by_budget.iter().any(|drop| {
24506                drop.item == "node"
24507                    && drop.kind == "source_handle"
24508                    && drop.reason == "per_kind_quota"
24509            }),
24510            "expected source_handle budget drops, got {:?}",
24511            budget.dropped_by_budget
24512        );
24513        assert!(report.page.as_ref().unwrap().truncated);
24514        assert!(
24515            report
24516                .page
24517                .as_ref()
24518                .unwrap()
24519                .diagnostics
24520                .iter()
24521                .any(|diagnostic| diagnostic.contains("budget ranking signals")),
24522            "{:?}",
24523            report.page
24524        );
24525    }
24526
24527    #[test]
24528    fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
24529        let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
24530        let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
24531        for idx in 0..8 {
24532            let id = format!("far-{idx:02}");
24533            nodes.push(SubstrateGraphNode::new(
24534                id.clone(),
24535                "note",
24536                format!("aaa deeper row {idx}"),
24537            ));
24538            depth_by_id.insert(id, 6);
24539        }
24540
24541        let origin_ids = vec!["target".to_string()];
24542        let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
24543            &origin_ids,
24544            &BTreeMap::new(),
24545            nodes,
24546            Vec::new(),
24547            Some(3),
24548            Some(&depth_by_id),
24549            None,
24550        );
24551
24552        assert!(
24553            budgeted.nodes.iter().any(|node| node.id == "near"),
24554            "expected the shallow evidence row to outrank deeper rows, got {:?}",
24555            budgeted
24556                .nodes
24557                .iter()
24558                .map(|node| (&node.id, &node.label))
24559                .collect::<Vec<_>>()
24560        );
24561        assert!(
24562            budgeted.report.dropped_by_budget.iter().any(|drop| {
24563                drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
24564            }),
24565            "expected node quota drops, got {:?}",
24566            budgeted.report.dropped_by_budget
24567        );
24568        assert!(
24569            budgeted
24570                .report
24571                .diagnostics
24572                .iter()
24573                .any(|diagnostic| diagnostic.contains("depth")),
24574            "{:?}",
24575            budgeted.report.diagnostics
24576        );
24577    }
24578
24579    #[test]
24580    fn evidence_pagination_returns_next_cursor_when_truncated() {
24581        let mut nodes = vec![SubstrateGraphNode::new(
24582            "target".to_string(),
24583            "backlog_item",
24584            "target item".to_string(),
24585        )];
24586        let mut depth_by_id = BTreeMap::new();
24587        depth_by_id.insert("target".to_string(), 0);
24588        for idx in 0..20 {
24589            let id = format!("ev-{idx}");
24590            nodes.push(
24591                SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
24592                    .with_property("detail", "x".repeat(400)),
24593            );
24594            depth_by_id.insert(id, 1);
24595        }
24596        let origin_ids = vec!["target".to_string()];
24597        let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
24598            &origin_ids,
24599            &BTreeMap::new(),
24600            nodes.clone(),
24601            Vec::new(),
24602            Some(3),
24603            Some(&depth_by_id),
24604            None,
24605        );
24606        assert!(
24607            first_page.truncated,
24608            "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
24609            first_page.nodes.len(),
24610            first_page.report.candidate_nodes
24611        );
24612        assert!(
24613            first_page.next_cursor.is_some(),
24614            "expected next_cursor when truncated"
24615        );
24616        let cursor = first_page.next_cursor.unwrap();
24617        assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
24618        let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
24619        let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
24620            &origin_ids,
24621            &BTreeMap::new(),
24622            nodes.clone(),
24623            Vec::new(),
24624            Some(3),
24625            Some(&depth_by_id),
24626            Some(&cursor),
24627        );
24628        let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
24629        let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
24630        assert!(
24631            overlap.is_empty(),
24632            "pages should not overlap, but found shared ids: {overlap:?}"
24633        );
24634        assert!(
24635            second_page
24636                .report
24637                .diagnostics
24638                .iter()
24639                .any(|d| d.contains("cursor skipped")),
24640            "expected cursor skip diagnostic, got {:?}",
24641            second_page.report.diagnostics
24642        );
24643    }
24644
24645    #[test]
24646    fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
24647        let mut nodes = vec![SubstrateGraphNode::new(
24648            "target".to_string(),
24649            "backlog_item",
24650            "target item".to_string(),
24651        )];
24652        let mut depth_by_id = BTreeMap::new();
24653        depth_by_id.insert("target".to_string(), 0);
24654        for idx in 0..3 {
24655            let id = format!("ev-{idx}");
24656            nodes.push(SubstrateGraphNode::new(
24657                id.clone(),
24658                "source_handle",
24659                format!("evidence row {idx}"),
24660            ));
24661            depth_by_id.insert(id, 1);
24662        }
24663        let origin_ids = vec!["target".to_string()];
24664        let result = graph_db_apply_output_budget_with_depths_and_cursor(
24665            &origin_ids,
24666            &BTreeMap::new(),
24667            nodes,
24668            Vec::new(),
24669            None,
24670            Some(&depth_by_id),
24671            None,
24672        );
24673        assert!(
24674            !result.truncated,
24675            "expected no truncation with small candidate set and default budget"
24676        );
24677        assert!(
24678            result.next_cursor.is_none(),
24679            "expected no next_cursor when not truncated"
24680        );
24681    }
24682
24683    #[test]
24684    fn evidence_pagination_invalid_cursor_returns_first_page() {
24685        let mut nodes = vec![SubstrateGraphNode::new(
24686            "target".to_string(),
24687            "backlog_item",
24688            "target item".to_string(),
24689        )];
24690        let mut depth_by_id = BTreeMap::new();
24691        depth_by_id.insert("target".to_string(), 0);
24692        for idx in 0..5 {
24693            let id = format!("ev-{idx}");
24694            nodes.push(SubstrateGraphNode::new(
24695                id.clone(),
24696                "source_handle",
24697                format!("evidence row {idx}"),
24698            ));
24699            depth_by_id.insert(id, 1);
24700        }
24701        let origin_ids = vec!["target".to_string()];
24702        let result = graph_db_apply_output_budget_with_depths_and_cursor(
24703            &origin_ids,
24704            &BTreeMap::new(),
24705            nodes.clone(),
24706            Vec::new(),
24707            None,
24708            Some(&depth_by_id),
24709            Some("nonexistent-id"),
24710        );
24711        assert!(
24712            result
24713                .report
24714                .diagnostics
24715                .iter()
24716                .any(|d| d.contains("cursor skipped 0")),
24717            "invalid cursor should skip 0 candidates, got {:?}",
24718            result.report.diagnostics
24719        );
24720    }
24721
24722    #[test]
24723    fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
24724        let dir = setup_traversal_project();
24725        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24726        let graph_db = dir.path().join(".tsift/graph.db");
24727        let _lock = hold_rollback_journal_lock(&graph_db);
24728
24729        let report =
24730            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24731                .unwrap();
24732
24733        assert_eq!(report.status, "current");
24734        assert_eq!(
24735            report.recovery,
24736            Some(index::ReadOnlyRecovery::SnapshotFallback)
24737        );
24738        assert!(
24739            report
24740                .warnings
24741                .iter()
24742                .any(|warning| warning.contains("rollback-journal lock")),
24743            "expected rollback-journal recovery warning, got {:?}",
24744            report.warnings
24745        );
24746    }
24747
24748    #[test]
24749    fn graph_db_status_copies_wal_sidecars_when_locked() {
24750        let dir = setup_traversal_project();
24751        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24752        let graph_db = dir.path().join(".tsift/graph.db");
24753        let _lock = hold_wal_database_lock(&graph_db);
24754
24755        let report =
24756            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24757                .unwrap();
24758
24759        assert_eq!(report.status, "current");
24760        assert_eq!(
24761            report.recovery,
24762            Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
24763        );
24764        assert!(
24765            report
24766                .warnings
24767                .iter()
24768                .any(|warning| warning.contains("WAL-aware snapshot fallback")),
24769            "expected WAL recovery warning, got {:?}",
24770            report.warnings
24771        );
24772    }
24773
24774    #[test]
24775    fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
24776        let dir = setup_traversal_project();
24777        let session = dir.path().join("tasks/software/tsift.md");
24778        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24779        let graph_db = dir.path().join(".tsift/graph.db");
24780        let _lock = hold_rollback_journal_lock(&graph_db);
24781
24782        let result = cmd_graph_db(
24783            &session,
24784            None,
24785            GraphDbBackend::Sqlite,
24786            None,
24787            GraphDbQuery::Evidence {
24788                target: "kgnv".to_string(),
24789                depth: 3,
24790                limit: 8,
24791                cursor: None,
24792            },
24793            OutputFormat {
24794                json_output: false,
24795                compact: true,
24796                pretty: false,
24797                terse: false,
24798                ultra_terse: false,
24799                schema: false,
24800                envelope: false,
24801            },
24802        );
24803
24804        assert!(result.is_ok());
24805    }
24806
24807    fn current_graph_db_freshness() -> GraphDbFreshnessReport {
24808        GraphDbFreshnessReport {
24809            status: "current".to_string(),
24810            fail_closed: false,
24811            projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
24812            content_hash: Some("fixture".to_string()),
24813            source_watermark: None,
24814            diagnostics: Vec::new(),
24815        }
24816    }
24817
24818    #[test]
24819    fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
24820        let dir = setup_traversal_project();
24821        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24822        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24823        let stale = GraphDbFreshnessReport {
24824            status: "stale".to_string(),
24825            fail_closed: true,
24826            projection_version: Some("old-v0".to_string()),
24827            content_hash: None,
24828            source_watermark: None,
24829            diagnostics: vec!["projection content hash is missing".to_string()],
24830        };
24831
24832        let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24833            root: dir.path(),
24834            scope: None,
24835            backend: "sqlite",
24836            target: "kgnv",
24837            depth: 3,
24838            limit: 8,
24839            cursor: None,
24840            store: &store,
24841            freshness: stale,
24842            warnings: Vec::new(),
24843        }) {
24844            Ok(_) => panic!("stale graph freshness should fail closed"),
24845            Err(err) => err,
24846        };
24847        let message = err.to_string();
24848        assert!(message.contains("failed closed"), "{message}");
24849        assert!(message.contains("graph-db --path"), "{message}");
24850        assert!(message.contains("refresh --json"), "{message}");
24851    }
24852
24853    fn paged_graph_ids(
24854        store: &impl GraphStore,
24855        cursor: Option<&str>,
24856    ) -> (Vec<String>, GraphDbPageReport) {
24857        let report = graph_db_report_from_store(
24858            Path::new("."),
24859            None,
24860            "fixture",
24861            GraphDbQuery::Kind {
24862                kind: "backlog".to_string(),
24863                cursor: cursor.map(str::to_string),
24864                limit: Some(2),
24865                property_filters: vec!["phase=open".to_string()],
24866            },
24867            store,
24868            current_graph_db_freshness(),
24869            Vec::new(),
24870        )
24871        .unwrap();
24872        (
24873            report.nodes.iter().map(|node| node.id.clone()).collect(),
24874            report.page.unwrap(),
24875        )
24876    }
24877
24878    #[test]
24879    fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
24880        let nodes = (0..5)
24881            .map(|idx| {
24882                let phase = if idx == 1 { "closed" } else { "open" };
24883                SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
24884                    .with_property("phase", phase)
24885            })
24886            .collect::<Vec<_>>();
24887        let projection = GraphProjection {
24888            nodes,
24889            edges: Vec::new(),
24890        };
24891        let sqlite = SqliteGraphStore::in_memory().unwrap();
24892        projection.upsert_into(&sqlite).unwrap();
24893        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24894        projection.upsert_into(&convex).unwrap();
24895
24896        let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
24897        let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
24898        assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
24899        assert_eq!(sqlite_first_ids, convex_first_ids);
24900        assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
24901        assert!(sqlite_first_page.truncated);
24902        assert_eq!(
24903            sqlite_first_page.returned_nodes,
24904            convex_first_page.returned_nodes
24905        );
24906        assert_eq!(
24907            sqlite_first_page.property_filters,
24908            convex_first_page.property_filters
24909        );
24910        assert!(
24911            sqlite_first_page
24912                .diagnostics
24913                .iter()
24914                .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
24915            "expected SQLite kind query plan diagnostics, got {:?}",
24916            sqlite_first_page.diagnostics
24917        );
24918
24919        let cursor = sqlite_first_page.next_cursor.as_deref();
24920        let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
24921        let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
24922        assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
24923        assert_eq!(sqlite_next_ids, convex_next_ids);
24924        assert_eq!(sqlite_next_page.next_cursor, None);
24925        assert!(!sqlite_next_page.truncated);
24926        assert_eq!(
24927            sqlite_next_page.returned_nodes,
24928            convex_next_page.returned_nodes
24929        );
24930        assert_eq!(
24931            sqlite_next_page.property_filters,
24932            convex_next_page.property_filters
24933        );
24934    }
24935
24936    #[test]
24937    fn traversal_shortest_path_crosses_artifacts_and_symbols() {
24938        let dir = setup_traversal_project();
24939        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24940        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24941        let main = resolve_traversal_node(&graph, "main").unwrap();
24942
24943        let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
24944        assert_eq!(path.first(), Some(&backlog.handle));
24945        assert_eq!(path.last(), Some(&main.handle));
24946        assert!(
24947            path.len() >= 3,
24948            "expected backlog -> symbol -> main, got {path:?}"
24949        );
24950    }
24951
24952    #[test]
24953    fn traversal_report_recommends_next_bugfix_nodes() {
24954        let dir = setup_traversal_project();
24955        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24956        let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
24957
24958        assert_eq!(report.mode, "neighborhood");
24959        assert!(
24960            report
24961                .recommendations
24962                .iter()
24963                .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
24964            "expected helper recommendation, got {:?}",
24965            report.recommendations
24966        );
24967        assert!(
24968            !report.exploration.source_windows.is_empty(),
24969            "expected exploration source windows"
24970        );
24971        assert!(
24972            report
24973                .exploration
24974                .no_reread_guidance
24975                .contains("avoid whole-file reads")
24976        );
24977    }
24978
24979    #[test]
24980    fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
24981        let dir = setup_traversal_project();
24982        std::thread::sleep(std::time::Duration::from_millis(50));
24983        std::fs::write(
24984            dir.path().join("main.rs"),
24985            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
24986        )
24987        .unwrap();
24988
24989        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24990
24991        assert!(
24992            graph
24993                .warnings
24994                .iter()
24995                .any(|warning| warning.contains("index refreshed")
24996                    && warning.contains("graph traversal packet")),
24997            "expected refresh diagnostic, got {:?}",
24998            graph.warnings
24999        );
25000        assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
25001
25002        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
25003        let summary = db.compute_changes(dir.path()).unwrap();
25004        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
25005    }
25006
25007    #[test]
25008    fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
25009        let dir = setup_traversal_project();
25010        let db_path = dir.path().join(".tsift/index.db");
25011        let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
25012        std::thread::sleep(std::time::Duration::from_millis(50));
25013        std::fs::write(
25014            dir.path().join("main.rs"),
25015            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25016        )
25017        .unwrap();
25018
25019        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25020        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
25021
25022        assert!(
25023            graph
25024                .warnings
25025                .iter()
25026                .any(|warning| warning.contains("falling back to raw source file nodes")),
25027            "expected raw-source fallback diagnostic, got {:?}",
25028            graph.warnings
25029        );
25030        assert!(
25031            file.detail
25032                .as_deref()
25033                .is_some_and(|detail| detail.contains("raw source fallback")),
25034            "expected raw-source detail, got {:?}",
25035            file.detail
25036        );
25037        assert!(
25038            file.expand.contains("source-read"),
25039            "expected source-read fallback command, got {}",
25040            file.expand
25041        );
25042        assert!(
25043            resolve_traversal_node(&graph, "helper").is_none(),
25044            "stale symbol evidence should be skipped when refresh is blocked"
25045        );
25046    }
25047
25048    #[test]
25049    fn traversal_cmd_supports_json_and_html_outputs() {
25050        let dir = setup_traversal_project();
25051        cmd_traverse(
25052            Some("#kgnv"),
25053            Some("main"),
25054            dir.path(),
25055            None,
25056            1,
25057            50,
25058            TraverseFormat::Json,
25059            false,
25060            false,
25061            false,
25062            None,
25063        )
25064        .unwrap();
25065        cmd_traverse(
25066            None,
25067            None,
25068            dir.path(),
25069            None,
25070            1,
25071            50,
25072            TraverseFormat::Html,
25073            false,
25074            false,
25075            false,
25076            None,
25077        )
25078        .unwrap();
25079    }
25080
25081    #[test]
25082    fn traversal_html_renders_inline_graph_visualization() {
25083        let dir = setup_traversal_project();
25084        seed_traversal_semantic_summaries(dir.path());
25085        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25086        let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
25087        let html = traversal_report_html(&report).unwrap();
25088
25089        assert!(html.contains("id=\"graph-canvas\""));
25090        assert!(html.contains("semantic_concept"));
25091        assert!(html.contains("graph navigation"));
25092        assert!(html.contains("JSON.parse"));
25093    }
25094
25095    #[test]
25096    fn compact_helpers_trim_scores_and_snippets() {
25097        assert_eq!(format_score(0.12345, true), "0.12");
25098        assert_eq!(format_score(0.12345, false), "0.1235");
25099        let snippet = compact_snippet("    first line with useful context\nsecond");
25100        assert_eq!(snippet.as_deref(), Some("first line with useful context"));
25101    }
25102
25103    #[test]
25104    fn compact_members_caps_list() {
25105        let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
25106            .iter()
25107            .map(|n| graph::CommunityMember::new(*n))
25108            .collect();
25109        assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
25110    }
25111
25112    #[test]
25113    fn abbreviate_kind_maps_common_kinds() {
25114        assert_eq!(abbreviate_kind("function"), "fn");
25115        assert_eq!(abbreviate_kind("method"), "meth");
25116        assert_eq!(abbreviate_kind("class"), "cls");
25117        assert_eq!(abbreviate_kind("interface"), "iface");
25118        assert_eq!(abbreviate_kind("type_alias"), "type");
25119        assert_eq!(abbreviate_kind("data_class"), "data_cls");
25120        assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
25121        assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
25122        assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
25123        assert_eq!(abbreviate_kind("object"), "obj");
25124        assert_eq!(abbreviate_kind("heading"), "h");
25125        assert_eq!(abbreviate_kind("code_block"), "code");
25126        // short kinds pass through
25127        assert_eq!(abbreviate_kind("struct"), "struct");
25128        assert_eq!(abbreviate_kind("trait"), "trait");
25129        assert_eq!(abbreviate_kind("enum"), "enum");
25130        assert_eq!(abbreviate_kind("const"), "const");
25131        assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
25132    }
25133
25134    #[test]
25135    fn abbreviate_match_type_maps_search_types() {
25136        assert_eq!(abbreviate_match_type("exact_name"), "exact");
25137        assert_eq!(abbreviate_match_type("partial_tags"), "partial");
25138        assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
25139        assert_eq!(abbreviate_match_type("other_type"), "other_type");
25140    }
25141
25142    #[test]
25143    fn explain_compact_groups_edges_by_file() {
25144        let edges = vec![
25145            index::StoredEdge {
25146                caller_file: "src/main.rs".to_string(),
25147                caller_name: "main".to_string(),
25148                caller_line: 1,
25149                callee_name: "helper".to_string(),
25150                call_site_line: 2,
25151                tagpath_handle: None,
25152            },
25153            index::StoredEdge {
25154                caller_file: "src/main.rs".to_string(),
25155                caller_name: "main".to_string(),
25156                caller_line: 1,
25157                callee_name: "render".to_string(),
25158                call_site_line: 3,
25159                tagpath_handle: None,
25160            },
25161        ];
25162        let lines = format_edge_groups(&edges, false);
25163        assert_eq!(lines, vec!["  src/main.rs (2): helper, render"]);
25164    }
25165
25166    #[test]
25167    fn search_hit_groups_preserve_file_counts_and_samples() {
25168        let dir = tempfile::tempdir().unwrap();
25169        let root = dir.path();
25170        let main_rs = root.join("src/main.rs");
25171        fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
25172        fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
25173        let freshness = exact_search_file_timestamp(&main_rs);
25174        let hits = vec![
25175            sift::SearchHit {
25176                artifact_id: "a".to_string(),
25177                artifact_kind: sift::ContextArtifactKind::File,
25178                path: main_rs.display().to_string(),
25179                rank: 1,
25180                score: 10.0,
25181                confidence: sift::ScoreConfidence::High,
25182                location: Some("line 3".to_string()),
25183                snippet: "claudescore-3 anchor".to_string(),
25184                provenance: sift::ArtifactProvenance {
25185                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25186                    source: "ripgrep -F".to_string(),
25187                    synthetic: false,
25188                },
25189                freshness: freshness.clone(),
25190                budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
25191            },
25192            sift::SearchHit {
25193                artifact_id: "b".to_string(),
25194                artifact_kind: sift::ContextArtifactKind::File,
25195                path: main_rs.display().to_string(),
25196                rank: 2,
25197                score: 9.0,
25198                confidence: sift::ScoreConfidence::High,
25199                location: Some("line 7".to_string()),
25200                snippet: "claudescore-3 follow-up".to_string(),
25201                provenance: sift::ArtifactProvenance {
25202                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25203                    source: "ripgrep -F".to_string(),
25204                    synthetic: false,
25205                },
25206                freshness: freshness.clone(),
25207                budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
25208            },
25209            sift::SearchHit {
25210                artifact_id: "c".to_string(),
25211                artifact_kind: sift::ContextArtifactKind::File,
25212                path: main_rs.display().to_string(),
25213                rank: 3,
25214                score: 8.0,
25215                confidence: sift::ScoreConfidence::High,
25216                location: Some("line 9".to_string()),
25217                snippet: "claudescore-3 tail".to_string(),
25218                provenance: sift::ArtifactProvenance {
25219                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25220                    source: "ripgrep -F".to_string(),
25221                    synthetic: false,
25222                },
25223                freshness,
25224                budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
25225            },
25226        ];
25227
25228        let groups = group_search_hits(&hits, root, false);
25229        assert_eq!(groups.len(), 1);
25230        assert_eq!(groups[0].path, "src/main.rs");
25231        assert_eq!(groups[0].hits, 3);
25232        assert_eq!(
25233            groups[0].samples,
25234            vec![
25235                "line 3: claudescore-3 anchor".to_string(),
25236                "line 7: claudescore-3 follow-up".to_string()
25237            ]
25238        );
25239        assert!(should_collapse_search_hits(&hits, root, false));
25240    }
25241
25242    #[test]
25243    fn dense_edge_groups_trigger_collapse() {
25244        let edges = vec![
25245            index::StoredEdge {
25246                caller_file: "src/main.rs".to_string(),
25247                caller_name: "main".to_string(),
25248                caller_line: 1,
25249                callee_name: "helper".to_string(),
25250                call_site_line: 2,
25251                tagpath_handle: None,
25252            },
25253            index::StoredEdge {
25254                caller_file: "src/main.rs".to_string(),
25255                caller_name: "beta".to_string(),
25256                caller_line: 5,
25257                callee_name: "helper".to_string(),
25258                call_site_line: 6,
25259                tagpath_handle: None,
25260            },
25261            index::StoredEdge {
25262                caller_file: "src/main.rs".to_string(),
25263                caller_name: "gamma".to_string(),
25264                caller_line: 9,
25265                callee_name: "helper".to_string(),
25266                call_site_line: 10,
25267                tagpath_handle: None,
25268            },
25269        ];
25270        assert!(should_collapse_edge_groups(&edges));
25271    }
25272
25273    // --- workspace indexing ---
25274
25275    fn setup_workspace() -> tempfile::TempDir {
25276        let dir = tempfile::tempdir().unwrap();
25277        let root = dir.path();
25278        std::fs::write(
25279            root.join(".gitmodules"),
25280            r#"[submodule "src/alpha"]
25281	path = src/alpha
25282	url = https://example.com/alpha
25283[submodule "src/beta"]
25284	path = src/beta
25285	url = https://example.com/beta
25286"#,
25287        )
25288        .unwrap();
25289        let alpha = root.join("src/alpha");
25290        let beta = root.join("src/beta");
25291        std::fs::create_dir_all(&alpha).unwrap();
25292        std::fs::create_dir_all(&beta).unwrap();
25293        std::fs::write(
25294            alpha.join("lib.rs"),
25295            "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
25296        )
25297        .unwrap();
25298        std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
25299        dir
25300    }
25301
25302    fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
25303        let dir = tempfile::tempdir().unwrap();
25304        let root = dir.path();
25305        std::fs::write(
25306            root.join(".gitmodules"),
25307            r#"[submodule "pkg/app/foo"]
25308	path = pkg/app/foo
25309	url = https://example.com/pkg-app-foo
25310[submodule "vendor/foo"]
25311	path = vendor/foo
25312	url = https://example.com/vendor-foo
25313"#,
25314        )
25315        .unwrap();
25316        let pkg_foo = root.join("pkg/app/foo");
25317        let vendor_foo = root.join("vendor/foo");
25318        std::fs::create_dir_all(&pkg_foo).unwrap();
25319        std::fs::create_dir_all(&vendor_foo).unwrap();
25320        std::fs::write(
25321            pkg_foo.join("lib.rs"),
25322            "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
25323        )
25324        .unwrap();
25325        std::fs::write(
25326            vendor_foo.join("lib.rs"),
25327            "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
25328        )
25329        .unwrap();
25330        dir
25331    }
25332
25333    #[test]
25334    fn workspace_index_creates_per_submodule_dbs() {
25335        let dir = setup_workspace();
25336        cmd_index(
25337            dir.path(),
25338            false,
25339            false,
25340            false,
25341            false,
25342            false,
25343            true,
25344            None,
25345            false,
25346            false,
25347            false,
25348            false,
25349            false,
25350            false,
25351        )
25352        .unwrap();
25353        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25354        assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
25355    }
25356
25357    #[test]
25358    fn workspace_index_single_submodule() {
25359        let dir = setup_workspace();
25360        cmd_index(
25361            dir.path(),
25362            false,
25363            false,
25364            false,
25365            false,
25366            false,
25367            false,
25368            Some("alpha"),
25369            false,
25370            false,
25371            false,
25372            false,
25373            false,
25374            false,
25375        )
25376        .unwrap();
25377        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25378        assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
25379    }
25380
25381    #[test]
25382    fn workspace_index_single_submodule_errors_on_unknown_scope() {
25383        let dir = setup_workspace();
25384
25385        let err = cmd_index(
25386            dir.path(),
25387            false,
25388            false,
25389            false,
25390            false,
25391            false,
25392            false,
25393            Some("missing"),
25394            false,
25395            false,
25396            false,
25397            false,
25398            false,
25399            false,
25400        )
25401        .unwrap_err();
25402
25403        let msg = err.to_string();
25404        assert!(msg.contains("unknown scope `missing`"));
25405        assert!(msg.contains("Available scopes: alpha, beta"));
25406        assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
25407    }
25408
25409    #[test]
25410    fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
25411        let dir = setup_workspace_with_duplicate_leaf_names();
25412        cmd_index(
25413            dir.path(),
25414            false,
25415            false,
25416            false,
25417            false,
25418            false,
25419            true,
25420            None,
25421            false,
25422            false,
25423            false,
25424            false,
25425            false,
25426            false,
25427        )
25428        .unwrap();
25429
25430        assert!(
25431            dir.path()
25432                .join(".tsift/indexes/pkg/app/foo/index.db")
25433                .exists()
25434        );
25435        assert!(
25436            dir.path()
25437                .join(".tsift/indexes/vendor/foo/index.db")
25438                .exists()
25439        );
25440    }
25441
25442    #[test]
25443    fn federated_search_across_submodules() {
25444        let dir = setup_workspace();
25445        cmd_index(
25446            dir.path(),
25447            false,
25448            false,
25449            false,
25450            false,
25451            false,
25452            true,
25453            None,
25454            false,
25455            false,
25456            false,
25457            false,
25458            false,
25459            false,
25460        )
25461        .unwrap();
25462        let (hits, _diag) = federated_symbol_search(
25463            dir.path(),
25464            "alpha_helper",
25465            10,
25466            &TagpathSearchOpts {
25467                no_tagpath: true,
25468                strict: false,
25469            },
25470        )
25471        .unwrap();
25472        assert!(
25473            !hits.is_empty(),
25474            "should find alpha_helper via federated search"
25475        );
25476    }
25477
25478    #[test]
25479    fn federated_search_respects_isolation() {
25480        let dir = setup_workspace();
25481        let tsift_dir = dir.path().join(".tsift");
25482        std::fs::create_dir_all(&tsift_dir).unwrap();
25483        std::fs::write(
25484            tsift_dir.join("config.toml"),
25485            r#"
25486[overrides.alpha]
25487tier = "isolated"
25488"#,
25489        )
25490        .unwrap();
25491        cmd_index(
25492            dir.path(),
25493            false,
25494            false,
25495            false,
25496            false,
25497            false,
25498            true,
25499            None,
25500            false,
25501            false,
25502            false,
25503            false,
25504            false,
25505            false,
25506        )
25507        .unwrap();
25508        let (hits, _diag) = federated_symbol_search(
25509            dir.path(),
25510            "alpha_helper",
25511            10,
25512            &TagpathSearchOpts {
25513                no_tagpath: true,
25514                strict: false,
25515            },
25516        )
25517        .unwrap();
25518        assert!(
25519            hits.is_empty(),
25520            "isolated submodule should not appear in federated search"
25521        );
25522    }
25523
25524    #[test]
25525    fn federated_lexical_search_respects_isolation() {
25526        let dir = setup_workspace();
25527        let tsift_dir = dir.path().join(".tsift");
25528        std::fs::create_dir_all(&tsift_dir).unwrap();
25529        std::fs::write(
25530            tsift_dir.join("config.toml"),
25531            r#"
25532[overrides.alpha]
25533tier = "isolated"
25534"#,
25535        )
25536        .unwrap();
25537        cmd_index(
25538            dir.path(),
25539            false,
25540            false,
25541            false,
25542            false,
25543            false,
25544            true,
25545            None,
25546            false,
25547            false,
25548            false,
25549            false,
25550            false,
25551            false,
25552        )
25553        .unwrap();
25554
25555        let response = federated_sift_search(
25556            dir.path(),
25557            &dir.path().join(".tsift/search-cache"),
25558            "fn",
25559            10,
25560            0,
25561            "lexical",
25562        )
25563        .unwrap();
25564
25565        assert!(
25566            !response.hits.is_empty(),
25567            "shared scopes should still contribute lexical hits"
25568        );
25569        assert!(
25570            response
25571                .hits
25572                .iter()
25573                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25574            "isolated scope should not leak lexical hits: {:?}",
25575            response.hits
25576        );
25577    }
25578
25579    #[test]
25580    fn federated_lexical_search_respects_private_tier() {
25581        let dir = setup_workspace();
25582        let tsift_dir = dir.path().join(".tsift");
25583        std::fs::create_dir_all(&tsift_dir).unwrap();
25584        std::fs::write(
25585            tsift_dir.join("config.toml"),
25586            r#"
25587[overrides.alpha]
25588tier = "private"
25589"#,
25590        )
25591        .unwrap();
25592        cmd_index(
25593            dir.path(),
25594            false,
25595            false,
25596            false,
25597            false,
25598            false,
25599            true,
25600            None,
25601            false,
25602            false,
25603            false,
25604            false,
25605            false,
25606            false,
25607        )
25608        .unwrap();
25609
25610        let response = federated_sift_search(
25611            dir.path(),
25612            &dir.path().join(".tsift/search-cache"),
25613            "fn",
25614            10,
25615            0,
25616            "lexical",
25617        )
25618        .unwrap();
25619
25620        assert!(
25621            !response.hits.is_empty(),
25622            "shared scopes should still contribute lexical hits"
25623        );
25624        assert!(
25625            response
25626                .hits
25627                .iter()
25628                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25629            "private scope should not leak lexical hits: {:?}",
25630            response.hits
25631        );
25632    }
25633
25634    #[test]
25635    fn scoped_search_finds_submodule_symbols() {
25636        let dir = setup_workspace();
25637        cmd_index(
25638            dir.path(),
25639            false,
25640            false,
25641            false,
25642            false,
25643            false,
25644            true,
25645            None,
25646            false,
25647            false,
25648            false,
25649            false,
25650            false,
25651            false,
25652        )
25653        .unwrap();
25654        let cfg = config::Config::load(dir.path()).unwrap();
25655        let db_path = cfg.db_path_for(dir.path(), "alpha");
25656        let db = index::IndexDb::open(&db_path).unwrap();
25657        let hits = db.symbol_search("alpha_main", 10).unwrap();
25658        assert!(!hits.is_empty());
25659        assert_eq!(hits[0].name, "alpha_main");
25660    }
25661
25662    #[test]
25663    fn scoped_search_cmd_errors_on_unknown_scope() {
25664        let dir = setup_workspace();
25665
25666        let err = cmd_search(
25667            "alpha_main".to_string(),
25668            Some(dir.path().to_path_buf()),
25669            5,
25670            Some("lexical".to_string()),
25671            Some("missing".to_string()),
25672            false,
25673            false,
25674            false,
25675            0,
25676            false,
25677            false,
25678            false,
25679            false,
25680            false,
25681            false,
25682            false,
25683        )
25684        .unwrap_err();
25685
25686        let msg = err.to_string();
25687        assert!(msg.contains("unknown scope `missing`"));
25688        assert!(msg.contains("Available scopes: alpha, beta"));
25689    }
25690
25691    #[test]
25692    fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
25693        let dir = setup_workspace_with_duplicate_leaf_names();
25694        cmd_index(
25695            dir.path(),
25696            false,
25697            false,
25698            false,
25699            false,
25700            false,
25701            true,
25702            None,
25703            false,
25704            false,
25705            false,
25706            false,
25707            false,
25708            false,
25709        )
25710        .unwrap();
25711
25712        let err = cmd_search(
25713            "vendor_only".to_string(),
25714            Some(dir.path().to_path_buf()),
25715            5,
25716            Some("lexical".to_string()),
25717            Some("foo".to_string()),
25718            false,
25719            false,
25720            false,
25721            0,
25722            false,
25723            false,
25724            false,
25725            false,
25726            false,
25727            false,
25728            false,
25729        )
25730        .unwrap_err();
25731
25732        let msg = err.to_string();
25733        assert!(msg.contains("ambiguous scope `foo`"));
25734        assert!(msg.contains("pkg/app/foo"));
25735        assert!(msg.contains("vendor/foo"));
25736    }
25737
25738    #[test]
25739    fn scoped_graph_query() {
25740        let dir = setup_workspace();
25741        cmd_index(
25742            dir.path(),
25743            false,
25744            false,
25745            false,
25746            false,
25747            false,
25748            true,
25749            None,
25750            false,
25751            false,
25752            false,
25753            false,
25754            false,
25755            false,
25756        )
25757        .unwrap();
25758        let cfg = config::Config::load(dir.path()).unwrap();
25759        let db_path = cfg.db_path_for(dir.path(), "alpha");
25760        let db = index::IndexDb::open(&db_path).unwrap();
25761        let callees = db.callees_of("alpha_main").unwrap();
25762        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
25763        assert!(names.contains(&"alpha_helper"));
25764    }
25765
25766    fn assert_workspace_query_requires_scope(err: anyhow::Error) {
25767        let msg = err.to_string();
25768        assert!(msg.contains("require `--scope <scope>`"), "{msg}");
25769        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
25770        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
25771        assert!(
25772            !msg.contains("no index found at"),
25773            "workspace query should fail with scope guidance, got: {msg}"
25774        );
25775    }
25776
25777    fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
25778        let msg = err.to_string();
25779        assert!(
25780            msg.contains("requires `--scope <scope>` or `--federated`"),
25781            "{msg}"
25782        );
25783        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
25784        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
25785        assert!(
25786            !msg.contains("autoindexing index"),
25787            "workspace search should fail before creating a shared root index: {msg}"
25788        );
25789    }
25790
25791    #[test]
25792    fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
25793        let dir = setup_workspace();
25794        cmd_index(
25795            dir.path(),
25796            false,
25797            false,
25798            false,
25799            false,
25800            false,
25801            true,
25802            None,
25803            false,
25804            false,
25805            false,
25806            false,
25807            false,
25808            false,
25809        )
25810        .unwrap();
25811
25812        let err = cmd_graph(
25813            "alpha_main",
25814            dir.path(),
25815            false,
25816            false,
25817            None,
25818            20,
25819            false,
25820            false,
25821            false,
25822            false,
25823            false,
25824            false,
25825            false,
25826            TagpathSearchOpts::default(),
25827        )
25828        .unwrap_err();
25829
25830        assert_workspace_query_requires_scope(err);
25831    }
25832
25833    #[test]
25834    fn graph_cmd_infers_scope_from_nested_workspace_path() {
25835        let dir = setup_workspace();
25836        cmd_index(
25837            dir.path(),
25838            false,
25839            false,
25840            false,
25841            false,
25842            false,
25843            true,
25844            None,
25845            false,
25846            false,
25847            false,
25848            false,
25849            false,
25850            false,
25851        )
25852        .unwrap();
25853        let nested = dir.path().join("src/alpha/nested");
25854        std::fs::create_dir_all(&nested).unwrap();
25855
25856        let result = cmd_graph(
25857            "alpha_main",
25858            &nested,
25859            false,
25860            false,
25861            None,
25862            20,
25863            false,
25864            false,
25865            false,
25866            false,
25867            false,
25868            false,
25869            false,
25870            TagpathSearchOpts::default(),
25871        );
25872
25873        assert!(result.is_ok());
25874    }
25875
25876    #[test]
25877    fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
25878        let dir = setup_workspace();
25879        cmd_index(
25880            dir.path(),
25881            false,
25882            false,
25883            false,
25884            false,
25885            false,
25886            true,
25887            None,
25888            false,
25889            false,
25890            false,
25891            false,
25892            false,
25893            false,
25894        )
25895        .unwrap();
25896
25897        let err = cmd_communities(
25898            dir.path(),
25899            None,
25900            1,
25901            10,
25902            false,
25903            false,
25904            false,
25905            false,
25906            false,
25907            false,
25908            TagpathSearchOpts::default(),
25909        )
25910        .unwrap_err();
25911
25912        assert_workspace_query_requires_scope(err);
25913    }
25914
25915    #[test]
25916    fn communities_cmd_infers_scope_from_nested_workspace_path() {
25917        let dir = setup_workspace();
25918        cmd_index(
25919            dir.path(),
25920            false,
25921            false,
25922            false,
25923            false,
25924            false,
25925            true,
25926            None,
25927            false,
25928            false,
25929            false,
25930            false,
25931            false,
25932            false,
25933        )
25934        .unwrap();
25935        let nested = dir.path().join("src/alpha/nested");
25936        std::fs::create_dir_all(&nested).unwrap();
25937
25938        let result = cmd_communities(
25939            &nested,
25940            None,
25941            1,
25942            10,
25943            false,
25944            false,
25945            false,
25946            false,
25947            false,
25948            false,
25949            TagpathSearchOpts::default(),
25950        );
25951
25952        assert!(result.is_ok());
25953    }
25954
25955    #[test]
25956    fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
25957        let dir = setup_workspace();
25958        cmd_index(
25959            dir.path(),
25960            false,
25961            false,
25962            false,
25963            false,
25964            false,
25965            true,
25966            None,
25967            false,
25968            false,
25969            false,
25970            false,
25971            false,
25972            false,
25973        )
25974        .unwrap();
25975
25976        let err = cmd_path(
25977            "alpha_main",
25978            "alpha_helper",
25979            dir.path(),
25980            None,
25981            false,
25982            false,
25983            false,
25984            false,
25985            false,
25986            TagpathSearchOpts::default(),
25987        )
25988        .unwrap_err();
25989
25990        assert_workspace_query_requires_scope(err);
25991    }
25992
25993    #[test]
25994    fn path_cmd_infers_scope_from_nested_workspace_path() {
25995        let dir = setup_workspace();
25996        cmd_index(
25997            dir.path(),
25998            false,
25999            false,
26000            false,
26001            false,
26002            false,
26003            true,
26004            None,
26005            false,
26006            false,
26007            false,
26008            false,
26009            false,
26010            false,
26011        )
26012        .unwrap();
26013        let nested = dir.path().join("src/alpha/nested");
26014        std::fs::create_dir_all(&nested).unwrap();
26015
26016        let result = cmd_path(
26017            "alpha_main",
26018            "alpha_helper",
26019            &nested,
26020            None,
26021            false,
26022            false,
26023            false,
26024            false,
26025            false,
26026            TagpathSearchOpts::default(),
26027        );
26028
26029        assert!(result.is_ok());
26030    }
26031
26032    #[test]
26033    fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26034        let dir = setup_graph_index();
26035        let db_path = dir.path().join(".tsift/index.db");
26036        let _lock = hold_rollback_journal_lock(&db_path);
26037
26038        let result = cmd_path(
26039            "main",
26040            "helper",
26041            dir.path(),
26042            None,
26043            false,
26044            false,
26045            false,
26046            false,
26047            false,
26048            TagpathSearchOpts::default(),
26049        );
26050
26051        assert!(result.is_ok());
26052    }
26053
26054    #[test]
26055    fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
26056        let dir = setup_workspace();
26057        cmd_index(
26058            dir.path(),
26059            false,
26060            false,
26061            false,
26062            false,
26063            false,
26064            true,
26065            None,
26066            false,
26067            false,
26068            false,
26069            false,
26070            false,
26071            false,
26072        )
26073        .unwrap();
26074
26075        let err = cmd_explain(
26076            "alpha_main",
26077            dir.path(),
26078            None,
26079            15,
26080            false,
26081            false,
26082            false,
26083            false,
26084            false,
26085            false,
26086            false,
26087            false,
26088        )
26089        .unwrap_err();
26090
26091        assert_workspace_query_requires_scope(err);
26092    }
26093
26094    #[test]
26095    fn explain_cmd_infers_scope_from_nested_workspace_path() {
26096        let dir = setup_workspace();
26097        cmd_index(
26098            dir.path(),
26099            false,
26100            false,
26101            false,
26102            false,
26103            false,
26104            true,
26105            None,
26106            false,
26107            false,
26108            false,
26109            false,
26110            false,
26111            false,
26112        )
26113        .unwrap();
26114        let nested = dir.path().join("src/alpha/nested");
26115        std::fs::create_dir_all(&nested).unwrap();
26116
26117        let result = cmd_explain(
26118            "alpha_main",
26119            &nested,
26120            None,
26121            15,
26122            false,
26123            false,
26124            false,
26125            false,
26126            false,
26127            false,
26128            false,
26129            false,
26130        );
26131
26132        assert!(result.is_ok());
26133    }
26134
26135    #[test]
26136    fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26137        let dir = setup_graph_index();
26138        let db_path = dir.path().join(".tsift/index.db");
26139        let _lock = hold_rollback_journal_lock(&db_path);
26140
26141        let result = cmd_explain(
26142            "main",
26143            dir.path(),
26144            None,
26145            15,
26146            false,
26147            false,
26148            false,
26149            false,
26150            false,
26151            false,
26152            false,
26153            false,
26154        );
26155
26156        assert!(result.is_ok());
26157    }
26158
26159    // --- community detection ---
26160
26161    #[test]
26162    fn community_detection_groups_related() {
26163        let dir = setup_graph_index();
26164        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26165        let edges = db.all_edges().unwrap();
26166        let result = graph::detect_communities(&edges);
26167        assert!(result.node_count > 0);
26168        assert!(!result.communities.is_empty());
26169    }
26170
26171    #[test]
26172    fn community_cmd_autoindexes_missing_index_by_default() {
26173        let dir = tempfile::tempdir().unwrap();
26174        let result = cmd_communities(
26175            dir.path(),
26176            None,
26177            2,
26178            10,
26179            false,
26180            false,
26181            false,
26182            false,
26183            false,
26184            false,
26185            TagpathSearchOpts::default(),
26186        );
26187
26188        assert!(result.is_ok());
26189        assert!(dir.path().join(".tsift/index.db").exists());
26190    }
26191
26192    // --- path ---
26193
26194    #[test]
26195    fn path_finds_connected_symbols() {
26196        let dir = setup_graph_index();
26197        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26198        let edges = db.all_edges().unwrap();
26199        let result = graph::shortest_path(&edges, "main", "helper");
26200        assert!(result.is_some());
26201        let path = result.unwrap();
26202        assert_eq!(path.hops, 1);
26203    }
26204
26205    #[test]
26206    fn path_returns_none_for_unknown() {
26207        let dir = setup_graph_index();
26208        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26209        let edges = db.all_edges().unwrap();
26210        assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
26211    }
26212
26213    #[test]
26214    fn path_cmd_autoindexes_missing_index_by_default() {
26215        let dir = tempfile::tempdir().unwrap();
26216        let result = cmd_path(
26217            "a",
26218            "b",
26219            dir.path(),
26220            None,
26221            false,
26222            false,
26223            false,
26224            false,
26225            false,
26226            TagpathSearchOpts::default(),
26227        );
26228
26229        assert!(result.is_ok());
26230        assert!(dir.path().join(".tsift/index.db").exists());
26231    }
26232
26233    // --- explain ---
26234
26235    #[test]
26236    fn explain_shows_symbol_info() {
26237        let dir = setup_graph_index();
26238        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26239        let symbols = db.symbol_info("main").unwrap();
26240        assert!(!symbols.is_empty());
26241        assert_eq!(symbols[0].name, "main");
26242        assert_eq!(symbols[0].kind, "function");
26243    }
26244
26245    #[test]
26246    fn explain_cmd_autoindexes_missing_index_by_default() {
26247        let dir = tempfile::tempdir().unwrap();
26248        let result = cmd_explain(
26249            "main",
26250            dir.path(),
26251            None,
26252            15,
26253            false,
26254            false,
26255            false,
26256            false,
26257            false,
26258            false,
26259            false,
26260            false,
26261        );
26262
26263        assert!(result.is_ok());
26264        assert!(dir.path().join(".tsift/index.db").exists());
26265    }
26266
26267    fn hold_write_lock(db_path: &std::path::Path) -> Connection {
26268        let conn = Connection::open(db_path).unwrap();
26269        conn.execute_batch("BEGIN IMMEDIATE").unwrap();
26270        conn
26271    }
26272
26273    fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
26274        use fs4::fs_std::FileExt;
26275        use std::io::Write;
26276
26277        let mut file = std::fs::OpenOptions::new()
26278            .read(true)
26279            .write(true)
26280            .create(true)
26281            .truncate(false)
26282            .open(lock_path)
26283            .unwrap();
26284        assert!(file.try_lock_exclusive().unwrap());
26285        writeln!(file, "{}", std::process::id()).unwrap();
26286        file
26287    }
26288
26289    fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
26290        let conn = Connection::open(db_path).unwrap();
26291        conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26292            .unwrap();
26293        std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
26294        conn
26295    }
26296
26297    fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
26298        let conn = Connection::open(db_path).unwrap();
26299        conn.execute_batch(
26300            "PRAGMA journal_mode=WAL;
26301             PRAGMA wal_autocheckpoint=0;
26302             CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
26303             INSERT INTO wal_lock_probe DEFAULT VALUES;
26304             PRAGMA locking_mode=EXCLUSIVE;
26305             BEGIN EXCLUSIVE;",
26306        )
26307        .unwrap();
26308        assert!(substrate::wal_sidecar_path(db_path).exists());
26309        conn
26310    }
26311
26312    #[test]
26313    fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
26314        let dir = setup_graph_index();
26315        let db_path = dir.path().join(".tsift/index.db");
26316        let _lock = hold_wal_database_lock(&db_path);
26317
26318        let err = cmd_index(
26319            dir.path(),
26320            false,
26321            false,
26322            false,
26323            false,
26324            false,
26325            false,
26326            None,
26327            false,
26328            false,
26329            false,
26330            false,
26331            false,
26332            false,
26333        )
26334        .unwrap_err();
26335
26336        let msg = err.to_string();
26337        assert!(msg.contains("indexing"));
26338        assert!(msg.contains("lock diagnostics:"));
26339        assert!(msg.contains("lock: absent"));
26340        assert!(msg.contains("wal: present") || msg.contains("shm: present"));
26341        assert!(msg.contains("wedged writer holding live WAL sidecars"));
26342        assert!(msg.contains("snapshot fallback"));
26343    }
26344
26345    #[test]
26346    fn search_cmd_succeeds_while_writer_lock_is_held() {
26347        let dir = setup_graph_index();
26348        let db_path = dir.path().join(".tsift/index.db");
26349        let _lock = hold_write_lock(&db_path);
26350
26351        let result = cmd_search(
26352            "main".to_string(),
26353            Some(dir.path().to_path_buf()),
26354            5,
26355            Some("lexical".to_string()),
26356            None,
26357            false,
26358            false,
26359            false,
26360            0,
26361            true,
26362            false,
26363            false,
26364            false,
26365            false,
26366            false,
26367            false,
26368        );
26369
26370        assert!(result.is_ok());
26371    }
26372
26373    #[test]
26374    fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
26375        let dir = setup_graph_index();
26376        let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
26377
26378        let result = cmd_search(
26379            "main".to_string(),
26380            Some(dir.path().to_path_buf()),
26381            5,
26382            Some("lexical".to_string()),
26383            None,
26384            false,
26385            false,
26386            false,
26387            0,
26388            true,
26389            false,
26390            false,
26391            false,
26392            false,
26393            false,
26394            false,
26395        );
26396
26397        assert!(result.is_ok());
26398    }
26399
26400    #[test]
26401    fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
26402        let dir = setup_graph_index();
26403        let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
26404
26405        let result = cmd_search(
26406            "main".to_string(),
26407            Some(dir.path().to_path_buf()),
26408            5,
26409            Some("lexical".to_string()),
26410            None,
26411            false,
26412            false,
26413            false,
26414            0,
26415            true,
26416            false,
26417            false,
26418            false,
26419            false,
26420            false,
26421            false,
26422        );
26423
26424        assert!(result.is_ok());
26425    }
26426
26427    #[test]
26428    fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
26429        let dir = setup_graph_index();
26430        std::thread::sleep(std::time::Duration::from_millis(50));
26431        std::fs::write(
26432            dir.path().join("main.rs"),
26433            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26434        )
26435        .unwrap();
26436
26437        let err = cmd_search(
26438            "helper".to_string(),
26439            Some(dir.path().to_path_buf()),
26440            5,
26441            Some("lexical".to_string()),
26442            None,
26443            false,
26444            false,
26445            false,
26446            0,
26447            false,
26448            false,
26449            false,
26450            false,
26451            false,
26452            false,
26453            false,
26454        )
26455        .unwrap_err();
26456
26457        assert!(err.to_string().contains("search aborted"));
26458        assert!(err.to_string().contains("index is stale"));
26459        assert!(err.to_string().contains("--no-autoindex"));
26460    }
26461
26462    #[test]
26463    fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
26464        let dir = setup_graph_index();
26465        std::thread::sleep(std::time::Duration::from_millis(50));
26466        std::fs::write(
26467            dir.path().join("main.rs"),
26468            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26469        )
26470        .unwrap();
26471        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26472
26473        let err = cmd_search(
26474            "helper".to_string(),
26475            Some(dir.path().to_path_buf()),
26476            5,
26477            Some("lexical".to_string()),
26478            None,
26479            false,
26480            false,
26481            false,
26482            0,
26483            false,
26484            false,
26485            false,
26486            false,
26487            false,
26488            false,
26489            false,
26490        )
26491        .unwrap_err();
26492
26493        assert!(err.to_string().contains("search aborted"));
26494        assert!(err.to_string().contains("index is stale"));
26495        assert!(!err.to_string().contains("database is locked"));
26496    }
26497
26498    #[test]
26499    fn search_cmd_autoindexes_stale_index_by_default() {
26500        let dir = setup_graph_index();
26501        std::thread::sleep(std::time::Duration::from_millis(50));
26502        std::fs::write(
26503            dir.path().join("main.rs"),
26504            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26505        )
26506        .unwrap();
26507
26508        let result = cmd_search(
26509            "helper".to_string(),
26510            Some(dir.path().to_path_buf()),
26511            5,
26512            Some("lexical".to_string()),
26513            None,
26514            false,
26515            false,
26516            true,
26517            0,
26518            false,
26519            false,
26520            false,
26521            false,
26522            false,
26523            false,
26524            false,
26525        );
26526
26527        assert!(result.is_ok());
26528
26529        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26530        let summary = db.compute_changes(dir.path()).unwrap();
26531        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26532    }
26533
26534    #[test]
26535    fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
26536        let dir = setup_graph_index();
26537        std::thread::sleep(std::time::Duration::from_millis(50));
26538        std::fs::write(
26539            dir.path().join("main.rs"),
26540            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26541        )
26542        .unwrap();
26543        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
26544
26545        let result = cmd_search(
26546            "helper".to_string(),
26547            Some(dir.path().to_path_buf()),
26548            5,
26549            Some("lexical".to_string()),
26550            None,
26551            false,
26552            false,
26553            true,
26554            0,
26555            false,
26556            false,
26557            false,
26558            false,
26559            false,
26560            false,
26561            false,
26562        );
26563
26564        assert!(result.is_ok());
26565
26566        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26567        let summary = db.compute_changes(dir.path()).unwrap();
26568        assert_eq!(summary.modified, 1);
26569    }
26570
26571    #[test]
26572    fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
26573        let dir = setup_graph_index();
26574        std::thread::sleep(std::time::Duration::from_millis(50));
26575        std::fs::write(
26576            dir.path().join("main.rs"),
26577            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26578        )
26579        .unwrap();
26580        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26581
26582        let err = cmd_search(
26583            "helper".to_string(),
26584            Some(dir.path().to_path_buf()),
26585            5,
26586            Some("lexical".to_string()),
26587            None,
26588            false,
26589            false,
26590            true,
26591            0,
26592            false,
26593            false,
26594            false,
26595            false,
26596            false,
26597            false,
26598            false,
26599        )
26600        .unwrap_err();
26601
26602        let msg = err.to_string();
26603        assert!(msg.contains("autoindexing index"));
26604        assert!(msg.contains("lock diagnostics:"));
26605        assert!(msg.contains("journal: present"));
26606        assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
26607    }
26608
26609    #[test]
26610    fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
26611        let dir = setup_graph_index();
26612        let nested = dir.path().join("src/nested");
26613        std::fs::create_dir_all(&nested).unwrap();
26614
26615        let result = cmd_search(
26616            "helper".to_string(),
26617            Some(nested.clone()),
26618            5,
26619            Some("lexical".to_string()),
26620            None,
26621            false,
26622            false,
26623            true,
26624            0,
26625            false,
26626            false,
26627            false,
26628            false,
26629            false,
26630            false,
26631            false,
26632        );
26633
26634        assert!(result.is_ok());
26635        assert!(!nested.join(".tsift/index.db").exists());
26636    }
26637
26638    #[test]
26639    fn exact_search_returns_literal_matches() {
26640        let dir = tempfile::tempdir().unwrap();
26641        std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
26642
26643        let response = run_exact_search_with_timeout(dir.path(), "claudescore-3", 5, 0).unwrap();
26644
26645        assert_eq!(response.strategy, "exact");
26646        assert_eq!(response.hits.len(), 1);
26647        assert!(response.hits[0].path.ends_with("notes.txt"));
26648        assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
26649        assert!(response.hits[0].snippet.contains("claudescore-3"));
26650    }
26651
26652    #[test]
26653    fn exact_search_skips_stale_index_precheck() {
26654        let dir = setup_graph_index();
26655        std::thread::sleep(std::time::Duration::from_millis(50));
26656        std::fs::write(
26657            dir.path().join("main.rs"),
26658            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
26659        )
26660        .unwrap();
26661
26662        let result = cmd_search(
26663            "println!(\"updated\")".to_string(),
26664            Some(dir.path().to_path_buf()),
26665            5,
26666            Some("exact".to_string()),
26667            None,
26668            false,
26669            false,
26670            false,
26671            0,
26672            false,
26673            false,
26674            false,
26675            false,
26676            false,
26677            false,
26678            false,
26679        );
26680
26681        assert!(result.is_ok());
26682    }
26683
26684    #[test]
26685    fn workspace_exact_search_does_not_require_shared_root_index() {
26686        let dir = setup_workspace();
26687        cmd_index(
26688            dir.path(),
26689            false,
26690            false,
26691            false,
26692            false,
26693            false,
26694            true,
26695            None,
26696            false,
26697            false,
26698            false,
26699            false,
26700            false,
26701            false,
26702        )
26703        .unwrap();
26704
26705        let result = cmd_search(
26706            "alpha_helper".to_string(),
26707            Some(dir.path().to_path_buf()),
26708            5,
26709            Some("exact".to_string()),
26710            None,
26711            false,
26712            false,
26713            false,
26714            0,
26715            false,
26716            false,
26717            false,
26718            false,
26719            false,
26720            false,
26721            false,
26722        );
26723
26724        assert!(result.is_ok());
26725        assert!(!dir.path().join(".tsift/index.db").exists());
26726    }
26727
26728    #[test]
26729    fn identifier_like_query_prefers_exact_search() {
26730        assert!(query_prefers_exact_search("claudescore-3"));
26731        assert!(query_prefers_exact_search("alpha_helper"));
26732        assert!(query_prefers_exact_search("src/main.rs"));
26733        assert!(query_prefers_exact_search("crate::module"));
26734        assert!(!query_prefers_exact_search("authenticate"));
26735        assert!(!query_prefers_exact_search("fn main"));
26736        assert!(!query_prefers_exact_search("."));
26737    }
26738
26739    #[test]
26740    fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
26741        assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
26742        assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
26743        assert_eq!(
26744            resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
26745            "hybrid"
26746        );
26747    }
26748
26749    #[test]
26750    fn workspace_identifier_like_search_auto_uses_exact_backend() {
26751        let dir = setup_workspace();
26752        cmd_index(
26753            dir.path(),
26754            false,
26755            false,
26756            false,
26757            false,
26758            false,
26759            true,
26760            None,
26761            false,
26762            false,
26763            false,
26764            false,
26765            false,
26766            false,
26767        )
26768        .unwrap();
26769
26770        let result = cmd_search(
26771            "alpha_helper".to_string(),
26772            Some(dir.path().to_path_buf()),
26773            5,
26774            None,
26775            None,
26776            false,
26777            false,
26778            false,
26779            0,
26780            false,
26781            false,
26782            false,
26783            false,
26784            false,
26785            false,
26786            false,
26787        );
26788
26789        assert!(result.is_ok());
26790        assert!(!dir.path().join(".tsift/index.db").exists());
26791    }
26792
26793    #[test]
26794    fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
26795        let dir = setup_graph_index();
26796        let nested = dir.path().join("src/nested");
26797        std::fs::create_dir_all(&nested).unwrap();
26798        std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
26799
26800        let result = cmd_index(
26801            &nested, false, false, false, false, false, false, None, false, false, false, false,
26802            false, false,
26803        );
26804
26805        assert!(result.is_ok());
26806        assert!(dir.path().join(".tsift/index.db").exists());
26807        assert!(!nested.join(".tsift/index.db").exists());
26808    }
26809
26810    #[test]
26811    fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
26812        let dir = setup_workspace();
26813        let nested = dir.path().join("docs/nested");
26814        std::fs::create_dir_all(&nested).unwrap();
26815
26816        let result = cmd_index(
26817            &nested, false, false, false, false, false, true, None, false, false, false, false,
26818            false, false,
26819        );
26820
26821        let cfg = config::Config::load(dir.path()).unwrap();
26822
26823        assert!(result.is_ok());
26824        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
26825        assert!(cfg.db_path_for(dir.path(), "beta").exists());
26826    }
26827
26828    #[test]
26829    fn status_cmd_autoindexes_missing_workspace_scopes() {
26830        let dir = setup_workspace();
26831        let cfg = config::Config::load(dir.path()).unwrap();
26832        let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
26833        let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
26834        let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
26835        alpha_db.apply_changes(&alpha.source_root).unwrap();
26836
26837        let beta_db_path = cfg.db_path_for(dir.path(), "beta");
26838        assert!(!beta_db_path.exists());
26839
26840        cmd_status(
26841            dir.path(),
26842            StatusCommandOptions {
26843                fix: false,
26844                no_fix: false,
26845                json_output: true,
26846                compact: false,
26847                pretty: false,
26848                terse: false,
26849                schema: false,
26850            },
26851        )
26852        .unwrap();
26853
26854        assert!(beta_db_path.exists());
26855        let report = status::check_status(dir.path()).unwrap();
26856        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
26857    }
26858
26859    #[test]
26860    fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
26861        let dir = setup_workspace();
26862        let cfg = config::Config::load(dir.path()).unwrap();
26863
26864        cmd_status(
26865            dir.path(),
26866            StatusCommandOptions {
26867                fix: false,
26868                no_fix: false,
26869                json_output: true,
26870                compact: false,
26871                pretty: false,
26872                terse: false,
26873                schema: false,
26874            },
26875        )
26876        .unwrap();
26877
26878        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
26879        assert!(cfg.db_path_for(dir.path(), "beta").exists());
26880        let report = status::check_status(dir.path()).unwrap();
26881        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
26882    }
26883
26884    #[test]
26885    fn status_cmd_fix_refreshes_stale_index() {
26886        let dir = setup_graph_index();
26887        std::thread::sleep(std::time::Duration::from_millis(50));
26888        std::fs::write(
26889            dir.path().join("main.rs"),
26890            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
26891        )
26892        .unwrap();
26893
26894        let report = status::check_status(dir.path()).unwrap();
26895        assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
26896
26897        cmd_status(
26898            dir.path(),
26899            StatusCommandOptions {
26900                fix: false,
26901                no_fix: false,
26902                json_output: true,
26903                compact: false,
26904                pretty: false,
26905                terse: false,
26906                schema: false,
26907            },
26908        )
26909        .unwrap();
26910
26911        let report = status::check_status(dir.path()).unwrap();
26912        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
26913    }
26914
26915    #[test]
26916    fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
26917        let dir = setup_graph_index();
26918        let db_path = dir.path().join(".tsift/index.db");
26919        let _lock = hold_wal_database_lock(&db_path);
26920
26921        cmd_status(
26922            dir.path(),
26923            StatusCommandOptions {
26924                fix: false,
26925                no_fix: false,
26926                json_output: true,
26927                compact: false,
26928                pretty: false,
26929                terse: false,
26930                schema: false,
26931            },
26932        )
26933        .unwrap();
26934
26935        let report = status::check_status(dir.path()).unwrap();
26936        assert!(matches!(
26937            report.index,
26938            status::IndexStatus::Fresh {
26939                recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
26940                ..
26941            }
26942        ));
26943        let locks = status::check_locks(dir.path(), None, None).unwrap();
26944        assert!(matches!(
26945            locks.writer_lock,
26946            status::WriterLockStatus::Absent { .. }
26947        ));
26948        assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
26949        assert!(
26950            locks
26951                .recommended_action
26952                .contains("wedged writer holding live WAL sidecars")
26953        );
26954    }
26955
26956    #[test]
26957    fn locks_report_uses_ancestor_project_root_for_nested_paths() {
26958        let dir = setup_graph_index();
26959        let nested = dir.path().join("src/nested");
26960        std::fs::create_dir_all(&nested).unwrap();
26961
26962        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
26963        let report = status::check_locks(&root, Some(&nested), None).unwrap();
26964
26965        assert_eq!(report.source_root, dir.path());
26966        assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
26967    }
26968
26969    #[test]
26970    fn workspace_locks_report_infers_scope_from_nested_path() {
26971        let dir = setup_workspace();
26972        cmd_index(
26973            dir.path(),
26974            false,
26975            false,
26976            false,
26977            false,
26978            false,
26979            true,
26980            None,
26981            false,
26982            false,
26983            false,
26984            false,
26985            false,
26986            false,
26987        )
26988        .unwrap();
26989        let nested = dir.path().join("src/alpha/nested");
26990        std::fs::create_dir_all(&nested).unwrap();
26991
26992        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
26993        let report = status::check_locks(&root, Some(&nested), None).unwrap();
26994        let cfg = config::Config::load(dir.path()).unwrap();
26995
26996        assert_eq!(report.label, "submodule `alpha` index");
26997        assert_eq!(report.source_root, dir.path().join("src/alpha"));
26998        assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
26999        assert_eq!(
27000            report.reindex_command,
27001            format!("tsift index --submodule alpha {}", dir.path().display())
27002        );
27003    }
27004
27005    #[test]
27006    fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
27007        let dir = setup_workspace();
27008        cmd_index(
27009            dir.path(),
27010            false,
27011            false,
27012            false,
27013            false,
27014            false,
27015            true,
27016            None,
27017            false,
27018            false,
27019            false,
27020            false,
27021            false,
27022            false,
27023        )
27024        .unwrap();
27025
27026        let alpha = dir.path().join("src/alpha/lib.rs");
27027        std::thread::sleep(std::time::Duration::from_millis(50));
27028        std::fs::write(
27029            &alpha,
27030            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27031        )
27032        .unwrap();
27033
27034        let result = cmd_search(
27035            "alpha_helper".to_string(),
27036            Some(dir.path().to_path_buf()),
27037            5,
27038            Some("lexical".to_string()),
27039            Some("alpha".to_string()),
27040            false,
27041            false,
27042            true,
27043            0,
27044            false,
27045            false,
27046            false,
27047            false,
27048            false,
27049            false,
27050            false,
27051        );
27052
27053        assert!(result.is_ok());
27054
27055        let cfg = config::Config::load(dir.path()).unwrap();
27056        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27057        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27058        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27059    }
27060
27061    #[test]
27062    fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27063        let dir = setup_workspace();
27064        cmd_index(
27065            dir.path(),
27066            false,
27067            false,
27068            false,
27069            false,
27070            false,
27071            true,
27072            None,
27073            false,
27074            false,
27075            false,
27076            false,
27077            false,
27078            false,
27079        )
27080        .unwrap();
27081
27082        let alpha = dir.path().join("src/alpha/lib.rs");
27083        std::thread::sleep(std::time::Duration::from_millis(50));
27084        std::fs::write(
27085            &alpha,
27086            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27087        )
27088        .unwrap();
27089
27090        let cfg = config::Config::load(dir.path()).unwrap();
27091        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27092
27093        let err = cmd_search(
27094            "alpha_helper".to_string(),
27095            Some(dir.path().to_path_buf()),
27096            5,
27097            Some("lexical".to_string()),
27098            Some("alpha".to_string()),
27099            false,
27100            false,
27101            false,
27102            0,
27103            false,
27104            false,
27105            false,
27106            false,
27107            false,
27108            false,
27109            false,
27110        )
27111        .unwrap_err();
27112
27113        assert!(err.to_string().contains("search aborted"));
27114        assert!(err.to_string().contains("submodule `alpha` index"));
27115        assert!(!err.to_string().contains("database is locked"));
27116    }
27117
27118    #[test]
27119    fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
27120        let dir = setup_workspace();
27121        cmd_index(
27122            dir.path(),
27123            false,
27124            false,
27125            false,
27126            false,
27127            false,
27128            true,
27129            None,
27130            false,
27131            false,
27132            false,
27133            false,
27134            false,
27135            false,
27136        )
27137        .unwrap();
27138
27139        let alpha = dir.path().join("src/alpha/lib.rs");
27140        std::thread::sleep(std::time::Duration::from_millis(50));
27141        std::fs::write(
27142            &alpha,
27143            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27144        )
27145        .unwrap();
27146
27147        let result = cmd_search(
27148            "alpha_helper".to_string(),
27149            Some(dir.path().to_path_buf()),
27150            5,
27151            Some("lexical".to_string()),
27152            None,
27153            true,
27154            false,
27155            true,
27156            0,
27157            false,
27158            false,
27159            false,
27160            false,
27161            false,
27162            false,
27163            false,
27164        );
27165
27166        assert!(result.is_ok());
27167
27168        let cfg = config::Config::load(dir.path()).unwrap();
27169        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27170        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27171        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27172    }
27173
27174    #[test]
27175    fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27176        let dir = setup_workspace();
27177        cmd_index(
27178            dir.path(),
27179            false,
27180            false,
27181            false,
27182            false,
27183            false,
27184            true,
27185            None,
27186            false,
27187            false,
27188            false,
27189            false,
27190            false,
27191            false,
27192        )
27193        .unwrap();
27194
27195        let alpha = dir.path().join("src/alpha/lib.rs");
27196        std::thread::sleep(std::time::Duration::from_millis(50));
27197        std::fs::write(
27198            &alpha,
27199            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27200        )
27201        .unwrap();
27202
27203        let cfg = config::Config::load(dir.path()).unwrap();
27204        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27205
27206        let err = cmd_search(
27207            "alpha_helper".to_string(),
27208            Some(dir.path().to_path_buf()),
27209            5,
27210            Some("lexical".to_string()),
27211            None,
27212            true,
27213            false,
27214            false,
27215            30,
27216            false,
27217            false,
27218            false,
27219            false,
27220            false,
27221            false,
27222            false,
27223        )
27224        .unwrap_err();
27225
27226        assert!(err.to_string().contains("stale"));
27227        assert!(err.to_string().contains("submodule `alpha` index"));
27228        assert!(!err.to_string().contains("database is locked"));
27229    }
27230
27231    #[test]
27232    fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
27233        let dir = setup_workspace();
27234        cmd_index(
27235            dir.path(),
27236            false,
27237            false,
27238            false,
27239            false,
27240            false,
27241            true,
27242            None,
27243            false,
27244            false,
27245            false,
27246            false,
27247            false,
27248            false,
27249        )
27250        .unwrap();
27251
27252        let err = cmd_search(
27253            "alpha_helper".to_string(),
27254            Some(dir.path().to_path_buf()),
27255            5,
27256            Some("lexical".to_string()),
27257            None,
27258            false,
27259            false,
27260            true,
27261            0,
27262            false,
27263            false,
27264            false,
27265            false,
27266            false,
27267            false,
27268            false,
27269        )
27270        .unwrap_err();
27271
27272        assert_workspace_search_requires_explicit_target(err);
27273        assert!(!dir.path().join(".tsift/index.db").exists());
27274    }
27275
27276    #[test]
27277    fn workspace_search_cmd_infers_scope_from_nested_path() {
27278        let dir = setup_workspace();
27279        cmd_index(
27280            dir.path(),
27281            false,
27282            false,
27283            false,
27284            false,
27285            false,
27286            true,
27287            None,
27288            false,
27289            false,
27290            false,
27291            false,
27292            false,
27293            false,
27294        )
27295        .unwrap();
27296        let nested = dir.path().join("src/alpha/nested");
27297        std::fs::create_dir_all(&nested).unwrap();
27298
27299        let result = cmd_search(
27300            "alpha_helper".to_string(),
27301            Some(nested),
27302            5,
27303            Some("lexical".to_string()),
27304            None,
27305            false,
27306            false,
27307            false,
27308            0,
27309            false,
27310            false,
27311            false,
27312            false,
27313            false,
27314            false,
27315            false,
27316        );
27317
27318        assert!(result.is_ok());
27319    }
27320
27321    #[test]
27322    fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
27323        let dir = setup_workspace_with_duplicate_leaf_names();
27324        cmd_index(
27325            dir.path(),
27326            false,
27327            false,
27328            false,
27329            false,
27330            false,
27331            true,
27332            None,
27333            false,
27334            false,
27335            false,
27336            false,
27337            false,
27338            false,
27339        )
27340        .unwrap();
27341        let nested = dir.path().join("vendor/foo/nested");
27342        std::fs::create_dir_all(&nested).unwrap();
27343
27344        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27345        let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
27346        let cfg = config::Config::load(dir.path()).unwrap();
27347
27348        assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
27349    }
27350
27351    #[test]
27352    fn graph_cmd_succeeds_while_writer_lock_is_held() {
27353        let dir = setup_graph_index();
27354        let db_path = dir.path().join(".tsift/index.db");
27355        let _lock = hold_write_lock(&db_path);
27356
27357        let result = cmd_graph(
27358            "main",
27359            dir.path(),
27360            false,
27361            false,
27362            None,
27363            20,
27364            false,
27365            true,
27366            false,
27367            false,
27368            false,
27369            false,
27370            false,
27371            TagpathSearchOpts::default(),
27372        );
27373
27374        assert!(result.is_ok());
27375    }
27376
27377    #[test]
27378    fn graph_cmd_autoindexes_stale_index_by_default() {
27379        let dir = setup_graph_index();
27380        std::thread::sleep(std::time::Duration::from_millis(50));
27381        std::fs::write(
27382            dir.path().join("main.rs"),
27383            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27384        )
27385        .unwrap();
27386
27387        let result = cmd_graph(
27388            "helper",
27389            dir.path(),
27390            true,
27391            false,
27392            None,
27393            20,
27394            false,
27395            true,
27396            false,
27397            false,
27398            false,
27399            false,
27400            false,
27401            TagpathSearchOpts::default(),
27402        );
27403
27404        assert!(result.is_ok());
27405        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27406        let summary = db.compute_changes(dir.path()).unwrap();
27407        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27408    }
27409
27410    #[test]
27411    fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27412        let dir = setup_graph_index();
27413        let db_path = dir.path().join(".tsift/index.db");
27414        let _lock = hold_rollback_journal_lock(&db_path);
27415
27416        let result = cmd_graph(
27417            "main",
27418            dir.path(),
27419            false,
27420            false,
27421            None,
27422            20,
27423            false,
27424            true,
27425            false,
27426            false,
27427            false,
27428            false,
27429            false,
27430            TagpathSearchOpts::default(),
27431        );
27432
27433        assert!(result.is_ok());
27434    }
27435
27436    #[test]
27437    fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
27438        let dir = setup_graph_index();
27439        let nested = dir.path().join("src/nested");
27440        std::fs::create_dir_all(&nested).unwrap();
27441
27442        let result = cmd_graph(
27443            "helper",
27444            &nested,
27445            true,
27446            false,
27447            None,
27448            20,
27449            false,
27450            false,
27451            false,
27452            false,
27453            false,
27454            false,
27455            false,
27456            TagpathSearchOpts::default(),
27457        );
27458
27459        assert!(result.is_ok());
27460    }
27461
27462    #[test]
27463    fn communities_cmd_succeeds_while_writer_lock_is_held() {
27464        let dir = setup_graph_index();
27465        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27466
27467        let result = cmd_communities(
27468            dir.path(),
27469            None,
27470            1,
27471            10,
27472            false,
27473            false,
27474            false,
27475            false,
27476            false,
27477            false,
27478            TagpathSearchOpts::default(),
27479        );
27480
27481        assert!(result.is_ok());
27482    }
27483
27484    #[test]
27485    fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27486        let dir = setup_graph_index();
27487        let db_path = dir.path().join(".tsift/index.db");
27488        let _lock = hold_rollback_journal_lock(&db_path);
27489
27490        let result = cmd_communities(
27491            dir.path(),
27492            None,
27493            1,
27494            10,
27495            false,
27496            false,
27497            false,
27498            false,
27499            false,
27500            false,
27501            TagpathSearchOpts::default(),
27502        );
27503
27504        assert!(result.is_ok());
27505    }
27506
27507    #[test]
27508    fn lint_finds_entities_from_project_root_index_db() {
27509        let dir = tempfile::tempdir().unwrap();
27510        std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
27511        std::fs::write(
27512            dir.path().join("README.md"),
27513            "alpha_helper should be backticked.\n",
27514        )
27515        .unwrap();
27516        cmd_index(
27517            dir.path(),
27518            false,
27519            false,
27520            false,
27521            false,
27522            false,
27523            false,
27524            None,
27525            false,
27526            false,
27527            false,
27528            false,
27529            false,
27530            false,
27531        )
27532        .unwrap();
27533
27534        let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
27535            .unwrap()
27536            .unwrap();
27537        let entities = lint::collect_entities_from_index_path(&root).unwrap();
27538        let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
27539
27540        assert!(
27541            result
27542                .annotations
27543                .iter()
27544                .any(|ann| ann.text == "alpha_helper")
27545        );
27546    }
27547
27548    // --- search timeout ---
27549
27550    #[test]
27551    fn search_direct_runs_ok() {
27552        let dir = tempfile::tempdir().unwrap();
27553        let search_dir = dir.path().to_path_buf();
27554        let cache_dir = search_dir.join(".tsift/search-cache");
27555        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27556        let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical");
27557        assert!(result.is_ok(), "direct search should succeed");
27558        assert!(
27559            cache_dir.exists(),
27560            "search should create the configured cache dir"
27561        );
27562    }
27563
27564    #[test]
27565    fn search_timeout_zero_disables_timeout() {
27566        let dir = tempfile::tempdir().unwrap();
27567        let search_dir = dir.path().to_path_buf();
27568        let cache_dir = search_dir.join(".tsift/search-cache");
27569        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27570        let result = run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[]);
27571        assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
27572        assert!(
27573            cache_dir.exists(),
27574            "timeout=0 should keep using the stable search cache dir"
27575        );
27576    }
27577
27578    #[test]
27579    fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
27580        let dir = tempfile::tempdir().unwrap();
27581        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
27582        cmd_index(
27583            dir.path(),
27584            false,
27585            false,
27586            false,
27587            false,
27588            false,
27589            false,
27590            None,
27591            false,
27592            false,
27593            false,
27594            false,
27595            false,
27596            false,
27597        )
27598        .unwrap();
27599        let db_path = dir.path().join(".tsift/index.db");
27600        std::fs::remove_file(&db_path).unwrap();
27601        let search_target = SearchIndexTarget {
27602            label: "index".to_string(),
27603            db_path,
27604            source_root: dir.path().to_path_buf(),
27605            scope_name: None,
27606            reindex_cmd: format!("tsift index {}", dir.path().display()),
27607        };
27608
27609        let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
27610
27611        assert!(message.contains("timed out after 1s"));
27612        assert!(message.contains("index is missing"));
27613        assert!(message.contains("Run `tsift index"));
27614        assert!(!message.contains("search root looks fresh"));
27615    }
27616
27617    #[test]
27618    fn search_worker_output_path_uses_json_suffix() {
27619        let path = next_search_worker_output_path();
27620        assert!(path.extension().is_some_and(|ext| ext == "json"));
27621    }
27622
27623    // --- index quiet mode ---
27624
27625    #[test]
27626    fn index_quiet_suppresses_file_list() {
27627        let dir = setup_graph_index();
27628        let result = cmd_index(
27629            dir.path(),
27630            false,
27631            true,
27632            false,
27633            false,
27634            true,
27635            false,
27636            None,
27637            false,
27638            false,
27639            false,
27640            false,
27641            false,
27642            false,
27643        );
27644        assert!(result.is_ok());
27645    }
27646
27647    #[test]
27648    fn index_exit_code_implies_quiet() {
27649        let dir = setup_graph_index();
27650        let result = cmd_index(
27651            dir.path(),
27652            false,
27653            true,
27654            false,
27655            false,
27656            false,
27657            false,
27658            None,
27659            false,
27660            false,
27661            false,
27662            false,
27663            false,
27664            false,
27665        );
27666        assert!(result.is_ok());
27667    }
27668
27669    #[test]
27670    fn index_quiet_json_omits_changes() {
27671        let dir = setup_graph_index();
27672        let result = cmd_index(
27673            dir.path(),
27674            false,
27675            true,
27676            false,
27677            false,
27678            true,
27679            false,
27680            None,
27681            true,
27682            false,
27683            false,
27684            false,
27685            false,
27686            false,
27687        );
27688        assert!(result.is_ok());
27689    }
27690
27691    #[test]
27692    fn cli_workflow_defaults_to_search_topic() {
27693        let cli = parse_cli(["tsift", "workflow"]);
27694        match cli.command {
27695            Some(Commands::Workflow { topic, json }) => {
27696                assert_eq!(topic, "search");
27697                assert!(!json);
27698            }
27699            _ => panic!("expected Workflow command"),
27700        }
27701    }
27702
27703    #[test]
27704    fn search_workflow_recipe_preserves_handles_across_expansions() {
27705        let recipe = workflow::search_workflow_recipe();
27706        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
27707        assert_eq!(
27708            step_names,
27709            vec![
27710                "exact-anchor",
27711                "semantic-search",
27712                "explain-symbol",
27713                "summarize-selection",
27714                "digest-expansion"
27715            ]
27716        );
27717        assert!(
27718            recipe
27719                .handle_contract
27720                .iter()
27721                .any(|item| item.contains("originating command"))
27722        );
27723        assert!(
27724            recipe.steps[1]
27725                .preserves
27726                .iter()
27727                .any(|item| item.contains("sfam-*"))
27728        );
27729        assert!(
27730            recipe.steps[2]
27731                .preserves
27732                .iter()
27733                .any(|item| item.contains("ecall-*"))
27734        );
27735        assert!(
27736            recipe.steps[4]
27737                .preserves
27738                .iter()
27739                .any(|item| item.contains("artifact handles"))
27740        );
27741    }
27742
27743    // --- JSON compact vs pretty ---
27744
27745    #[test]
27746    fn to_json_compact_default() {
27747        let val = serde_json::json!({"a": 1, "b": [2, 3]});
27748        let compact = to_json(&val, false, false).unwrap();
27749        assert!(!compact.contains('\n'));
27750        assert!(
27751            compact.contains("\"a\":1")
27752                || compact.contains("\"a\": 1")
27753                || compact.contains("\"a\":")
27754        );
27755    }
27756
27757    #[test]
27758    fn to_json_pretty_indents() {
27759        let val = serde_json::json!({"a": 1, "b": [2, 3]});
27760        let pretty = to_json(&val, true, false).unwrap();
27761        assert!(pretty.contains('\n'));
27762        assert!(pretty.contains("  "));
27763    }
27764
27765    #[test]
27766    fn to_json_compact_is_shorter() {
27767        let val =
27768            serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
27769        let compact = to_json(&val, false, false).unwrap();
27770        let pretty = to_json(&val, true, false).unwrap();
27771        assert!(compact.len() < pretty.len());
27772    }
27773
27774    #[test]
27775    fn terse_renames_keys() {
27776        let val =
27777            serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
27778        let result = to_json(&val, false, true).unwrap();
27779        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27780        assert!(parsed["_s"].is_object());
27781        let d = &parsed["d"];
27782        assert_eq!(d["cf"], "a.rs");
27783        assert_eq!(d["cn"], "main");
27784        assert_eq!(d["csl"], 10);
27785    }
27786
27787    #[test]
27788    fn terse_schema_only_includes_used_keys() {
27789        let val = serde_json::json!({"name": "test", "score": 0.5});
27790        let result = to_json(&val, false, true).unwrap();
27791        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27792        let schema = parsed["_s"].as_object().unwrap();
27793        assert_eq!(schema["n"], "name");
27794        assert_eq!(schema["sc"], "score");
27795        assert!(!schema.contains_key("cf"));
27796    }
27797
27798    #[test]
27799    fn terse_nested_arrays() {
27800        let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
27801        let result = to_json(&val, false, true).unwrap();
27802        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27803        let d = &parsed["d"];
27804        assert_eq!(d["crs"][0]["cn"], "a");
27805        assert_eq!(d["crs"][0]["cf"], "b.rs");
27806    }
27807
27808    #[test]
27809    fn terse_preserves_unknown_keys() {
27810        let val = serde_json::json!({"custom_field": "value", "name": "test"});
27811        let result = to_json(&val, false, true).unwrap();
27812        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27813        let d = &parsed["d"];
27814        assert_eq!(d["custom_field"], "value");
27815        assert_eq!(d["n"], "test");
27816    }
27817
27818    // --- ultra-terse ---
27819
27820    #[test]
27821    fn ultra_terse_strips_properties_from_graph_nodes() {
27822        let val = serde_json::json!({
27823            "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
27824        });
27825        let result = to_json_schema(&val, false, true, true, false).unwrap();
27826        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27827        let node = &parsed["d"]["nodes"][0];
27828        assert_eq!(node["id"], "fn:main");
27829        assert_eq!(node["k"], "fn");
27830        assert_eq!(node["n"], "main");
27831        assert!(node.get("properties").is_none());
27832    }
27833
27834    #[test]
27835    fn ultra_terse_strips_properties_from_graph_edges() {
27836        let val = serde_json::json!({
27837            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
27838        });
27839        let result = to_json_schema(&val, false, true, true, false).unwrap();
27840        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27841        let edge = &parsed["d"]["edges"][0];
27842        assert_eq!(edge["from_id"], "a");
27843        assert_eq!(edge["to_id"], "b");
27844        assert_eq!(edge["k"], "c");
27845        assert!(edge.get("properties").is_none());
27846    }
27847
27848    #[test]
27849    fn ultra_terse_abbreviates_edge_kinds() {
27850        let val = serde_json::json!({
27851            "edges": [
27852                {"from_id": "a", "to_id": "b", "kind": "defines"},
27853                {"from_id": "a", "to_id": "c", "kind": "contains"},
27854                {"from_id": "a", "to_id": "d", "kind": "imports"},
27855                {"from_id": "a", "to_id": "e", "kind": "mentions"},
27856                {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
27857                {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
27858                {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
27859                {"from_id": "a", "to_id": "i", "kind": "uses"},
27860                {"from_id": "a", "to_id": "j", "kind": "parent"},
27861                {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
27862            ]
27863        });
27864        let result = to_json_schema(&val, false, true, true, false).unwrap();
27865        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27866        let edges = &parsed["d"]["edges"].as_array().unwrap();
27867        assert_eq!(edges[0]["k"], "d");
27868        assert_eq!(edges[1]["k"], "ct");
27869        assert_eq!(edges[2]["k"], "i");
27870        assert_eq!(edges[3]["k"], "m");
27871        assert_eq!(edges[4]["k"], "sr");
27872        assert_eq!(edges[5]["k"], "bt");
27873        assert_eq!(edges[6]["k"], "sctx");
27874        assert_eq!(edges[7]["k"], "u");
27875        assert_eq!(edges[8]["k"], "p");
27876        assert_eq!(edges[9]["k"], "unknown_edge");
27877    }
27878
27879    #[test]
27880    fn ultra_terse_strips_provenance_freshness_from_edges() {
27881        let val = serde_json::json!({
27882            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
27883        });
27884        let result = to_json_schema(&val, false, true, true, false).unwrap();
27885        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27886        let edge = &parsed["d"]["edges"][0];
27887        assert!(edge.get("provenance").is_none());
27888        assert!(edge.get("freshness").is_none());
27889        assert_eq!(edge["k"], "c");
27890    }
27891
27892    #[test]
27893    fn ultra_terse_truncates_snippets() {
27894        let long_snippet = "x".repeat(120);
27895        let val = serde_json::json!({"snippet": long_snippet});
27896        let result = to_json_schema(&val, false, true, true, false).unwrap();
27897        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27898        let snipped = parsed["d"]["sn"].as_str().unwrap();
27899        assert_eq!(snipped.len(), 80);
27900        assert!(snipped.ends_with("..."));
27901    }
27902
27903    #[test]
27904    fn ultra_terse_truncates_abbreviated_snippet_key() {
27905        let long_snippet = "y".repeat(100);
27906        let val = serde_json::json!({"snippet": long_snippet});
27907        let result = to_json_schema(&val, false, true, true, false).unwrap();
27908        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27909        let snipped = parsed["d"]["sn"].as_str().unwrap();
27910        assert_eq!(snipped.len(), 80);
27911        assert!(snipped.ends_with("..."));
27912    }
27913
27914    #[test]
27915    fn ultra_terse_compacts_coverage_snapshot() {
27916        let val = serde_json::json!({
27917            "mode": "incremental",
27918            "total_sector_count": 10,
27919            "dirty_sector_count": 2,
27920            "active_rebuild": Some("rebuild-1"),
27921            "completed_dirty_sector_count": 1,
27922            "mounted_sector_count": 8,
27923            "rebuilding_sector_count": 1,
27924            "resumed_sector_count": 3,
27925            "reused_sector_count": 5
27926        });
27927        let result = to_json_schema(&val, false, true, true, false).unwrap();
27928        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27929        let d = &parsed["d"];
27930        assert_eq!(d["mode"], "incremental");
27931        assert_eq!(d["total_sector_count"], 10);
27932        assert_eq!(d["dirty_sector_count"], 2);
27933        assert!(d.get("active_rebuild").is_none());
27934        assert!(d.get("completed_dirty_sector_count").is_none());
27935        assert!(d.get("mounted_sector_count").is_none());
27936        assert!(d.get("rebuilding_sector_count").is_none());
27937        assert!(d.get("resumed_sector_count").is_none());
27938        assert!(d.get("reused_sector_count").is_none());
27939    }
27940
27941    #[test]
27942    fn ultra_terse_short_snippet_unchanged() {
27943        let val = serde_json::json!({"snippet": "short text"});
27944        let result = to_json_schema(&val, false, true, true, false).unwrap();
27945        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27946        assert_eq!(parsed["d"]["sn"], "short text");
27947    }
27948
27949    #[test]
27950    fn ultra_terse_non_graph_object_properties_preserved() {
27951        let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
27952        let result = to_json_schema(&val, false, true, true, false).unwrap();
27953        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27954        assert!(parsed["d"]["config"]["properties"].is_object());
27955    }
27956
27957    // --- schema-then-values ---
27958
27959    #[test]
27960    fn schema_converts_homogeneous_arrays() {
27961        let val = serde_json::json!({"symbols": [
27962            {"name": "foo", "kind": "fn", "line": 10},
27963            {"name": "bar", "kind": "fn", "line": 20}
27964        ]});
27965        let result = to_json_schema(&val, false, false, false, true).unwrap();
27966        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27967        let syms = &parsed["symbols"];
27968        let columns = syms["_c"]
27969            .as_array()
27970            .unwrap()
27971            .iter()
27972            .map(|value| value.as_str().unwrap())
27973            .collect::<Vec<_>>();
27974        let row0 = syms["_r"][0].as_array().unwrap();
27975        let row1 = syms["_r"][1].as_array().unwrap();
27976        let name_index = columns.iter().position(|column| *column == "name").unwrap();
27977        let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
27978        let line_index = columns.iter().position(|column| *column == "line").unwrap();
27979        assert_eq!(row0[name_index], "foo");
27980        assert_eq!(row0[kind_index], "fn");
27981        assert_eq!(row0[line_index], 10);
27982        assert_eq!(row1[name_index], "bar");
27983        assert_eq!(row1[kind_index], "fn");
27984        assert_eq!(row1[line_index], 20);
27985    }
27986
27987    #[test]
27988    fn schema_skips_short_arrays() {
27989        let val = serde_json::json!({"items": [{"name": "only"}]});
27990        let result = to_json_schema(&val, false, false, false, true).unwrap();
27991        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27992        assert!(parsed["items"].is_array());
27993        assert_eq!(parsed["items"][0]["name"], "only");
27994    }
27995
27996    #[test]
27997    fn schema_skips_heterogeneous_arrays() {
27998        let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
27999        let result = to_json_schema(&val, false, false, false, true).unwrap();
28000        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28001        assert!(parsed["items"].is_array());
28002        assert_eq!(parsed["items"][0]["a"], 1);
28003    }
28004
28005    #[test]
28006    fn schema_with_terse_combines() {
28007        let val = serde_json::json!({"callers": [
28008            {"caller_name": "a", "caller_file": "x.rs"},
28009            {"caller_name": "b", "caller_file": "y.rs"}
28010        ]});
28011        let result = to_json_schema(&val, false, true, false, true).unwrap();
28012        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28013        assert!(parsed["_s"].is_object());
28014        let d = &parsed["d"];
28015        let crs = &d["crs"];
28016        assert!(crs["_c"].is_array());
28017        assert!(crs["_r"].is_array());
28018        let columns = crs["_c"]
28019            .as_array()
28020            .unwrap()
28021            .iter()
28022            .map(|value| value.as_str().unwrap())
28023            .collect::<Vec<_>>();
28024        let row = crs["_r"][0].as_array().unwrap();
28025        let name_index = columns.iter().position(|column| *column == "cn").unwrap();
28026        let file_index = columns.iter().position(|column| *column == "cf").unwrap();
28027        assert_eq!(row[name_index], "a");
28028        assert_eq!(row[file_index], "x.rs");
28029    }
28030
28031    #[test]
28032    fn schema_preserves_non_object_arrays() {
28033        let val = serde_json::json!({"tags": ["a", "b", "c"]});
28034        let result = to_json_schema(&val, false, false, false, true).unwrap();
28035        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28036        assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
28037    }
28038
28039    #[test]
28040    fn cli_accepts_global_schema_flag() {
28041        let cli = parse_cli(["tsift", "--schema", "search", "test"]);
28042        assert!(cli.schema);
28043        assert!(matches!(cli.command, Some(Commands::Search { .. })));
28044    }
28045
28046    #[test]
28047    fn cli_accepts_global_envelope_flag() {
28048        let cli = parse_cli([
28049            "tsift",
28050            "--envelope",
28051            "context-pack",
28052            "tasks/software/tsift.md",
28053        ]);
28054        assert!(cli.envelope);
28055        assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
28056    }
28057
28058    #[test]
28059    fn cli_accepts_locks_command() {
28060        let cli = parse_cli(["tsift", "locks"]);
28061        assert!(matches!(cli.command, Some(Commands::Locks { .. })));
28062    }
28063
28064    #[test]
28065    fn cli_parses_memory_budget_guard_command() {
28066        let cli = parse_cli([
28067            "tsift",
28068            "memory",
28069            "budget-guard",
28070            "--file",
28071            "tool.log",
28072            "--budget-tokens",
28073            "1000",
28074            "--json",
28075        ]);
28076        match cli.command {
28077            Some(Commands::Memory {
28078                command:
28079                    crate::cli::MemoryCommand::BudgetGuard {
28080                        file,
28081                        budget_tokens,
28082                        json,
28083                        ..
28084                    },
28085            }) => {
28086                assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
28087                assert_eq!(budget_tokens, 1000);
28088                assert!(json);
28089            }
28090            _ => panic!("expected memory budget-guard command"),
28091        }
28092    }
28093
28094    #[test]
28095    fn cli_parses_memory_capture_agent_doc_closeout_command() {
28096        let cli = parse_cli([
28097            "tsift",
28098            "memory",
28099            "capture-agent-doc-closeout",
28100            ".",
28101            "--session-path",
28102            "tasks/software/tsift.md",
28103            "--prompt-target",
28104            "do [#tsiftmemhooks]",
28105            "--response-summary",
28106            "wired closeout capture",
28107            "--commit-hash",
28108            "abc123",
28109            "--session-check-status",
28110            "clean",
28111            "--json",
28112        ]);
28113        match cli.command {
28114            Some(Commands::Memory {
28115                command:
28116                    crate::cli::MemoryCommand::CaptureAgentDocCloseout {
28117                        path,
28118                        session_path,
28119                        prompt_target,
28120                        response_summary,
28121                        commit_hash,
28122                        session_check_status,
28123                        json,
28124                    },
28125            }) => {
28126                assert_eq!(path, std::path::PathBuf::from("."));
28127                assert_eq!(
28128                    session_path,
28129                    std::path::PathBuf::from("tasks/software/tsift.md")
28130                );
28131                assert_eq!(prompt_target, "do [#tsiftmemhooks]");
28132                assert_eq!(response_summary, "wired closeout capture");
28133                assert_eq!(commit_hash.as_deref(), Some("abc123"));
28134                assert_eq!(session_check_status, "clean");
28135                assert!(json);
28136            }
28137            _ => panic!("expected memory capture-agent-doc-closeout command"),
28138        }
28139    }
28140
28141    #[test]
28142    fn cli_parses_memory_project_graph_read_policy() {
28143        let cli = parse_cli([
28144            "tsift",
28145            "memory",
28146            "project-graph",
28147            ".",
28148            "--read-policy",
28149            "query-relevant",
28150            "--query",
28151            "semantic memory",
28152            "--limit",
28153            "7",
28154            "--json",
28155        ]);
28156        match cli.command {
28157            Some(Commands::Memory {
28158                command:
28159                    crate::cli::MemoryCommand::ProjectGraph {
28160                        read_policy,
28161                        query,
28162                        limit,
28163                        json,
28164                        ..
28165                    },
28166            }) => {
28167                assert_eq!(
28168                    read_policy,
28169                    crate::cli::MemoryProjectReadPolicy::QueryRelevant
28170                );
28171                assert_eq!(query.as_deref(), Some("semantic memory"));
28172                assert_eq!(limit, 7);
28173                assert!(json);
28174            }
28175            _ => panic!("expected memory project-graph command"),
28176        }
28177    }
28178
28179    #[test]
28180    fn cli_locks_accepts_scope_flag() {
28181        let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
28182        match cli.command {
28183            Some(Commands::Locks { scope, .. }) => {
28184                assert_eq!(scope.as_deref(), Some("alpha"));
28185            }
28186            _ => panic!("expected Locks command"),
28187        }
28188    }
28189
28190    #[test]
28191    fn cli_search_accepts_autoindex_flag() {
28192        let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
28193        match cli.command {
28194            Some(Commands::Search {
28195                autoindex,
28196                no_autoindex,
28197                ..
28198            }) => {
28199                assert!(autoindex);
28200                assert!(!no_autoindex);
28201            }
28202            _ => panic!("expected Search command"),
28203        }
28204    }
28205
28206    #[test]
28207    fn cli_search_accepts_exact_flag() {
28208        let cli = parse_cli(["tsift", "search", "test", "--exact"]);
28209        match cli.command {
28210            Some(Commands::Search {
28211                exact, strategy, ..
28212            }) => {
28213                assert!(exact);
28214                assert!(strategy.is_none());
28215            }
28216            _ => panic!("expected Search command"),
28217        }
28218    }
28219
28220    #[test]
28221    fn cli_parses_diff_digest_command() {
28222        let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
28223        match cli.command {
28224            Some(Commands::DiffDigest {
28225                json,
28226                path,
28227                cached,
28228                revision,
28229                max_parsed_files,
28230            }) => {
28231                assert!(json);
28232                assert_eq!(path, PathBuf::from("."));
28233                assert!(!cached);
28234                assert!(revision.is_none());
28235                assert_eq!(max_parsed_files, 25);
28236            }
28237            _ => panic!("expected DiffDigest command"),
28238        }
28239    }
28240
28241    #[test]
28242    fn cli_rejects_conflicting_diff_digest_modes() {
28243        match try_parse_cli([
28244            "tsift",
28245            "diff-digest",
28246            "--cached",
28247            "--revision",
28248            "HEAD",
28249            ".",
28250        ]) {
28251            Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
28252            Err(err) => {
28253                assert!(err.to_string().contains("--cached"));
28254                assert!(err.to_string().contains("--revision"));
28255            }
28256        }
28257    }
28258
28259    #[test]
28260    fn cli_parses_test_digest_command() {
28261        let cli = parse_cli([
28262            "tsift",
28263            "test-digest",
28264            "--path",
28265            ".",
28266            "--input",
28267            "target/test.log",
28268            "--runner",
28269            "cargo",
28270            "--json",
28271        ]);
28272        match cli.command {
28273            Some(Commands::TestDigest {
28274                json,
28275                path,
28276                input,
28277                runner,
28278            }) => {
28279                assert!(json);
28280                assert_eq!(path, PathBuf::from("."));
28281                assert_eq!(input, Some(PathBuf::from("target/test.log")));
28282                assert_eq!(runner.as_deref(), Some("cargo"));
28283            }
28284            _ => panic!("expected TestDigest command"),
28285        }
28286    }
28287
28288    #[test]
28289    fn cli_parses_log_digest_command() {
28290        let cli = parse_cli([
28291            "tsift",
28292            "log-digest",
28293            "--path",
28294            ".",
28295            "--input",
28296            "target/build.log",
28297            "--json",
28298        ]);
28299        match cli.command {
28300            Some(Commands::LogDigest { json, path, input }) => {
28301                assert!(json);
28302                assert_eq!(path, PathBuf::from("."));
28303                assert_eq!(input, Some(PathBuf::from("target/build.log")));
28304            }
28305            _ => panic!("expected LogDigest command"),
28306        }
28307    }
28308
28309    #[test]
28310    fn cli_parses_metric_digest_command() {
28311        let cli = parse_cli([
28312            "tsift",
28313            "metric-digest",
28314            "--input",
28315            "target/runs.json",
28316            "--baseline",
28317            "target/prior.json",
28318            "--metric",
28319            "session_mae",
28320            "--lower-is-better",
28321            "session_mae",
28322            "--history",
28323            "4",
28324            "--top",
28325            "2",
28326            "--json",
28327        ]);
28328        match cli.command {
28329            Some(Commands::MetricDigest {
28330                input,
28331                baseline,
28332                metrics,
28333                lower_is_better,
28334                history,
28335                top,
28336                json,
28337                ..
28338            }) => {
28339                assert!(json);
28340                assert_eq!(input, Some(PathBuf::from("target/runs.json")));
28341                assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
28342                assert_eq!(metrics, vec!["session_mae"]);
28343                assert_eq!(lower_is_better, vec!["session_mae"]);
28344                assert_eq!(history, 4);
28345                assert_eq!(top, 2);
28346            }
28347            _ => panic!("expected MetricDigest command"),
28348        }
28349    }
28350
28351    #[test]
28352    fn cli_parses_dci_benchmark_command() {
28353        let cli = parse_cli([
28354            "tsift",
28355            "dci-benchmark",
28356            "--fixture",
28357            "fixtures/dci-search-benchmark.json",
28358            "--json",
28359        ]);
28360        match cli.command {
28361            Some(Commands::DciBenchmark { fixture, json }) => {
28362                assert!(json);
28363                assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
28364            }
28365            _ => panic!("expected DciBenchmark command"),
28366        }
28367    }
28368
28369    #[test]
28370    fn cli_parses_session_digest_command() {
28371        let cli = parse_cli([
28372            "tsift",
28373            "session-digest",
28374            "--path",
28375            ".",
28376            "--input",
28377            "target/session.md",
28378            "--source",
28379            "markdown",
28380            "--json",
28381        ]);
28382        match cli.command {
28383            Some(Commands::SessionDigest {
28384                json,
28385                path,
28386                input,
28387                source,
28388            }) => {
28389                assert!(json);
28390                assert_eq!(path, PathBuf::from("."));
28391                assert_eq!(input, Some(PathBuf::from("target/session.md")));
28392                assert_eq!(source.as_deref(), Some("markdown"));
28393            }
28394            _ => panic!("expected SessionDigest command"),
28395        }
28396    }
28397
28398    #[test]
28399    fn cli_parses_session_cost_command() {
28400        let cli = parse_cli([
28401            "tsift",
28402            "session-cost",
28403            "--input",
28404            "target/session.jsonl",
28405            "--source",
28406            "codex-jsonl",
28407            "--json",
28408        ]);
28409        match cli.command {
28410            Some(Commands::SessionCost {
28411                json,
28412                input,
28413                fixture,
28414                fail_under,
28415                source,
28416            }) => {
28417                assert!(json);
28418                assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
28419                assert_eq!(fixture, None);
28420                assert!(!fail_under);
28421                assert_eq!(source.as_deref(), Some("codex-jsonl"));
28422            }
28423            _ => panic!("expected SessionCost command"),
28424        }
28425
28426        let cli = parse_cli([
28427            "tsift",
28428            "session-cost",
28429            "--fixture",
28430            "fixtures/real-session-prompt-cache-effectiveness.json",
28431            "--fail-under",
28432            "--json",
28433        ]);
28434        match cli.command {
28435            Some(Commands::SessionCost {
28436                json,
28437                input,
28438                fixture,
28439                fail_under,
28440                source,
28441            }) => {
28442                assert!(json);
28443                assert_eq!(input, None);
28444                assert_eq!(
28445                    fixture,
28446                    Some(PathBuf::from(
28447                        "fixtures/real-session-prompt-cache-effectiveness.json"
28448                    ))
28449                );
28450                assert!(fail_under);
28451                assert_eq!(source, None);
28452            }
28453            _ => panic!("expected SessionCost command"),
28454        }
28455    }
28456
28457    #[test]
28458    fn cli_parses_session_review_command() {
28459        let cli = parse_cli([
28460            "tsift",
28461            "session-review",
28462            "tasks/software/tsift.md",
28463            "--next-context",
28464            "--json",
28465        ]);
28466        match cli.command {
28467            Some(Commands::SessionReview {
28468                json,
28469                next_context,
28470                path,
28471                ..
28472            }) => {
28473                assert!(json);
28474                assert!(next_context);
28475                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28476            }
28477            _ => panic!("expected SessionReview command"),
28478        }
28479    }
28480
28481    #[test]
28482    fn cli_search_accepts_budget_flags() {
28483        let cli = parse_cli([
28484            "tsift",
28485            "search",
28486            "alpha_helper",
28487            "--max-items",
28488            "3",
28489            "--max-bytes",
28490            "96",
28491        ]);
28492        match cli.command {
28493            Some(Commands::Search {
28494                max_items,
28495                max_bytes,
28496                ..
28497            }) => {
28498                assert_eq!(max_items, Some(3));
28499                assert_eq!(max_bytes, Some(96));
28500            }
28501            _ => panic!("expected Search command"),
28502        }
28503    }
28504
28505    #[test]
28506    fn cli_search_accepts_budget_preset() {
28507        let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
28508        match cli.command {
28509            Some(Commands::Search { budget, .. }) => {
28510                assert_eq!(budget, Some(ResponseBudgetPreset::Small));
28511            }
28512            _ => panic!("expected Search command"),
28513        }
28514    }
28515
28516    #[test]
28517    fn cli_search_accepts_ast_facet_filters() {
28518        let cli = parse_cli([
28519            "tsift",
28520            "search",
28521            "setup",
28522            "--lang",
28523            "markdown",
28524            "--kind",
28525            "list_item",
28526            "--node-kind",
28527            "list_item",
28528            "--section",
28529            "Install",
28530            "--parent",
28531            "Run setup.",
28532            "--child",
28533            "Confirm setup.",
28534            "--fence-language",
28535            "rust",
28536            "--list-depth",
28537            "1",
28538            "--heading-level",
28539            "2",
28540        ]);
28541        match cli.command {
28542            Some(Commands::Search {
28543                lang,
28544                kind,
28545                node_kind,
28546                section,
28547                parent,
28548                child,
28549                fence_language,
28550                list_depth,
28551                heading_level,
28552                ..
28553            }) => {
28554                assert_eq!(lang, vec!["markdown"]);
28555                assert_eq!(kind, vec!["list_item"]);
28556                assert_eq!(node_kind, vec!["list_item"]);
28557                assert_eq!(section, vec!["Install"]);
28558                assert_eq!(parent, vec!["Run setup."]);
28559                assert_eq!(child, vec!["Confirm setup."]);
28560                assert_eq!(fence_language, vec!["rust"]);
28561                assert_eq!(list_depth, vec![1]);
28562                assert_eq!(heading_level, vec![2]);
28563            }
28564            _ => panic!("expected Search command"),
28565        }
28566    }
28567
28568    #[test]
28569    fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
28570        let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
28571        assert_eq!(small.preview_items(), 3);
28572        assert_eq!(small.preview_bytes(), 120);
28573        assert_eq!(small.follow_up_items(), 4);
28574
28575        let overridden =
28576            ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
28577        assert_eq!(overridden.preview_items(), 7);
28578        assert_eq!(overridden.preview_bytes(), 120);
28579        assert_eq!(overridden.follow_up_items(), 7);
28580
28581        let envelope_default = ResponseBudget::from_cli(None, None, None, true);
28582        assert!(envelope_default.is_active());
28583    }
28584
28585    #[test]
28586    fn cli_explain_accepts_budget_flags() {
28587        let cli = parse_cli([
28588            "tsift",
28589            "explain",
28590            "alpha_helper",
28591            "--max-items",
28592            "2",
28593            "--max-bytes",
28594            "80",
28595        ]);
28596        match cli.command {
28597            Some(Commands::Explain {
28598                max_items,
28599                max_bytes,
28600                ..
28601            }) => {
28602                assert_eq!(max_items, Some(2));
28603                assert_eq!(max_bytes, Some(80));
28604            }
28605            _ => panic!("expected Explain command"),
28606        }
28607    }
28608
28609    #[test]
28610    fn cli_session_review_accepts_budget_flags() {
28611        let cli = parse_cli([
28612            "tsift",
28613            "session-review",
28614            "tasks/software/tsift.md",
28615            "--max-items",
28616            "4",
28617            "--max-bytes",
28618            "120",
28619        ]);
28620        match cli.command {
28621            Some(Commands::SessionReview {
28622                max_items,
28623                max_bytes,
28624                ..
28625            }) => {
28626                assert_eq!(max_items, Some(4));
28627                assert_eq!(max_bytes, Some(120));
28628            }
28629            _ => panic!("expected SessionReview command"),
28630        }
28631    }
28632
28633    #[test]
28634    fn cli_parses_context_pack_command() {
28635        let cli = parse_cli([
28636            "tsift",
28637            "context-pack",
28638            "tasks/software/tsift.md",
28639            "--test-input",
28640            "target/test.log",
28641            "--runner",
28642            "cargo",
28643            "--log-input",
28644            "target/build.log",
28645            "--max-items",
28646            "3",
28647            "--max-bytes",
28648            "96",
28649            "--json",
28650        ]);
28651        match cli.command {
28652            Some(Commands::ContextPack {
28653                path,
28654                test_input,
28655                runner,
28656                log_input,
28657                json,
28658                max_items,
28659                max_bytes,
28660                budget,
28661                convex_snapshot,
28662            }) => {
28663                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28664                assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
28665                assert_eq!(runner.as_deref(), Some("cargo"));
28666                assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
28667                assert!(json);
28668                assert_eq!(max_items, Some(3));
28669                assert_eq!(max_bytes, Some(96));
28670                assert!(budget.is_none());
28671                assert!(convex_snapshot.is_none());
28672            }
28673            _ => panic!("expected ContextPack command"),
28674        }
28675    }
28676
28677    #[test]
28678    fn cli_parses_token_savings_command() {
28679        let cli = parse_cli([
28680            "tsift",
28681            "token-savings",
28682            "--fixture",
28683            "fixtures/tsift-token-savings.json",
28684            "--fail-under",
28685            "--json",
28686        ]);
28687        match cli.command {
28688            Some(Commands::TokenSavings {
28689                fixture,
28690                fail_under,
28691                json,
28692            }) => {
28693                assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
28694                assert!(fail_under);
28695                assert!(json);
28696            }
28697            _ => panic!("expected TokenSavings command"),
28698        }
28699    }
28700
28701    #[test]
28702    fn token_savings_report_records_fixture_thresholds() {
28703        let raw_symbols = [
28704            "validate_user",
28705            "validateUser",
28706            "ValidateUser",
28707            "validate-user",
28708            "VALIDATE_USER",
28709            "Validate_User",
28710            "raw_symbol",
28711            "rawSymbol",
28712            "RawSymbol",
28713            "raw-symbol",
28714            "RAW_SYMBOL",
28715            "Raw_Symbol",
28716        ]
28717        .iter()
28718        .enumerate()
28719        .map(|(idx, identifier)| TokenSavingsRawSymbol {
28720            identifier: (*identifier).to_string(),
28721            file: format!("src/example_{idx}.rs"),
28722            line: (idx + 1) as u64,
28723            context: "function".to_string(),
28724        })
28725        .collect();
28726        let fixture = TokenSavingsFixture {
28727            schema_version: 1,
28728            description: "fixture".to_string(),
28729            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28730            cases: vec![TokenSavingsFixtureCase {
28731                name: "search-preview".to_string(),
28732                surface: "search".to_string(),
28733                minimum_savings_percent: 40.0,
28734                raw_symbols,
28735                tagpath_families: vec![
28736                    TokenSavingsFamily {
28737                        canonical: "validate_user".to_string(),
28738                        count: 6,
28739                        aliases: BTreeMap::new(),
28740                    },
28741                    TokenSavingsFamily {
28742                        canonical: "raw_symbol".to_string(),
28743                        count: 6,
28744                        aliases: BTreeMap::new(),
28745                    },
28746                ],
28747                context_pack_inputs: None,
28748                session_review_inputs: None,
28749                source_read_inputs: None,
28750                markdown_projection_inputs: None,
28751            }],
28752        };
28753
28754        let report = build_token_savings_report(&fixture).unwrap();
28755
28756        assert!(report.pass);
28757        assert_eq!(report.cases[0].raw_symbol_count, 12);
28758        assert_eq!(report.cases[0].family_count, 2);
28759        assert_eq!(report.cases[0].status, "pass");
28760        assert!(report.cases[0].byte_delta > 0);
28761        assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
28762        assert!(report.cases[0].savings_percent >= 40.0);
28763    }
28764
28765    #[test]
28766    fn token_savings_source_read_inputs_preserve_required_anchors() {
28767        let fixture = TokenSavingsFixture {
28768            schema_version: 1,
28769            description: "fixture".to_string(),
28770            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28771            cases: vec![TokenSavingsFixtureCase {
28772                name: "source-read".to_string(),
28773                surface: "source-read".to_string(),
28774                minimum_savings_percent: 40.0,
28775                raw_symbols: Vec::new(),
28776                tagpath_families: Vec::new(),
28777                context_pack_inputs: None,
28778                session_review_inputs: None,
28779                source_read_inputs: Some(TokenSavingsSourceReadInputs {
28780                    reads: vec![TokenSavingsSourceReadInput {
28781                        command: "sed -n '40,160p' src/main.rs".to_string(),
28782                        file: "src/main.rs".to_string(),
28783                        raw_start: 40,
28784                        raw_lines: 121,
28785                        raw_excerpt: "line 40\n".repeat(121),
28786                        envelope_start: 40,
28787                        envelope_lines: 121,
28788                        required_line_anchors: vec![40, 120, 160],
28789                    }],
28790                }),
28791                markdown_projection_inputs: None,
28792            }],
28793        };
28794
28795        let report = build_token_savings_report(&fixture).unwrap();
28796
28797        assert!(report.pass);
28798        assert_eq!(report.cases[0].surface, "source-read");
28799        assert!(report.cases[0].savings_percent >= 40.0);
28800    }
28801
28802    #[test]
28803    fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
28804        let fixture = TokenSavingsFixture {
28805            schema_version: 1,
28806            description: "fixture".to_string(),
28807            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28808            cases: vec![TokenSavingsFixtureCase {
28809                name: "source-read".to_string(),
28810                surface: "source-read".to_string(),
28811                minimum_savings_percent: 40.0,
28812                raw_symbols: Vec::new(),
28813                tagpath_families: Vec::new(),
28814                context_pack_inputs: None,
28815                session_review_inputs: None,
28816                source_read_inputs: Some(TokenSavingsSourceReadInputs {
28817                    reads: vec![TokenSavingsSourceReadInput {
28818                        command: "cat src/main.rs".to_string(),
28819                        file: "src/main.rs".to_string(),
28820                        raw_start: 1,
28821                        raw_lines: 200,
28822                        raw_excerpt: "line\n".repeat(200),
28823                        envelope_start: 1,
28824                        envelope_lines: 80,
28825                        required_line_anchors: vec![120],
28826                    }],
28827                }),
28828                markdown_projection_inputs: None,
28829            }],
28830        };
28831
28832        let err = match build_token_savings_report(&fixture) {
28833            Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
28834            Err(err) => err,
28835        };
28836
28837        assert!(err.to_string().contains("hides required line anchor 120"));
28838    }
28839
28840    #[test]
28841    fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
28842        let fixture = TokenSavingsFixture {
28843            schema_version: 1,
28844            description: "fixture".to_string(),
28845            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28846            cases: vec![TokenSavingsFixtureCase {
28847                name: "markdown-projection".to_string(),
28848                surface: "context-pack".to_string(),
28849                minimum_savings_percent: 40.0,
28850                raw_symbols: Vec::new(),
28851                tagpath_families: Vec::new(),
28852                context_pack_inputs: None,
28853                session_review_inputs: None,
28854                source_read_inputs: None,
28855                markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
28856                    documents: vec![TokenSavingsMarkdownProjectionInput {
28857                        command: "context-pack markdown body".to_string(),
28858                        file: "tasks/software/tsift.md".to_string(),
28859                        raw_markdown: "# Heading\n\n".repeat(120),
28860                        outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
28861                        selected_nodes: vec!["mdast-selected".to_string()],
28862                        expand:
28863                            "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
28864                                .to_string(),
28865                    }],
28866                }),
28867            }],
28868        };
28869
28870        let report = build_token_savings_report(&fixture).unwrap();
28871
28872        assert!(report.pass);
28873        assert_eq!(report.cases[0].surface, "context-pack");
28874        assert!(report.cases[0].savings_percent >= 40.0);
28875    }
28876
28877    #[test]
28878    fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
28879        let mut content = String::from("# Cache Root\n\n");
28880        for idx in 0..96 {
28881            content.push_str(&format!(
28882                "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
28883            ));
28884        }
28885
28886        let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
28887        assert!(!first.cache_hit);
28888        assert!(first.nodes.len() > 200);
28889
28890        let sections = markdown_section_spans(&content).unwrap();
28891        let list_items = markdown_block_spans(&content, "list_item").unwrap();
28892        let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
28893        let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
28894
28895        assert!(second.cache_hit);
28896        assert_eq!(second.nodes.len(), first.nodes.len());
28897        assert_eq!(sections.len(), 97);
28898        assert_eq!(list_items.len(), 96);
28899        assert_eq!(code_blocks.len(), 96);
28900        let first_code = first
28901            .nodes
28902            .iter()
28903            .find(|node| node.kind == "code_block")
28904            .expect("expected a Markdown code block");
28905        let first_code_node = markdown_ast_node(
28906            Path::new("/repo"),
28907            "semantic-edit",
28908            first_code,
28909            content.as_bytes(),
28910            &first.nodes,
28911            8,
28912        );
28913        assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
28914        assert_eq!(
28915            first_code_node.metadata.embedded_symbols[0].name,
28916            "sample_0"
28917        );
28918        assert_eq!(
28919            first_code_node.metadata.embedded_symbols[0].language,
28920            "rust"
28921        );
28922    }
28923
28924    #[test]
28925    fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
28926        let response = empty_search_response(Path::new("/repo"), "lexical");
28927        let symbol_hits = vec![index::SymbolHit {
28928            name: "alpha_helper_with_a_long_name".to_string(),
28929            kind: "function".to_string(),
28930            language: "rust".to_string(),
28931            file: "/repo/src/lib.rs".to_string(),
28932            line: 12,
28933            end_line: None,
28934            node_kind: None,
28935            start_byte: None,
28936            end_byte: None,
28937            body_start_byte: None,
28938            body_end_byte: None,
28939            tags: None,
28940            score: 0.98,
28941            match_type: "exact_name".to_string(),
28942            tagpath_handle: None,
28943        }];
28944
28945        let report = build_relative_search_budget_report(
28946            "alpha_helper_with_a_long_name",
28947            "lexical",
28948            Path::new("/repo"),
28949            &response,
28950            &symbol_hits,
28951            ResponseBudget::new(Some(1), Some(12)),
28952            &SearchFacetFilters::default(),
28953        );
28954
28955        assert_eq!(report.symbols.len(), 1);
28956        assert!(report.symbols[0].handle.starts_with("sfam-"));
28957        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
28958        assert_eq!(report.symbols[0].name, "alpha_hel...");
28959        assert_eq!(report.symbols[0].file, "src/lib.rs");
28960        assert!(report.symbols[0].expand.contains("tsift search"));
28961    }
28962
28963    #[test]
28964    fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
28965        let dir = tempfile::tempdir().unwrap();
28966        let src_dir = dir.path().join("src");
28967        fs::create_dir_all(&src_dir).unwrap();
28968        let source = "fn alpha_helper() {\n    beta();\n}\n";
28969        let file = src_dir.join("lib.rs");
28970        fs::write(&file, source).unwrap();
28971        let body_start = source.find("{\n").unwrap() + 1;
28972        let body_end = source.rfind("\n}").unwrap() + 1;
28973
28974        let response = empty_search_response(dir.path(), "lexical");
28975        let symbol_hits = vec![index::SymbolHit {
28976            name: "alpha_helper".to_string(),
28977            kind: "function".to_string(),
28978            language: "rust".to_string(),
28979            file: file.to_string_lossy().to_string(),
28980            line: 0,
28981            end_line: Some(2),
28982            node_kind: Some("function_item".to_string()),
28983            start_byte: Some(0),
28984            end_byte: Some(i64::try_from(source.len()).unwrap()),
28985            body_start_byte: Some(i64::try_from(body_start).unwrap()),
28986            body_end_byte: Some(i64::try_from(body_end).unwrap()),
28987            tags: Some("alpha,helper".to_string()),
28988            score: 0.98,
28989            match_type: "exact_name".to_string(),
28990            tagpath_handle: None,
28991        }];
28992
28993        let report = build_relative_search_budget_report(
28994            "alpha helper",
28995            "lexical",
28996            dir.path(),
28997            &response,
28998            &symbol_hits,
28999            ResponseBudget::new(Some(5), Some(96)),
29000            &SearchFacetFilters::default(),
29001        );
29002
29003        let symbol = &report.symbols[0];
29004        assert_eq!(symbol.language, "rust");
29005        assert_eq!(symbol.end_line, Some(2));
29006        let ast = symbol
29007            .ast
29008            .as_ref()
29009            .expect("search symbol preview should expose an AST span artifact");
29010        assert_eq!(ast.artifact_kind, "ast_span");
29011        assert!(ast.span.handle.starts_with("span-"));
29012        assert_eq!(ast.span.node_kind, "function_item");
29013        assert_eq!(ast.span.start_byte, 0);
29014        assert_eq!(ast.span.end_byte, source.len());
29015        assert_eq!(ast.span.body_start_byte, Some(body_start));
29016        assert_eq!(ast.span.body_end_byte, Some(body_end));
29017        assert!(ast.expand.source_window.contains("source-read"));
29018        assert!(
29019            ast.expand
29020                .source_body
29021                .as_ref()
29022                .unwrap()
29023                .contains("source-read")
29024        );
29025        assert!(ast.expand.symbol_read.contains("symbol-read"));
29026        assert!(ast.expand.markdown_ast.is_none());
29027    }
29028
29029    #[test]
29030    fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
29031        let dir = tempfile::tempdir().unwrap();
29032        let source = "# Guide\n\n## Install\n\n- Run setup.\n";
29033        let file = dir.path().join("README.md");
29034        fs::write(&file, source).unwrap();
29035        let heading_start = source.find("## Install").unwrap();
29036        let heading_end = source.len();
29037
29038        let response = empty_search_response(dir.path(), "lexical");
29039        let symbol_hits = vec![index::SymbolHit {
29040            name: "Install".to_string(),
29041            kind: "heading".to_string(),
29042            language: "markdown".to_string(),
29043            file: file.to_string_lossy().to_string(),
29044            line: 2,
29045            end_line: Some(4),
29046            node_kind: Some("atx_heading".to_string()),
29047            start_byte: Some(i64::try_from(heading_start).unwrap()),
29048            end_byte: Some(i64::try_from(heading_end).unwrap()),
29049            body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
29050            body_end_byte: Some(i64::try_from(heading_end).unwrap()),
29051            tags: Some("install".to_string()),
29052            score: 1.0,
29053            match_type: "exact_name".to_string(),
29054            tagpath_handle: None,
29055        }];
29056
29057        let report = build_relative_search_budget_report(
29058            "Install",
29059            "lexical",
29060            dir.path(),
29061            &response,
29062            &symbol_hits,
29063            ResponseBudget::new(Some(5), Some(96)),
29064            &SearchFacetFilters::default(),
29065        );
29066
29067        let ast = report.symbols[0]
29068            .ast
29069            .as_ref()
29070            .expect("Markdown search symbol should expose an AST span artifact");
29071        assert_eq!(ast.span.node_kind, "atx_heading");
29072        assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
29073        let markdown_ast = ast
29074            .expand
29075            .markdown_ast
29076            .as_ref()
29077            .expect("Markdown symbols should include markdown-ast expansion");
29078        assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
29079        assert!(markdown_ast.contains("--node"), "{markdown_ast}");
29080        assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
29081        assert!(ast.expand.source_window.contains("source-read"));
29082        assert!(ast.expand.symbol_read.contains("symbol-read"));
29083    }
29084
29085    #[test]
29086    fn search_budget_report_exposes_markdown_embedded_code_symbols() {
29087        let dir = tempfile::tempdir().unwrap();
29088        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29089        let file = dir.path().join("README.md");
29090        fs::write(&file, source).unwrap();
29091        let fence_start = source.find("```rust").unwrap();
29092        let body_start = source.find("fn sample").unwrap();
29093        let body_end = body_start + "fn sample() {}\n".len();
29094
29095        let response = empty_search_response(dir.path(), "lexical");
29096        let symbol_hits = vec![index::SymbolHit {
29097            name: "rust".to_string(),
29098            kind: "code_block".to_string(),
29099            language: "markdown".to_string(),
29100            file: file.to_string_lossy().to_string(),
29101            line: 2,
29102            end_line: Some(4),
29103            node_kind: Some("fenced_code_block".to_string()),
29104            start_byte: Some(i64::try_from(fence_start).unwrap()),
29105            end_byte: Some(i64::try_from(source.len()).unwrap()),
29106            body_start_byte: Some(i64::try_from(body_start).unwrap()),
29107            body_end_byte: Some(i64::try_from(body_end).unwrap()),
29108            tags: Some("rust".to_string()),
29109            score: 1.0,
29110            match_type: "exact_name".to_string(),
29111            tagpath_handle: None,
29112        }];
29113
29114        let report = build_relative_search_budget_report(
29115            "rust",
29116            "lexical",
29117            dir.path(),
29118            &response,
29119            &symbol_hits,
29120            ResponseBudget::new(Some(5), Some(96)),
29121            &SearchFacetFilters::default(),
29122        );
29123
29124        let embedded = &report.symbols[0]
29125            .ast
29126            .as_ref()
29127            .unwrap()
29128            .span
29129            .markdown
29130            .as_ref()
29131            .unwrap()
29132            .embedded_symbols;
29133        assert_eq!(embedded.len(), 1);
29134        assert_eq!(embedded[0].name, "sample");
29135        assert_eq!(embedded[0].kind, "function");
29136        assert_eq!(embedded[0].language, "rust");
29137        assert_eq!(embedded[0].node_kind, "function_item");
29138        assert!(embedded[0].handle.starts_with("span-"));
29139        assert_eq!(embedded[0].start_byte, body_start);
29140        assert_eq!(embedded[0].start_line, 4);
29141    }
29142
29143    fn test_lexical_search_hit(
29144        path: &Path,
29145        rank: usize,
29146        score: f64,
29147        snippet: &str,
29148    ) -> sift::SearchHit {
29149        sift::SearchHit {
29150            artifact_id: format!("hit-{rank}"),
29151            artifact_kind: sift::ContextArtifactKind::File,
29152            budget: sift::ArtifactBudget::from_text(snippet, 1),
29153            confidence: sift::ScoreConfidence::High,
29154            freshness: sift::ArtifactFreshness {
29155                modified_unix_secs: None,
29156                observed_unix_secs: 0,
29157            },
29158            location: Some("line 1".to_string()),
29159            path: path.to_string_lossy().to_string(),
29160            provenance: sift::ArtifactProvenance {
29161                adapter: sift::AcquisitionAdapterKind::FileSystem,
29162                source: "test lexical hit".to_string(),
29163                synthetic: false,
29164            },
29165            rank,
29166            score,
29167            snippet: snippet.to_string(),
29168        }
29169    }
29170
29171    fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
29172        summarize::Summary {
29173            id: 0,
29174            symbol_name: symbol_name.to_string(),
29175            file_path: file_path.to_string(),
29176            content_hash: "hash".to_string(),
29177            summary: summary.to_string(),
29178            entities: None,
29179            relationships: None,
29180            concept_labels: None,
29181            extracted_at: "2026-06-02T00:00:00Z".to_string(),
29182            model: "test".to_string(),
29183            tokens_input: None,
29184            tokens_output: None,
29185        }
29186    }
29187
29188    #[test]
29189    fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
29190        let dir = tempfile::tempdir().unwrap();
29191        let src_dir = dir.path().join("src");
29192        fs::create_dir_all(&src_dir).unwrap();
29193        let source = "fn alpha_helper() {}\n";
29194        let file = src_dir.join("lib.rs");
29195        let broad_file = dir.path().join("README.md");
29196        fs::write(&file, source).unwrap();
29197        fs::write(
29198            &broad_file,
29199            "alpha helper alpha helper alpha helper in prose\n",
29200        )
29201        .unwrap();
29202
29203        let mut response = empty_search_response(dir.path(), "lexical");
29204        response.hits.push(test_lexical_search_hit(
29205            &broad_file,
29206            1,
29207            240.0,
29208            "alpha helper alpha helper alpha helper in prose",
29209        ));
29210        let symbol_hits = vec![index::SymbolHit {
29211            name: "alpha_helper".to_string(),
29212            kind: "function".to_string(),
29213            language: "rust".to_string(),
29214            file: file.to_string_lossy().to_string(),
29215            line: 0,
29216            end_line: Some(0),
29217            node_kind: Some("function_item".to_string()),
29218            start_byte: Some(0),
29219            end_byte: Some(i64::try_from(source.len()).unwrap()),
29220            body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29221            body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29222            tags: Some("alpha,helper".to_string()),
29223            score: 0.8,
29224            match_type: "all_tags".to_string(),
29225            tagpath_handle: None,
29226        }];
29227
29228        let report = build_relative_search_budget_report(
29229            "alpha helper",
29230            "lexical",
29231            dir.path(),
29232            &response,
29233            &symbol_hits,
29234            ResponseBudget::new(Some(5), Some(128)),
29235            &SearchFacetFilters::default(),
29236        );
29237
29238        assert_eq!(report.ranked[0].source, "symbol_span");
29239        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
29240        assert!(report.ranked[0].score > report.ranked[1].score);
29241        assert_eq!(report.ranked[1].source, "lexical_file");
29242    }
29243
29244    #[test]
29245    fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
29246        let dir = tempfile::tempdir().unwrap();
29247        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29248        let file = dir.path().join("README.md");
29249        fs::write(&file, source).unwrap();
29250        let summary_db =
29251            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
29252        summary_db
29253            .insert(&test_summary(
29254                "rust",
29255                "README.md",
29256                "Rust fence contains a sample function.",
29257            ))
29258            .unwrap();
29259
29260        let fence_start = source.find("```rust").unwrap();
29261        let body_start = source.find("fn sample").unwrap();
29262        let body_end = body_start + "fn sample() {}\n".len();
29263        let response = empty_search_response(dir.path(), "lexical");
29264        let symbol_hits = vec![index::SymbolHit {
29265            name: "rust".to_string(),
29266            kind: "code_block".to_string(),
29267            language: "markdown".to_string(),
29268            file: file.to_string_lossy().to_string(),
29269            line: 2,
29270            end_line: Some(4),
29271            node_kind: Some("fenced_code_block".to_string()),
29272            start_byte: Some(i64::try_from(fence_start).unwrap()),
29273            end_byte: Some(i64::try_from(source.len()).unwrap()),
29274            body_start_byte: Some(i64::try_from(body_start).unwrap()),
29275            body_end_byte: Some(i64::try_from(body_end).unwrap()),
29276            tags: Some("rust".to_string()),
29277            score: 1.0,
29278            match_type: "exact_name".to_string(),
29279            tagpath_handle: None,
29280        }];
29281
29282        let report = build_relative_search_budget_report(
29283            "rust",
29284            "lexical",
29285            dir.path(),
29286            &response,
29287            &symbol_hits,
29288            ResponseBudget::new(Some(5), Some(128)),
29289            &SearchFacetFilters::default(),
29290        );
29291
29292        let symbol = &report.symbols[0];
29293        assert_eq!(symbol.summary_refs, 1);
29294        assert_eq!(symbol.graph_neighbors, 1);
29295        assert!(
29296            report.ranked[0]
29297                .reasons
29298                .iter()
29299                .any(|reason| reason == "summary_refs:1")
29300        );
29301        assert!(
29302            report.ranked[0]
29303                .reasons
29304                .iter()
29305                .any(|reason| reason == "graph_neighbors:1")
29306        );
29307    }
29308
29309    fn markdown_search_facet_fixture() -> tempfile::TempDir {
29310        let dir = tempfile::tempdir().unwrap();
29311        let source = r#"# Guide
29312
29313## Install
29314
29315- Run setup.
29316  - Confirm setup.
29317
29318```rust
29319fn sample() {}
29320```
29321"#;
29322        fs::write(dir.path().join("README.md"), source).unwrap();
29323        let index_dir = dir.path().join(".tsift");
29324        fs::create_dir_all(&index_dir).unwrap();
29325        run_index_update(
29326            &index_dir.join("index.db"),
29327            dir.path(),
29328            "indexing markdown search facet fixture".to_string(),
29329            dir.path(),
29330            None,
29331            false,
29332            false,
29333        )
29334        .unwrap();
29335        dir
29336    }
29337
29338    fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
29339        let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
29340        db.symbol_search(query, 20).unwrap()
29341    }
29342
29343    #[test]
29344    fn search_facet_filters_match_scalar_symbol_fields() {
29345        let dir = tempfile::tempdir().unwrap();
29346        let hits = vec![
29347            index::SymbolHit {
29348                name: "alpha_helper".to_string(),
29349                kind: "function".to_string(),
29350                language: "rust".to_string(),
29351                file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
29352                line: 0,
29353                end_line: None,
29354                node_kind: Some("function_item".to_string()),
29355                start_byte: None,
29356                end_byte: None,
29357                body_start_byte: None,
29358                body_end_byte: None,
29359                tags: None,
29360                score: 1.0,
29361                match_type: "exact_name".to_string(),
29362                tagpath_handle: None,
29363            },
29364            index::SymbolHit {
29365                name: "Install".to_string(),
29366                kind: "heading".to_string(),
29367                language: "markdown".to_string(),
29368                file: dir.path().join("README.md").to_string_lossy().to_string(),
29369                line: 0,
29370                end_line: None,
29371                node_kind: Some("atx_heading".to_string()),
29372                start_byte: None,
29373                end_byte: None,
29374                body_start_byte: None,
29375                body_end_byte: None,
29376                tags: None,
29377                score: 0.9,
29378                match_type: "exact_name".to_string(),
29379                tagpath_handle: None,
29380            },
29381        ];
29382
29383        let filtered = apply_search_facet_filters(
29384            dir.path(),
29385            hits,
29386            &SearchFacetFilters {
29387                languages: vec!["rust".to_string()],
29388                kinds: vec!["function".to_string()],
29389                node_kinds: vec!["function_item".to_string()],
29390                ..SearchFacetFilters::default()
29391            },
29392        );
29393
29394        assert_eq!(filtered.len(), 1);
29395        assert_eq!(filtered[0].name, "alpha_helper");
29396    }
29397
29398    #[test]
29399    fn search_facet_filters_match_markdown_sections_and_block_metadata() {
29400        let dir = markdown_search_facet_fixture();
29401
29402        let nested_list = apply_search_facet_filters(
29403            dir.path(),
29404            markdown_search_facet_hits(dir.path(), "setup"),
29405            &SearchFacetFilters {
29406                sections: vec!["Install".to_string()],
29407                parents: vec!["Run setup.".to_string()],
29408                list_depths: vec![1],
29409                ..SearchFacetFilters::default()
29410            },
29411        );
29412        assert_eq!(nested_list.len(), 1);
29413        assert_eq!(nested_list[0].name, "Confirm setup.");
29414
29415        let parent_list = apply_search_facet_filters(
29416            dir.path(),
29417            markdown_search_facet_hits(dir.path(), "setup"),
29418            &SearchFacetFilters {
29419                children: vec!["Confirm setup.".to_string()],
29420                ..SearchFacetFilters::default()
29421            },
29422        );
29423        assert_eq!(parent_list.len(), 1);
29424        assert_eq!(parent_list[0].name, "Run setup.");
29425
29426        let heading = apply_search_facet_filters(
29427            dir.path(),
29428            markdown_search_facet_hits(dir.path(), "Install"),
29429            &SearchFacetFilters {
29430                heading_levels: vec![2],
29431                node_kinds: vec!["atx_heading".to_string()],
29432                ..SearchFacetFilters::default()
29433            },
29434        );
29435        assert_eq!(heading.len(), 1);
29436        assert_eq!(heading[0].name, "Install");
29437
29438        let fence = apply_search_facet_filters(
29439            dir.path(),
29440            markdown_search_facet_hits(dir.path(), "rust"),
29441            &SearchFacetFilters {
29442                fence_languages: vec!["rust".to_string()],
29443                kinds: vec!["code_block".to_string()],
29444                ..SearchFacetFilters::default()
29445            },
29446        );
29447        assert_eq!(fence.len(), 1);
29448        assert_eq!(fence[0].kind, "code_block");
29449
29450        let embedded_child = apply_search_facet_filters(
29451            dir.path(),
29452            markdown_search_facet_hits(dir.path(), "rust"),
29453            &SearchFacetFilters {
29454                children: vec!["sample".to_string()],
29455                kinds: vec!["code_block".to_string()],
29456                ..SearchFacetFilters::default()
29457            },
29458        );
29459        assert_eq!(embedded_child.len(), 1);
29460        assert_eq!(embedded_child[0].name, "rust");
29461    }
29462
29463    #[test]
29464    fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
29465        let response = empty_search_response(Path::new("/repo"), "lexical");
29466        let symbol_hits = vec![
29467            index::SymbolHit {
29468                name: "alpha_helper".to_string(),
29469                kind: "function".to_string(),
29470                language: "rust".to_string(),
29471                file: "/repo/src/lib.rs".to_string(),
29472                line: 12,
29473                end_line: None,
29474                node_kind: None,
29475                start_byte: None,
29476                end_byte: None,
29477                body_start_byte: None,
29478                body_end_byte: None,
29479                tags: Some("alpha,helper".to_string()),
29480                score: 0.98,
29481                match_type: "exact_name".to_string(),
29482                tagpath_handle: None,
29483            },
29484            index::SymbolHit {
29485                name: "alphaHelper".to_string(),
29486                kind: "method".to_string(),
29487                language: "rust".to_string(),
29488                file: "/repo/src/main.rs".to_string(),
29489                line: 34,
29490                end_line: None,
29491                node_kind: None,
29492                start_byte: None,
29493                end_byte: None,
29494                body_start_byte: None,
29495                body_end_byte: None,
29496                tags: Some("alpha,helper".to_string()),
29497                score: 0.93,
29498                match_type: "tag_overlap".to_string(),
29499                tagpath_handle: None,
29500            },
29501            index::SymbolHit {
29502                name: "alpha_helper".to_string(),
29503                kind: "function".to_string(),
29504                language: "rust".to_string(),
29505                file: "/repo/src/worker.rs".to_string(),
29506                line: 56,
29507                end_line: None,
29508                node_kind: None,
29509                start_byte: None,
29510                end_byte: None,
29511                body_start_byte: None,
29512                body_end_byte: None,
29513                tags: Some("alpha,helper".to_string()),
29514                score: 0.91,
29515                match_type: "tag_overlap".to_string(),
29516                tagpath_handle: None,
29517            },
29518        ];
29519
29520        let report = build_relative_search_budget_report(
29521            "alpha helper",
29522            "lexical",
29523            Path::new("/repo"),
29524            &response,
29525            &symbol_hits,
29526            ResponseBudget::new(Some(5), Some(48)),
29527            &SearchFacetFilters::default(),
29528        );
29529
29530        assert_eq!(report.symbol_total, 1);
29531        assert_eq!(report.raw_symbol_total, 3);
29532        assert_eq!(report.symbols.len(), 1);
29533        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
29534        assert_eq!(report.symbols[0].match_count, 3);
29535        assert_eq!(report.symbols[0].surface_count, 2);
29536        assert_eq!(report.symbols[0].file_count, 3);
29537        assert_eq!(
29538            report.symbols[0].surface_examples,
29539            vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
29540        );
29541        assert!(report.symbols[0].name.contains("(+1 variant)"));
29542        assert!(report.symbols[0].file.contains("(+2 files)"));
29543        assert!(report.symbols[0].expand.contains("tsift search"));
29544        assert!(report.symbols[0].expand.contains("alpha helper"));
29545    }
29546
29547    #[test]
29548    fn search_budget_report_carries_active_filters() {
29549        let response = empty_search_response(Path::new("/repo"), "lexical");
29550        let symbol_hits = vec![index::SymbolHit {
29551            name: "alpha_helper".to_string(),
29552            kind: "function".to_string(),
29553            language: "rust".to_string(),
29554            file: "/repo/src/lib.rs".to_string(),
29555            line: 12,
29556            end_line: None,
29557            node_kind: Some("function_item".to_string()),
29558            start_byte: None,
29559            end_byte: None,
29560            body_start_byte: None,
29561            body_end_byte: None,
29562            tags: Some("alpha,helper".to_string()),
29563            score: 0.98,
29564            match_type: "exact_name".to_string(),
29565            tagpath_handle: None,
29566        }];
29567        let filters = SearchFacetFilters {
29568            languages: vec!["rust".to_string()],
29569            kinds: vec!["function".to_string()],
29570            node_kinds: vec!["function_item".to_string()],
29571            ..SearchFacetFilters::default()
29572        };
29573
29574        let report = build_relative_search_budget_report(
29575            "alpha helper",
29576            "lexical",
29577            Path::new("/repo"),
29578            &response,
29579            &symbol_hits,
29580            ResponseBudget::new(Some(5), Some(48)),
29581            &filters,
29582        );
29583
29584        assert_eq!(report.filters, filters);
29585        assert_eq!(
29586            search_facet_filters_summary(&report.filters),
29587            "lang=rust kind=function node-kind=function_item"
29588        );
29589    }
29590
29591    #[test]
29592    fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
29593        let mut response = empty_search_response(Path::new("/repo"), "lexical");
29594        response.indexed_artifacts = 450;
29595        let symbol_hits = vec![
29596            index::SymbolHit {
29597                name: "alpha_helper".to_string(),
29598                kind: "function".to_string(),
29599                language: "rust".to_string(),
29600                file: "/repo/src/lib.rs".to_string(),
29601                line: 12,
29602                end_line: None,
29603                node_kind: None,
29604                start_byte: None,
29605                end_byte: None,
29606                body_start_byte: None,
29607                body_end_byte: None,
29608                tags: Some("alpha,helper".to_string()),
29609                score: 0.98,
29610                match_type: "exact_name".to_string(),
29611                tagpath_handle: None,
29612            },
29613            index::SymbolHit {
29614                name: "beta_helper".to_string(),
29615                kind: "function".to_string(),
29616                language: "rust".to_string(),
29617                file: "/repo/src/beta.rs".to_string(),
29618                line: 21,
29619                end_line: None,
29620                node_kind: None,
29621                start_byte: None,
29622                end_byte: None,
29623                body_start_byte: None,
29624                body_end_byte: None,
29625                tags: Some("beta,helper".to_string()),
29626                score: 0.92,
29627                match_type: "tag_overlap".to_string(),
29628                tagpath_handle: None,
29629            },
29630        ];
29631
29632        let report = build_relative_search_budget_report(
29633            "helper",
29634            "lexical",
29635            Path::new("/repo"),
29636            &response,
29637            &symbol_hits,
29638            ResponseBudget::new(Some(1), Some(64)),
29639            &SearchFacetFilters::default(),
29640        );
29641
29642        let guard = report
29643            .scale_guard
29644            .as_ref()
29645            .expect("broad previews should emit a scale guard");
29646        assert_eq!(guard.level, "high-hit");
29647        assert_eq!(guard.signals.indexed_artifacts, 450);
29648        assert_eq!(guard.signals.raw_symbol_matches, 2);
29649        assert!(
29650            guard
29651                .narrow_commands
29652                .iter()
29653                .any(|command| command.contains("--exact"))
29654        );
29655        assert!(
29656            guard
29657                .narrow_commands
29658                .iter()
29659                .any(|command| command.contains("alpha helper"))
29660        );
29661        assert!(
29662            guard
29663                .narrow_commands
29664                .last()
29665                .unwrap()
29666                .contains("workflow search")
29667        );
29668    }
29669
29670    #[test]
29671    fn explain_budget_report_limits_edges_and_members() {
29672        let symbols = vec![index::StoredSymbol {
29673            name: "alpha_helper".to_string(),
29674            kind: "function".to_string(),
29675            language: "rust".to_string(),
29676            signature: None,
29677            file: "src/lib.rs".to_string(),
29678            line: 10,
29679            end_line: None,
29680            node_kind: None,
29681            start_byte: None,
29682            end_byte: None,
29683            body_start_byte: None,
29684            body_end_byte: None,
29685            parent_module: None,
29686            visibility: None,
29687            tags: None,
29688            tagpath_handle: None,
29689        }];
29690        let callers = vec![
29691            index::StoredEdge {
29692                caller_file: "src/main.rs".to_string(),
29693                caller_name: "main".to_string(),
29694                caller_line: 1,
29695                callee_name: "alpha_helper".to_string(),
29696                call_site_line: 3,
29697                tagpath_handle: None,
29698            },
29699            index::StoredEdge {
29700                caller_file: "src/worker.rs".to_string(),
29701                caller_name: "worker".to_string(),
29702                caller_line: 5,
29703                callee_name: "alpha_helper".to_string(),
29704                call_site_line: 8,
29705                tagpath_handle: None,
29706            },
29707        ];
29708        let community = graph::Community {
29709            id: 1,
29710            members: vec![
29711                graph::CommunityMember::new("alpha_helper"),
29712                graph::CommunityMember::new("main"),
29713                graph::CommunityMember::new("worker"),
29714            ],
29715            modularity_contribution: 0.5,
29716        };
29717
29718        let report = build_explain_budget_report(
29719            "alpha_helper",
29720            Path::new("/repo"),
29721            &symbols,
29722            &callers,
29723            2,
29724            false,
29725            &[],
29726            0,
29727            false,
29728            Some(&community),
29729            ResponseBudget::new(Some(1), Some(24)),
29730        );
29731
29732        assert_eq!(report.definitions.len(), 1);
29733        assert_eq!(report.callers.len(), 1);
29734        assert!(report.truncated);
29735        assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
29736        assert_eq!(
29737            report.definitions[0].tag_alias.as_deref(),
29738            Some("alpha/helper")
29739        );
29740        assert!(report.callers[0].handle.starts_with("ecall-"));
29741        assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
29742    }
29743
29744    #[test]
29745    fn session_review_next_context_budget_limits_lists() {
29746        let report = session_review::SessionReviewReport {
29747            root: "/repo".to_string(),
29748            target: "tasks/software/tsift.md".to_string(),
29749            target_kind: "file".to_string(),
29750            sessions_considered: 1,
29751            sessions_matched: 1,
29752            claude_sessions: 1,
29753            codex_sessions: 0,
29754            agent_doc_logs: 0,
29755            prompt_target_count: 2,
29756            command_groups: 0,
29757            file_groups: 2,
29758            symbol_groups: 1,
29759            failure_groups: 1,
29760            runtime_event_groups: 0,
29761            restart_churn_groups: 0,
29762            closeout_groups: 0,
29763            usage_samples: 1,
29764            prompt_tokens: 120,
29765            cached_input_tokens: 80,
29766            cache_creation_input_tokens: 0,
29767            output_tokens: 40,
29768            reasoning_output_tokens: 0,
29769            total_tokens: 240,
29770            cached_input_ratio: Some(40.0),
29771            largest_turn_total_tokens: 240,
29772            aggregate_cost: session_review::SessionReviewCostSummary {
29773                scope: "bounded_matched_sessions".to_string(),
29774                sessions: 1,
29775                usage_samples: 1,
29776                prompt_tokens: 120,
29777                cached_input_tokens: 80,
29778                cache_creation_input_tokens: 0,
29779                output_tokens: 40,
29780                reasoning_output_tokens: 0,
29781                total_tokens: 240,
29782                cached_input_ratio: Some(40.0),
29783                largest_turn_total_tokens: 240,
29784            },
29785            latest_session_cost: Some(session_review::SessionReviewCostSummary {
29786                scope: "latest_matched_session".to_string(),
29787                sessions: 1,
29788                usage_samples: 1,
29789                prompt_tokens: 120,
29790                cached_input_tokens: 80,
29791                cache_creation_input_tokens: 0,
29792                output_tokens: 40,
29793                reasoning_output_tokens: 0,
29794                total_tokens: 240,
29795                cached_input_ratio: Some(66.67),
29796                largest_turn_total_tokens: 240,
29797            }),
29798            guardrails: vec![
29799                session_cost::SessionCostGuardrail {
29800                    kind: "cache_resend".to_string(),
29801                    severity: "warn".to_string(),
29802                    message: "cached input ratio was high".to_string(),
29803                    guidance: "compact or restart the session".to_string(),
29804                },
29805                session_cost::SessionCostGuardrail {
29806                    kind: "prompt_budget".to_string(),
29807                    severity: "warn".to_string(),
29808                    message: "largest prompt turn reached 999999 tokens".to_string(),
29809                    guidance: "compact the session before another large turn".to_string(),
29810                },
29811                session_cost::SessionCostGuardrail {
29812                    kind: "restart_loop".to_string(),
29813                    severity: "warn".to_string(),
29814                    message: "restart churn detected".to_string(),
29815                    guidance: "restart cleanly".to_string(),
29816                },
29817                session_cost::SessionCostGuardrail {
29818                    kind: "noop_closeout".to_string(),
29819                    severity: "warn".to_string(),
29820                    message: "commit_already_current appeared 8 times".to_string(),
29821                    guidance: "avoid reopening without new edits".to_string(),
29822                },
29823            ],
29824            loop_clusters: vec![],
29825            file_read_diagnostics: vec![],
29826            prompt_targets: vec![
29827                session_review::SessionReviewPromptTarget {
29828                    text: "do one".to_string(),
29829                    occurrences: 1,
29830                },
29831                session_review::SessionReviewPromptTarget {
29832                    text: "do two".to_string(),
29833                    occurrences: 1,
29834                },
29835            ],
29836            commands: vec![],
29837            touched_files: vec![],
29838            touched_symbols: vec![],
29839            failures: vec![],
29840            runtime_events: vec![],
29841            restart_churn: vec![],
29842            closeout: vec![],
29843            largest_turns: vec![],
29844            sessions: vec![session_review::SessionReviewSession {
29845                source: "claude_jsonl".to_string(),
29846                path: "/tmp/session.jsonl".to_string(),
29847                matched_by: vec!["path".to_string()],
29848                modified_unix_secs: None,
29849                prompt_target_count: 2,
29850                command_groups: 0,
29851                file_groups: 2,
29852                symbol_groups: 1,
29853                failure_groups: 1,
29854                runtime_event_groups: 0,
29855                restart_churn_groups: 0,
29856                closeout_groups: 0,
29857                usage_samples: 1,
29858                prompt_tokens: 120,
29859                cached_input_tokens: 80,
29860                cache_creation_input_tokens: 0,
29861                output_tokens: 40,
29862                reasoning_output_tokens: 0,
29863                total_tokens: 240,
29864                largest_turn_total_tokens: 240,
29865            }],
29866            next_context: session_review::SessionReviewNextContext {
29867                target: "tasks/software/tsift.md".to_string(),
29868                active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
29869                last_verification: session_review::SessionReviewVerificationState {
29870                    status: "green".to_string(),
29871                    detail: "cargo test".to_string(),
29872                },
29873                touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
29874                touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
29875                unresolved_failures: vec![session_review::SessionReviewFailure {
29876                    kind: "timeout".to_string(),
29877                    message: "search timed out".to_string(),
29878                    occurrences: 1,
29879                    command: None,
29880                    session_path: None,
29881                }],
29882                next_digest_commands: vec![
29883                    "tsift session-review --next-context tasks/software/tsift.md".to_string(),
29884                    "tsift diff-digest .".to_string(),
29885                    "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
29886                    "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
29887                ],
29888            },
29889            warnings: vec![],
29890        };
29891
29892        let budget_report = build_session_review_next_context_budget_report(
29893            &report,
29894            ResponseBudget::new(Some(1), Some(12)),
29895            None,
29896        );
29897
29898        assert!(budget_report.truncated);
29899        assert_eq!(budget_report.prompt_targets, vec!["do one"]);
29900        assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
29901        assert!(
29902            budget_report.touched_symbol_refs[0]
29903                .handle
29904                .starts_with("ncsym-")
29905        );
29906        assert_eq!(
29907            budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
29908            Some("alpha/helper")
29909        );
29910        assert!(
29911            budget_report.unresolved_failures[0]
29912                .handle
29913                .starts_with("snf-")
29914        );
29915        assert_eq!(budget_report.next_digest_commands.len(), 4);
29916        assert_eq!(
29917            budget_report.next_digest_commands[2],
29918            "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
29919        );
29920        assert_eq!(budget_report.next_token_actions.len(), 1);
29921        assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
29922
29923        let full_action_report = build_session_review_next_context_budget_report(
29924            &report,
29925            ResponseBudget::new(Some(4), Some(120)),
29926            None,
29927        );
29928        assert_eq!(
29929            full_action_report
29930                .next_token_actions
29931                .iter()
29932                .map(|action| action.kind.as_str())
29933                .collect::<Vec<_>>(),
29934            vec![
29935                "prompt_budget",
29936                "cache_resend",
29937                "restart_loop",
29938                "noop_closeout"
29939            ]
29940        );
29941        assert_eq!(
29942            full_action_report.next_token_actions[0]
29943                .compact_command
29944                .as_deref(),
29945            Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
29946        );
29947        assert_eq!(
29948            full_action_report.next_token_actions[0]
29949                .restart_command
29950                .as_deref(),
29951            Some("agent-doc start \"tasks/software/tsift.md\"")
29952        );
29953        assert!(
29954            full_action_report.next_token_actions[0]
29955                .digest_commands
29956                .iter()
29957                .any(|command| command
29958                    == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
29959        );
29960    }
29961
29962    #[test]
29963    fn context_pack_diff_preview_limits_files_and_symbols() {
29964        let report = diff_digest::DiffDigestReport {
29965            root: "/repo".to_string(),
29966            mode: diff_digest::DiffDigestMode::WorkingTree,
29967            revision: None,
29968            files_changed: 2,
29969            files_with_current_summaries: 1,
29970            symbols_touched: 3,
29971            call_edges_added: 1,
29972            call_edges_removed: 0,
29973            files: vec![
29974                diff_digest::DiffDigestFile {
29975                    path: "src/lib.rs".to_string(),
29976                    status: diff_digest::DiffDigestFileStatus::Modified,
29977                    touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
29978                    summary_state: diff_digest::DiffDigestSummaryState::Current,
29979                    current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
29980                        symbol: "alpha_helper".to_string(),
29981                        summary: "alpha helper handles the main alpha workflow".to_string(),
29982                    }],
29983                    added_call_edges: vec!["alpha->beta".to_string()],
29984                    removed_call_edges: vec![],
29985                    warnings: vec!["stale parse".to_string()],
29986                },
29987                diff_digest::DiffDigestFile {
29988                    path: "src/main.rs".to_string(),
29989                    status: diff_digest::DiffDigestFileStatus::Added,
29990                    touched_symbols: vec!["main".to_string()],
29991                    summary_state: diff_digest::DiffDigestSummaryState::Missing,
29992                    current_summaries: vec![],
29993                    added_call_edges: vec![],
29994                    removed_call_edges: vec![],
29995                    warnings: vec![],
29996                },
29997            ],
29998        };
29999
30000        let preview =
30001            build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
30002
30003        assert!(preview.truncated);
30004        assert_eq!(preview.files.len(), 1);
30005        assert_eq!(preview.files[0].path, "src/lib.rs");
30006        assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
30007        assert!(
30008            preview.files[0].touched_symbol_refs[0]
30009                .handle
30010                .starts_with("cdsym-")
30011        );
30012        assert_eq!(
30013            preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
30014            Some("alpha/he...")
30015        );
30016        assert!(
30017            preview.files[0].summary_refs[0]
30018                .handle
30019                .starts_with("cdsum-")
30020        );
30021        assert_eq!(
30022            preview.files[0].summary_refs[0].tag_alias.as_deref(),
30023            Some("alpha/he...")
30024        );
30025        assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
30026        assert_eq!(
30027            preview.files[0].summary_refs[0].expand,
30028            "tsift summarize --file \"src/lib.rs\""
30029        );
30030        assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
30031    }
30032
30033    #[test]
30034    fn context_pack_status_reminders_include_stale_index_state() {
30035        let dir = setup_graph_index();
30036        std::thread::sleep(std::time::Duration::from_millis(50));
30037        std::fs::write(
30038            dir.path().join("main.rs"),
30039            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
30040        )
30041        .unwrap();
30042
30043        let reminders = context_pack_status_reminders(dir.path());
30044
30045        assert_eq!(reminders.len(), 1);
30046        assert!(reminders[0].contains("index stale"));
30047        assert!(reminders[0].contains("tsift index ."));
30048    }
30049
30050    // #gdbgatecold regression-lock: the trusted context-pack pipeline must
30051    // share its index-inspection across `prepare_agent_doc_index_gate` and
30052    // `context_pack_status_reminders` (both call `IndexDb::inspect_read_only`
30053    // on the same `(root, .tsift/index.db)` key). With the scope guard
30054    // active in `build_context_pack_report_with_profile`, the second call
30055    // hits the cache, so we should record one miss and at least one hit.
30056    #[test]
30057    fn build_context_pack_reuses_inspect_within_scope() {
30058        let dir = setup_graph_index();
30059        init_git_repo(dir.path());
30060        let _guard = index::InspectScopeGuard::new();
30061        let _ = build_context_pack_report(
30062            dir.path(),
30063            None,
30064            None,
30065            None,
30066            ResponseBudget::new(Some(2), Some(96)),
30067        )
30068        .unwrap();
30069        let (hits, misses) = index::inspect_scope_stats();
30070        assert!(
30071            hits >= 1,
30072            "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
30073        );
30074        assert!(
30075            misses >= 1,
30076            "expected at least one initial inspect miss (hits={hits}, misses={misses})"
30077        );
30078    }
30079
30080    // #gdbgatecold scope-isolation: outside of any scope, every call to
30081    // `IndexDb::inspect_read_only` must hit the disk fresh. This locks in
30082    // the contract that the search/status fast-paths never reuse a cached
30083    // inspection across consecutive top-level calls.
30084    #[test]
30085    fn inspect_read_only_outside_scope_does_not_cache() {
30086        let dir = setup_graph_index();
30087        let db_path = dir.path().join(".tsift/index.db");
30088        let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30089        let (hits, misses) = index::inspect_scope_stats();
30090        assert_eq!(
30091            (hits, misses),
30092            (0, 0),
30093            "no scope guard => no hits/misses recorded"
30094        );
30095        let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30096        let (hits, _) = index::inspect_scope_stats();
30097        assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
30098    }
30099
30100    #[test]
30101    fn context_pack_refreshes_stale_index_before_handoff() {
30102        let dir = setup_graph_index();
30103        init_git_repo(dir.path());
30104        std::thread::sleep(std::time::Duration::from_millis(50));
30105        std::fs::write(
30106            dir.path().join("main.rs"),
30107            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
30108        )
30109        .unwrap();
30110
30111        let report = build_context_pack_report(
30112            dir.path(),
30113            None,
30114            None,
30115            None,
30116            ResponseBudget::new(Some(2), Some(96)),
30117        )
30118        .unwrap();
30119
30120        assert!(
30121            report
30122                .status_reminders
30123                .iter()
30124                .any(|reminder| reminder.contains("index refreshed")
30125                    && reminder.contains("context-pack handoff")),
30126            "expected context-pack refresh diagnostic, got {:?}",
30127            report.status_reminders
30128        );
30129        assert!(
30130            !report
30131                .status_reminders
30132                .iter()
30133                .any(|reminder| reminder.contains("index stale")),
30134            "stale reminder should be gone after refresh: {:?}",
30135            report.status_reminders
30136        );
30137
30138        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
30139        let summary = db.compute_changes(dir.path()).unwrap();
30140        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
30141    }
30142
30143    #[test]
30144    fn context_pack_materializes_source_handles_into_graph_store() {
30145        let dir = tempfile::tempdir().unwrap();
30146        let packet = ExplorationPacket {
30147            budget: exploration_budget_for_counts(2, 1),
30148            relationship_map: vec![ExplorationRelation {
30149                from: "file:main.rs".to_string(),
30150                relation: "touches_symbol".to_string(),
30151                to: "symbol:helper".to_string(),
30152                label: Some("modified diff".to_string()),
30153            }],
30154            source_windows: vec![ExplorationSourceWindow {
30155                handle: "xwin-test".to_string(),
30156                file: "main.rs".to_string(),
30157                start: 1,
30158                end: 32,
30159                reason: "changed file".to_string(),
30160                expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
30161            }],
30162            worker_context: vec![ExplorationWorkerContext {
30163                handle: "xwrk-test".to_string(),
30164                target: "tasks/software/tsift.md".to_string(),
30165                summary: "do #kgnv".to_string(),
30166                expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
30167                    .to_string(),
30168            }],
30169            no_reread_guidance: "use windows".to_string(),
30170        };
30171
30172        let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
30173        assert_eq!(packet.source_windows[0].handle, "xwin-test");
30174
30175        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
30176        let source_handles = store.nodes_by_kind("source_handle").unwrap();
30177        assert_eq!(source_handles.len(), 1);
30178        assert_eq!(
30179            source_handles[0].properties.get("file"),
30180            Some(&"main.rs".to_string())
30181        );
30182        assert_eq!(
30183            store
30184                .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
30185                .unwrap()
30186                .len(),
30187            1
30188        );
30189        let worker_context = store.nodes_by_kind("worker_context").unwrap();
30190        assert_eq!(worker_context.len(), 1);
30191        assert_eq!(
30192            store
30193                .outgoing_edges("xwrk-test", Some("scopes_source"))
30194                .unwrap()
30195                .len(),
30196            1
30197        );
30198    }
30199
30200    #[test]
30201    fn context_pack_records_graph_orchestration_observability() {
30202        let dir = setup_traversal_project();
30203        init_git_repo(dir.path());
30204        let session = dir.path().join("tasks/software/tsift.md");
30205        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
30206
30207        let report = build_context_pack_report(
30208            &session,
30209            None,
30210            None,
30211            None,
30212            ResponseBudget::new(Some(4), Some(160)),
30213        )
30214        .unwrap();
30215
30216        assert_eq!(
30217            report.graph_orchestration.contract_version,
30218            CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
30219        );
30220        assert_eq!(
30221            report
30222                .graph_orchestration
30223                .projection_freshness
30224                .status
30225                .as_str(),
30226            "current"
30227        );
30228        assert!(!report.graph_orchestration.projection_hashes.is_empty());
30229        assert_eq!(report.graph_orchestration.readiness.status, "blocked");
30230        assert_eq!(
30231            report.graph_orchestration.readiness.reason,
30232            "summary_cache_empty"
30233        );
30234        assert!(report.graph_orchestration.readiness.fail_closed);
30235        assert!(
30236            report
30237                .graph_orchestration
30238                .readiness
30239                .next_commands
30240                .iter()
30241                .any(|command| command == "tsift summarize --extract ."),
30242            "{:?}",
30243            report.graph_orchestration.readiness.next_commands
30244        );
30245        assert!(
30246            report
30247                .graph_orchestration
30248                .evidence_packet_ids
30249                .iter()
30250                .all(|id| !id.starts_with("gevd-")),
30251            "evidence packet ids should be empty when readiness is blocked: {:?}",
30252            report.graph_orchestration.evidence_packet_ids
30253        );
30254        assert!(
30255            report
30256                .graph_orchestration
30257                .conflict_matrix_decisions
30258                .iter()
30259                .any(|decision| decision.contains("readiness blocked")),
30260            "conflict-matrix decisions should reference readiness block: {:?}",
30261            report.graph_orchestration.conflict_matrix_decisions
30262        );
30263        assert!(
30264            !report
30265                .graph_orchestration
30266                .follow_up_commands
30267                .iter()
30268                .any(|command| command.contains("conflict-matrix")),
30269            "conflict-matrix command should not appear when readiness is blocked: {:?}",
30270            report.graph_orchestration.follow_up_commands
30271        );
30272        assert!(
30273            report
30274                .graph_orchestration
30275                .follow_up_commands
30276                .iter()
30277                .any(|command| command == "tsift summarize --extract ."),
30278            "{:?}",
30279            report.graph_orchestration.follow_up_commands
30280        );
30281        assert!(
30282            !report
30283                .graph_orchestration
30284                .worker_ownership_blocks
30285                .is_empty()
30286        );
30287    }
30288
30289    #[test]
30290    fn convex_sync_report_chunks_upserts_and_tombstones() {
30291        let dir = setup_traversal_project();
30292        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
30293        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
30294        let mut snapshot = projection.to_convex_rows();
30295        snapshot.nodes.push(ConvexNodeRow {
30296            external_id: "stale-node".to_string(),
30297            kind: "backlog".to_string(),
30298            label: "stale".to_string(),
30299            properties: BTreeMap::new(),
30300            provenance: Vec::new(),
30301            freshness: None,
30302        });
30303        snapshot.edges.clear();
30304        snapshot.edges.push(ConvexEdgeRow {
30305            edge_key: "stale-edge".to_string(),
30306            from_external_id: "stale-node".to_string(),
30307            to_external_id: "stale-node".to_string(),
30308            kind: "mentions".to_string(),
30309            properties: BTreeMap::new(),
30310            provenance: Vec::new(),
30311            freshness: None,
30312        });
30313        let snapshot_path = dir.path().join("convex-snapshot.json");
30314        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30315
30316        let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
30317
30318        assert_eq!(report.freshness.status, "stale");
30319        assert!(report.freshness.fail_closed);
30320        assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
30321        assert!(
30322            report.edge_upserts.len() > 1,
30323            "snapshot without edges should upsert local edges"
30324        );
30325        assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
30326        assert_eq!(
30327            report.chunks.first().map(|chunk| chunk.operation.as_str()),
30328            Some("delete_edges"),
30329            "edge tombstones should be planned before node tombstones"
30330        );
30331        assert!(
30332            report
30333                .chunks
30334                .iter()
30335                .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
30336            "expected chunked edge upserts, got {:?}",
30337            report.chunks
30338        );
30339    }
30340
30341    #[test]
30342    fn convex_snapshot_validation_fails_closed_when_stale() {
30343        let dir = setup_traversal_project();
30344        build_traversal_graph(dir.path(), dir.path(), None).unwrap();
30345        let snapshot = ConvexProjectionRows::default();
30346        let snapshot_path = dir.path().join("empty-convex-snapshot.json");
30347        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30348
30349        let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
30350        assert!(
30351            err.to_string()
30352                .contains("Convex graph projection is not current"),
30353            "{err}"
30354        );
30355    }
30356
30357    #[test]
30358    fn convex_sync_report_marks_live_apply_mode_without_network() {
30359        let dir = setup_traversal_project();
30360        let report =
30361            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30362
30363        assert!(!report.dry_run);
30364        assert!(
30365            !report
30366                .diagnostics
30367                .iter()
30368                .any(|diagnostic| diagnostic.contains("dry-run only")),
30369            "apply-mode report should not claim dry-run diagnostics"
30370        );
30371        assert!(
30372            report
30373                .chunks
30374                .iter()
30375                .any(|chunk| chunk.operation == "upsert_nodes"),
30376            "live apply mode should still expose chunked idempotent operations"
30377        );
30378    }
30379
30380    #[test]
30381    fn convex_sync_apply_round_trips_with_http_backend() {
30382        use std::net::TcpListener;
30383        use std::sync::{Arc, Mutex};
30384
30385        let dir = setup_traversal_project();
30386        let report =
30387            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30388        let expected_chunks = report.chunks.len();
30389        assert!(expected_chunks > 0);
30390
30391        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
30392        let endpoint = format!("http://{}", listener.local_addr().unwrap());
30393        let operations = Arc::new(Mutex::new(Vec::<String>::new()));
30394        let server_operations = Arc::clone(&operations);
30395        let server = std::thread::spawn(move || {
30396            for _ in 0..expected_chunks {
30397                let (mut stream, _) = listener.accept().unwrap();
30398                let mut reader = BufReader::new(stream.try_clone().unwrap());
30399                let mut request_line = String::new();
30400                reader.read_line(&mut request_line).unwrap();
30401                assert!(request_line.starts_with("POST "));
30402
30403                let mut content_length = 0usize;
30404                loop {
30405                    let mut line = String::new();
30406                    reader.read_line(&mut line).unwrap();
30407                    if line == "\r\n" {
30408                        break;
30409                    }
30410                    if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
30411                        content_length = value.trim().parse().unwrap();
30412                    }
30413                }
30414
30415                let mut body = vec![0u8; content_length];
30416                reader.read_exact(&mut body).unwrap();
30417                let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
30418                server_operations
30419                    .lock()
30420                    .unwrap()
30421                    .push(request["operation"].as_str().unwrap().to_string());
30422
30423                let response = br#"{"status":"ok","message":"accepted"}"#;
30424                write!(
30425                    stream,
30426                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
30427                    response.len()
30428                )
30429                .unwrap();
30430                stream.write_all(response).unwrap();
30431            }
30432        });
30433
30434        cmd_convex_sync(
30435            ConvexSyncOptions {
30436                path: dir.path(),
30437                scope: None,
30438                snapshot: None,
30439                chunk_size: 100,
30440                remote_snapshot: false,
30441                apply: true,
30442                endpoint: Some(&endpoint),
30443                auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
30444            },
30445            OutputFormat {
30446                json_output: false,
30447                compact: true,
30448                pretty: false,
30449                terse: false,
30450                ultra_terse: false,
30451                schema: false,
30452                envelope: false,
30453            },
30454        )
30455        .unwrap();
30456        server.join().unwrap();
30457
30458        let operations = operations.lock().unwrap().clone();
30459        assert!(operations.contains(&"upsert_nodes".to_string()));
30460        assert!(operations.contains(&"upsert_edges".to_string()));
30461    }
30462
30463    #[test]
30464    fn context_pack_diff_preview_attaches_tag_ontology_refs() {
30465        let root = tempfile::tempdir().unwrap();
30466        fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
30467        fs::write(
30468            root.path().join(".naming/tags/alpha.md"),
30469            "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
30470        )
30471        .unwrap();
30472        let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
30473        let report = diff_digest::DiffDigestReport {
30474            root: root.path().display().to_string(),
30475            mode: diff_digest::DiffDigestMode::WorkingTree,
30476            revision: None,
30477            files_changed: 1,
30478            files_with_current_summaries: 1,
30479            symbols_touched: 1,
30480            call_edges_added: 0,
30481            call_edges_removed: 0,
30482            files: vec![diff_digest::DiffDigestFile {
30483                path: "src/lib.rs".to_string(),
30484                status: diff_digest::DiffDigestFileStatus::Modified,
30485                touched_symbols: vec!["alpha_helper".to_string()],
30486                summary_state: diff_digest::DiffDigestSummaryState::Current,
30487                current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30488                    symbol: "alpha_helper".to_string(),
30489                    summary: "alpha helper summary".to_string(),
30490                }],
30491                added_call_edges: vec![],
30492                removed_call_edges: vec![],
30493                warnings: vec![],
30494            }],
30495        };
30496
30497        let preview = build_context_pack_diff_preview(
30498            &report,
30499            ResponseBudget::new(Some(1), Some(80)),
30500            Some(&ontology),
30501        );
30502
30503        let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
30504        assert!(symbol_ref.handle.starts_with("tont-"));
30505        assert_eq!(symbol_ref.tag, "alpha");
30506        assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
30507        assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
30508        assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
30509        assert_eq!(
30510            preview.files[0].summary_refs[0].ontology_refs[0].path,
30511            ".naming/tags/alpha.md"
30512        );
30513    }
30514
30515    #[test]
30516    fn context_pack_test_preview_limits_failure_groups() {
30517        let report = test_digest::TestDigestReport {
30518            root: "/repo".to_string(),
30519            runner: "cargo".to_string(),
30520            failures: 2,
30521            grouped_failures: 2,
30522            counts: test_digest::TestDigestCounts {
30523                passed: Some(8),
30524                failed: Some(2),
30525                skipped: Some(1),
30526            },
30527            failure_groups: vec![
30528                test_digest::TestDigestFailure {
30529                    tests: vec!["suite::alpha_failure".to_string()],
30530                    message: "assertion failed".to_string(),
30531                    path: Some("src/lib.rs".to_string()),
30532                    line: Some(42),
30533                    column: None,
30534                    occurrences: 1,
30535                    summary_state: test_digest::TestDigestSummaryState::Current,
30536                    current_summaries: vec![test_digest::TestDigestSummarySnippet {
30537                        symbol: "alpha_failure".to_string(),
30538                        summary: "failure summary for alpha test".to_string(),
30539                    }],
30540                },
30541                test_digest::TestDigestFailure {
30542                    tests: vec!["suite::beta_failure".to_string()],
30543                    message: "panic".to_string(),
30544                    path: Some("src/main.rs".to_string()),
30545                    line: Some(7),
30546                    column: None,
30547                    occurrences: 1,
30548                    summary_state: test_digest::TestDigestSummaryState::Missing,
30549                    current_summaries: vec![],
30550                },
30551            ],
30552            warnings: vec!["warning text".to_string()],
30553        };
30554
30555        let preview =
30556            build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30557
30558        assert!(preview.truncated);
30559        assert_eq!(preview.failure_groups.len(), 1);
30560        assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
30561        assert_eq!(preview.failure_groups[0].message, "assertion f...");
30562        assert!(
30563            preview.failure_groups[0].summary_refs[0]
30564                .handle
30565                .starts_with("ctsum-")
30566        );
30567        assert_eq!(
30568            preview.failure_groups[0].summary_refs[0].expand,
30569            "tsift summarize --file \"src/lib.rs\""
30570        );
30571        assert_eq!(preview.warnings, vec!["warning text"]);
30572    }
30573
30574    #[test]
30575    fn context_pack_log_preview_limits_signals_and_refs() {
30576        let report = log_digest::LogDigestReport {
30577            root: "/repo".to_string(),
30578            total_lines: 12,
30579            non_empty_lines: 10,
30580            signal_groups: 2,
30581            repeated_line_groups: 2,
30582            repeated_line_occurrences: 3,
30583            file_ref_groups: 2,
30584            symbol_ref_groups: 2,
30585            stack_groups: 1,
30586            signals: vec![
30587                log_digest::LogDigestSignal {
30588                    severity: "error".to_string(),
30589                    message: "src/lib.rs:42 boom".to_string(),
30590                    path: Some("src/lib.rs".to_string()),
30591                    line: Some(42),
30592                    column: None,
30593                    occurrences: 2,
30594                    summary_state: log_digest::LogDigestSummaryState::Current,
30595                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
30596                        symbol: "alpha_helper".to_string(),
30597                        summary: "alpha helper cached log summary".to_string(),
30598                    }],
30599                },
30600                log_digest::LogDigestSignal {
30601                    severity: "warn".to_string(),
30602                    message: "slow path".to_string(),
30603                    path: None,
30604                    line: None,
30605                    column: None,
30606                    occurrences: 1,
30607                    summary_state: log_digest::LogDigestSummaryState::Unavailable,
30608                    current_summaries: vec![],
30609                },
30610            ],
30611            repeated_lines: vec![
30612                log_digest::LogDigestRepeatedLine {
30613                    line: "retrying work item alpha".to_string(),
30614                    occurrences: 3,
30615                },
30616                log_digest::LogDigestRepeatedLine {
30617                    line: "retrying work item beta".to_string(),
30618                    occurrences: 2,
30619                },
30620            ],
30621            file_refs: vec![
30622                log_digest::LogDigestFileRef {
30623                    path: "src/lib.rs".to_string(),
30624                    line: Some(42),
30625                    column: None,
30626                    occurrences: 2,
30627                    summary_state: log_digest::LogDigestSummaryState::Current,
30628                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
30629                        symbol: "alpha_helper".to_string(),
30630                        summary: "alpha helper cached file summary".to_string(),
30631                    }],
30632                },
30633                log_digest::LogDigestFileRef {
30634                    path: "src/main.rs".to_string(),
30635                    line: Some(7),
30636                    column: None,
30637                    occurrences: 1,
30638                    summary_state: log_digest::LogDigestSummaryState::Missing,
30639                    current_summaries: vec![],
30640                },
30641            ],
30642            symbol_refs: vec![
30643                log_digest::LogDigestSymbolRef {
30644                    symbol: "alpha_helper".to_string(),
30645                    occurrences: 2,
30646                    summary_state: log_digest::LogDigestSummaryState::Current,
30647                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
30648                        symbol: "alpha_helper".to_string(),
30649                        summary: "alpha helper cached symbol summary".to_string(),
30650                    }],
30651                },
30652                log_digest::LogDigestSymbolRef {
30653                    symbol: "beta_helper".to_string(),
30654                    occurrences: 1,
30655                    summary_state: log_digest::LogDigestSummaryState::Missing,
30656                    current_summaries: vec![],
30657                },
30658            ],
30659            stack_traces: vec![log_digest::LogDigestStackGroup {
30660                frames: vec!["frame one".to_string()],
30661                occurrences: 1,
30662            }],
30663            warnings: vec!["warning text".to_string()],
30664        };
30665
30666        let preview =
30667            build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30668
30669        assert!(preview.truncated);
30670        assert_eq!(preview.signals.len(), 1);
30671        assert_eq!(preview.signals[0].message, "src/lib.rs:...");
30672        assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
30673        assert_eq!(preview.file_refs.len(), 1);
30674        assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
30675        assert!(
30676            preview.signals[0].summary_refs[0]
30677                .handle
30678                .starts_with("clsum-")
30679        );
30680        assert!(
30681            preview.file_refs[0].summary_refs[0]
30682                .handle
30683                .starts_with("clfsum-")
30684        );
30685        assert!(
30686            preview.symbol_refs[0].summary_refs[0]
30687                .handle
30688                .starts_with("clssum-")
30689        );
30690        assert_eq!(
30691            preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
30692            Some("alpha/helper")
30693        );
30694        assert_eq!(
30695            preview.symbol_refs[0].summary_refs[0].expand,
30696            "tsift summarize \"alpha_helper\""
30697        );
30698        assert_eq!(preview.warnings, vec!["warning text"]);
30699    }
30700
30701    #[test]
30702    fn cli_search_rejects_exact_with_strategy_flag() {
30703        let cli = try_parse_cli([
30704            "tsift",
30705            "search",
30706            "test",
30707            "--exact",
30708            "--strategy",
30709            "lexical",
30710        ]);
30711        assert!(cli.is_err());
30712    }
30713
30714    #[test]
30715    fn cli_search_autoindexes_by_default() {
30716        let cli = parse_cli(["tsift", "search", "test"]);
30717        match cli.command {
30718            Some(Commands::Search {
30719                autoindex,
30720                no_autoindex,
30721                ..
30722            }) => {
30723                assert!(!autoindex);
30724                assert!(!no_autoindex);
30725                assert!(autoindex || !no_autoindex);
30726            }
30727            _ => panic!("expected Search command"),
30728        }
30729    }
30730
30731    #[test]
30732    fn cli_search_accepts_no_autoindex_flag() {
30733        let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
30734        match cli.command {
30735            Some(Commands::Search {
30736                autoindex,
30737                no_autoindex,
30738                ..
30739            }) => {
30740                assert!(!autoindex);
30741                assert!(no_autoindex);
30742            }
30743            _ => panic!("expected Search command"),
30744        }
30745    }
30746
30747    #[test]
30748    fn cli_search_rejects_conflicting_autoindex_flags() {
30749        let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
30750        assert!(cli.is_err());
30751    }
30752
30753    // --- relativize paths ---
30754
30755    #[test]
30756    fn cli_accepts_global_absolute_flag() {
30757        let cli = parse_cli(["tsift", "--absolute", "status"]);
30758        assert!(cli.absolute);
30759        assert!(matches!(cli.command, Some(Commands::Status { .. })));
30760    }
30761
30762    #[test]
30763    fn cli_accepts_global_tabular_flag() {
30764        let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
30765        assert!(cli.tabular);
30766        assert!(matches!(cli.command, Some(Commands::Search { .. })));
30767    }
30768
30769    #[test]
30770    fn cli_tabular_with_graph() {
30771        let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
30772        assert!(cli.tabular);
30773        assert!(matches!(cli.command, Some(Commands::Graph { .. })));
30774    }
30775
30776    #[test]
30777    fn cli_tabular_with_communities() {
30778        let cli = parse_cli(["tsift", "--tabular", "communities"]);
30779        assert!(cli.tabular);
30780        assert!(matches!(cli.command, Some(Commands::Communities { .. })));
30781    }
30782
30783    #[test]
30784    fn cli_tabular_with_explain() {
30785        let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
30786        assert!(cli.tabular);
30787        assert!(matches!(cli.command, Some(Commands::Explain { .. })));
30788    }
30789
30790    #[test]
30791    fn cli_traverse_accepts_path_target_and_html_format() {
30792        let cli = parse_cli([
30793            "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
30794        ]);
30795        match cli.command {
30796            Some(Commands::Traverse {
30797                node,
30798                to,
30799                path,
30800                format,
30801                ..
30802            }) => {
30803                assert_eq!(node.as_deref(), Some("#kgnv"));
30804                assert_eq!(to.as_deref(), Some("main"));
30805                assert_eq!(path, PathBuf::from("."));
30806                assert_eq!(format, TraverseFormat::Html);
30807            }
30808            _ => panic!("expected Traverse command"),
30809        }
30810    }
30811
30812    #[test]
30813    fn cli_parses_semantic_related_command() {
30814        let cli = parse_cli([
30815            "tsift",
30816            "semantic",
30817            "graph navigation",
30818            "--path",
30819            ".",
30820            "--kind",
30821            "all",
30822            "--limit",
30823            "3",
30824            "--json",
30825        ]);
30826        match cli.command {
30827            Some(Commands::Semantic {
30828                query,
30829                path,
30830                kind,
30831                limit,
30832                json,
30833                ..
30834            }) => {
30835                assert_eq!(query, "graph navigation");
30836                assert_eq!(path, PathBuf::from("."));
30837                assert_eq!(kind, SemanticRelatedKind::All);
30838                assert_eq!(limit, 3);
30839                assert!(json);
30840            }
30841            _ => panic!("expected Semantic command"),
30842        }
30843    }
30844
30845    #[test]
30846    fn cli_parses_convex_sync_command() {
30847        let cli = parse_cli([
30848            "tsift",
30849            "convex-sync",
30850            ".",
30851            "--snapshot",
30852            "rows.json",
30853            "--chunk-size",
30854            "25",
30855            "--json",
30856        ]);
30857        match cli.command {
30858            Some(Commands::ConvexSync {
30859                path,
30860                snapshot,
30861                chunk_size,
30862                json,
30863                ..
30864            }) => {
30865                assert_eq!(path, PathBuf::from("."));
30866                assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
30867                assert_eq!(chunk_size, 25);
30868                assert!(json);
30869            }
30870            _ => panic!("expected ConvexSync command"),
30871        }
30872    }
30873
30874    #[test]
30875    fn cli_parses_convex_sync_live_flags() {
30876        let cli = parse_cli([
30877            "tsift",
30878            "convex-sync",
30879            ".",
30880            "--remote-snapshot",
30881            "--apply",
30882            "--endpoint",
30883            "https://example.test/convex-graph",
30884            "--auth-token-env",
30885            "TSIFT_TEST_TOKEN",
30886        ]);
30887        match cli.command {
30888            Some(Commands::ConvexSync {
30889                remote_snapshot,
30890                apply,
30891                endpoint,
30892                auth_token_env,
30893                ..
30894            }) => {
30895                assert!(remote_snapshot);
30896                assert!(apply);
30897                assert_eq!(
30898                    endpoint.as_deref(),
30899                    Some("https://example.test/convex-graph")
30900                );
30901                assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
30902            }
30903            _ => panic!("expected ConvexSync command"),
30904        }
30905    }
30906
30907    #[test]
30908    fn cli_parses_graph_db_query() {
30909        let cli = parse_cli([
30910            "tsift",
30911            "graph-db",
30912            "--backend",
30913            "convex-snapshot",
30914            "--convex-snapshot",
30915            "rows.json",
30916            "--json",
30917            "neighborhood",
30918            "gbak-kgnv",
30919            "--depth",
30920            "2",
30921            "--edge-kind",
30922            "mentions",
30923            "--property",
30924            "path=tasks/software/tsift.md",
30925            "--cursor",
30926            "gbak-old",
30927            "--limit",
30928            "10",
30929        ]);
30930        match cli.command {
30931            Some(Commands::GraphDb {
30932                backend,
30933                convex_snapshot,
30934                json,
30935                query,
30936                ..
30937            }) => {
30938                assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
30939                assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
30940                assert!(json);
30941                match query {
30942                    GraphDbQuery::Neighborhood {
30943                        id,
30944                        depth,
30945                        edge_kind,
30946                        cursor,
30947                        limit,
30948                        property_filters,
30949                    } => {
30950                        assert_eq!(id, "gbak-kgnv");
30951                        assert_eq!(depth, 2);
30952                        assert_eq!(edge_kind.as_deref(), Some("mentions"));
30953                        assert_eq!(cursor.as_deref(), Some("gbak-old"));
30954                        assert_eq!(limit, Some(10));
30955                        assert_eq!(
30956                            property_filters,
30957                            vec!["path=tasks/software/tsift.md".to_string()]
30958                        );
30959                    }
30960                    _ => panic!("expected graph-db neighborhood query"),
30961                }
30962            }
30963            _ => panic!("expected GraphDb command"),
30964        }
30965    }
30966
30967    #[test]
30968    fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
30969        let cli = parse_cli([
30970            "tsift",
30971            "graph-db",
30972            "--json",
30973            "backend-eval",
30974            "--candidate",
30975            "surrealdb",
30976            "--target",
30977            "gval",
30978            "--full-projection",
30979        ]);
30980        match cli.command {
30981            Some(Commands::GraphDb { json, query, .. }) => {
30982                assert!(json);
30983                match query {
30984                    GraphDbQuery::BackendEval {
30985                        candidates,
30986                        targets,
30987                        full_projection,
30988                    } => {
30989                        assert_eq!(candidates, vec!["surrealdb".to_string()]);
30990                        assert_eq!(targets, vec!["gval".to_string()]);
30991                        assert!(full_projection);
30992                    }
30993                    _ => panic!("expected graph-db backend-eval query"),
30994                }
30995            }
30996            _ => panic!("expected GraphDb command"),
30997        }
30998    }
30999
31000    #[test]
31001    fn cli_parses_graph_db_tokensave_backend() {
31002        let cli = parse_cli([
31003            "tsift",
31004            "graph-db",
31005            "--backend",
31006            "tokensave",
31007            "--json",
31008            "node",
31009            "fn:main",
31010        ]);
31011        match cli.command {
31012            Some(Commands::GraphDb {
31013                backend,
31014                json,
31015                query,
31016                ..
31017            }) => {
31018                assert_eq!(backend, GraphDbBackend::Tokensave);
31019                assert!(json);
31020                match query {
31021                    GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
31022                    _ => panic!("expected graph-db node query"),
31023                }
31024            }
31025            _ => panic!("expected GraphDb command"),
31026        }
31027    }
31028
31029    #[test]
31030    fn cli_parses_analyze_command() {
31031        let cli = parse_cli([
31032            "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
31033            "--limit", "7", "--json",
31034        ]);
31035        match cli.command {
31036            Some(Commands::Analyze {
31037                path,
31038                scope,
31039                entry_points,
31040                limit,
31041                json,
31042            }) => {
31043                assert_eq!(path, PathBuf::from("."));
31044                assert_eq!(scope.as_deref(), Some("core"));
31045                assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
31046                assert_eq!(limit, 7);
31047                assert!(json);
31048            }
31049            _ => panic!("expected Analyze command"),
31050        }
31051    }
31052
31053    #[test]
31054    fn cli_parses_graph_db_related_query() {
31055        let cli = parse_cli([
31056            "tsift",
31057            "graph-db",
31058            "--json",
31059            "related",
31060            "voice avatar memory retrieval",
31061            "--kind",
31062            "all",
31063            "--depth",
31064            "3",
31065            "--seed-limit",
31066            "4",
31067            "--limit",
31068            "12",
31069        ]);
31070        match cli.command {
31071            Some(Commands::GraphDb { json, query, .. }) => {
31072                assert!(json);
31073                match query {
31074                    GraphDbQuery::Related {
31075                        query,
31076                        kind,
31077                        depth,
31078                        seed_limit,
31079                        limit,
31080                    } => {
31081                        assert_eq!(query, "voice avatar memory retrieval");
31082                        assert_eq!(kind, SemanticRelatedKind::All);
31083                        assert_eq!(depth, 3);
31084                        assert_eq!(seed_limit, 4);
31085                        assert_eq!(limit, 12);
31086                    }
31087                    _ => panic!("expected graph-db related query"),
31088                }
31089            }
31090            _ => panic!("expected GraphDb command"),
31091        }
31092    }
31093
31094    #[test]
31095    fn cli_parses_graph_db_compact_query() {
31096        let cli = parse_cli([
31097            "tsift",
31098            "graph-db",
31099            "--path",
31100            ".",
31101            "compact",
31102            "--apply",
31103            "--prune-tombstones",
31104            "--confirmed-convex-reconciled",
31105        ]);
31106        match cli.command {
31107            Some(Commands::GraphDb { query, .. }) => match query {
31108                GraphDbQuery::Compact {
31109                    apply,
31110                    prune_tombstones,
31111                    confirmed_convex_reconciled,
31112                } => {
31113                    assert!(apply);
31114                    assert!(prune_tombstones);
31115                    assert!(confirmed_convex_reconciled);
31116                }
31117                _ => panic!("expected graph-db compact query"),
31118            },
31119            _ => panic!("expected GraphDb command"),
31120        }
31121    }
31122
31123    #[test]
31124    fn cli_parses_impact_command() {
31125        let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
31126        match cli.command {
31127            Some(Commands::Impact {
31128                path,
31129                cached,
31130                limit,
31131                ..
31132            }) => {
31133                assert_eq!(path, PathBuf::from("."));
31134                assert!(cached);
31135                assert_eq!(limit, 5);
31136            }
31137            _ => panic!("expected Impact command"),
31138        }
31139    }
31140
31141    #[test]
31142    fn cli_parses_conflict_matrix_command() {
31143        let cli = parse_cli([
31144            "tsift",
31145            "conflict-matrix",
31146            "--path",
31147            "tasks/software/tsift.md",
31148            "--depth",
31149            "4",
31150            "--limit",
31151            "12",
31152            "--impact-limit",
31153            "6",
31154            "--json",
31155            "pwcm",
31156            "#g6kf",
31157        ]);
31158        match cli.command {
31159            Some(Commands::ConflictMatrix {
31160                targets,
31161                path,
31162                depth,
31163                limit,
31164                impact_limit,
31165                json,
31166                ..
31167            }) => {
31168                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31169                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31170                assert_eq!(depth, 4);
31171                assert_eq!(limit, 12);
31172                assert_eq!(impact_limit, 6);
31173                assert!(json);
31174            }
31175            _ => panic!("expected ConflictMatrix command"),
31176        }
31177    }
31178
31179    #[test]
31180    fn cli_parses_dispatch_trace_command() {
31181        let cli = parse_cli([
31182            "tsift",
31183            "dispatch-trace",
31184            "--path",
31185            "tasks/software/tsift.md",
31186            "--format",
31187            "html",
31188            "--depth",
31189            "4",
31190            "pwcm",
31191            "#g6kf",
31192        ]);
31193        match cli.command {
31194            Some(Commands::DispatchTrace {
31195                targets,
31196                path,
31197                format,
31198                depth,
31199                ..
31200            }) => {
31201                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31202                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31203                assert_eq!(format, DispatchTraceFormat::Html);
31204                assert_eq!(depth, 4);
31205            }
31206            _ => panic!("expected DispatchTrace command"),
31207        }
31208    }
31209
31210    #[test]
31211    fn cli_parses_dependency_dag_command() {
31212        let cli = parse_cli([
31213            "tsift",
31214            "dependency-dag",
31215            "--path",
31216            "tasks/software/tsift.md",
31217            "--depth",
31218            "5",
31219            "--limit",
31220            "20",
31221            "--json",
31222            "alpha",
31223            "#beta",
31224        ]);
31225        match cli.command {
31226            Some(Commands::DependencyDag {
31227                targets,
31228                path,
31229                depth,
31230                limit,
31231                json,
31232                ..
31233            }) => {
31234                assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
31235                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31236                assert_eq!(depth, 5);
31237                assert_eq!(limit, 20);
31238                assert!(json);
31239            }
31240            _ => panic!("expected DependencyDag command"),
31241        }
31242    }
31243
31244    #[test]
31245    fn relativize_strips_root_prefix() {
31246        let root = std::path::Path::new("/home/user/project");
31247        assert_eq!(
31248            relativize("/home/user/project/src/main.rs", root),
31249            "src/main.rs"
31250        );
31251    }
31252
31253    #[test]
31254    fn relativize_leaves_non_matching_path() {
31255        let root = std::path::Path::new("/home/user/project");
31256        assert_eq!(
31257            relativize("/other/path/file.rs", root),
31258            "/other/path/file.rs"
31259        );
31260    }
31261
31262    #[test]
31263    fn relativize_leaves_already_relative() {
31264        let root = std::path::Path::new("/home/user/project");
31265        assert_eq!(relativize("src/main.rs", root), "src/main.rs");
31266    }
31267
31268    #[test]
31269    fn relativize_pathbuf_strips_prefix() {
31270        let root = std::path::Path::new("/home/user/project");
31271        let path = std::path::Path::new("/home/user/project/src/lib.rs");
31272        assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
31273    }
31274
31275    #[test]
31276    fn relativize_edges_strips_caller_file() {
31277        let root = std::path::Path::new("/tmp/proj");
31278        let mut edges = vec![index::StoredEdge {
31279            caller_file: "/tmp/proj/src/main.rs".to_string(),
31280            caller_name: "main".to_string(),
31281            caller_line: 1,
31282            callee_name: "helper".to_string(),
31283            call_site_line: 5,
31284            tagpath_handle: None,
31285        }];
31286        relativize_edges(&mut edges, root);
31287        assert_eq!(edges[0].caller_file, "src/main.rs");
31288    }
31289
31290    #[test]
31291    fn relativize_json_paths_strips_known_keys() {
31292        let root = std::path::Path::new("/tmp/proj");
31293        let mut val = serde_json::json!({
31294            "file": "/tmp/proj/src/main.rs",
31295            "path": "/tmp/proj/test.rs",
31296            "name": "/tmp/proj/not-a-path",
31297            "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
31298        });
31299        relativize_json_paths(&mut val, root);
31300        assert_eq!(val["file"], "src/main.rs");
31301        assert_eq!(val["path"], "test.rs");
31302        assert_eq!(val["name"], "/tmp/proj/not-a-path");
31303        assert_eq!(val["hits"][0]["path"], "nested.rs");
31304    }
31305
31306    // --- limit caps ---
31307
31308    #[test]
31309    fn cli_graph_accepts_limit_flag() {
31310        let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
31311        match cli.command {
31312            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
31313            _ => panic!("expected Graph command"),
31314        }
31315    }
31316
31317    #[test]
31318    fn cli_graph_default_limit_is_20() {
31319        let cli = parse_cli(["tsift", "graph", "main"]);
31320        match cli.command {
31321            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
31322            _ => panic!("expected Graph command"),
31323        }
31324    }
31325
31326    #[test]
31327    fn cli_communities_accepts_limit_flag() {
31328        let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
31329        match cli.command {
31330            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
31331            _ => panic!("expected Communities command"),
31332        }
31333    }
31334
31335    #[test]
31336    fn cli_communities_default_limit_is_10() {
31337        let cli = parse_cli(["tsift", "communities"]);
31338        match cli.command {
31339            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
31340            _ => panic!("expected Communities command"),
31341        }
31342    }
31343
31344    #[test]
31345    fn cli_explain_accepts_limit_flag() {
31346        let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
31347        match cli.command {
31348            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
31349            _ => panic!("expected Explain command"),
31350        }
31351    }
31352
31353    #[test]
31354    fn cli_explain_default_limit_is_15() {
31355        let cli = parse_cli(["tsift", "explain", "main"]);
31356        match cli.command {
31357            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
31358            _ => panic!("expected Explain command"),
31359        }
31360    }
31361
31362    #[test]
31363    fn cli_limit_zero_means_unlimited() {
31364        let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
31365        match cli.command {
31366            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
31367            _ => panic!("expected Graph command"),
31368        }
31369    }
31370
31371    #[test]
31372    fn graph_cmd_limit_runs_ok() {
31373        let dir = setup_graph_index();
31374        let result = cmd_graph(
31375            "main",
31376            dir.path(),
31377            false,
31378            false,
31379            None,
31380            1,
31381            false,
31382            false,
31383            false,
31384            false,
31385            false,
31386            false,
31387            false,
31388            TagpathSearchOpts::default(),
31389        );
31390        assert!(result.is_ok());
31391    }
31392
31393    #[test]
31394    fn graph_cmd_unlimited_runs_ok() {
31395        let dir = setup_graph_index();
31396        let result = cmd_graph(
31397            "main",
31398            dir.path(),
31399            false,
31400            false,
31401            None,
31402            0,
31403            false,
31404            false,
31405            false,
31406            false,
31407            false,
31408            false,
31409            false,
31410            TagpathSearchOpts::default(),
31411        );
31412        assert!(result.is_ok());
31413    }
31414
31415    #[test]
31416    fn graph_cmd_tabular_runs_ok() {
31417        let dir = setup_graph_index();
31418        let result = cmd_graph(
31419            "main",
31420            dir.path(),
31421            false,
31422            false,
31423            None,
31424            20,
31425            false,
31426            false,
31427            false,
31428            false,
31429            false,
31430            true,
31431            false,
31432            TagpathSearchOpts::default(),
31433        );
31434        assert!(result.is_ok());
31435    }
31436
31437    #[test]
31438    fn communities_cmd_tabular_runs_ok() {
31439        let dir = setup_graph_index();
31440        let result = cmd_communities(
31441            dir.path(),
31442            None,
31443            1,
31444            10,
31445            false,
31446            false,
31447            false,
31448            false,
31449            true,
31450            false,
31451            TagpathSearchOpts::default(),
31452        );
31453        assert!(result.is_ok());
31454    }
31455
31456    #[test]
31457    fn explain_cmd_tabular_runs_ok() {
31458        let dir = setup_graph_index();
31459        let result = cmd_explain(
31460            "main",
31461            dir.path(),
31462            None,
31463            15,
31464            false,
31465            false,
31466            false,
31467            false,
31468            false,
31469            true,
31470            false,
31471            false,
31472        );
31473        assert!(result.is_ok());
31474    }
31475
31476    #[test]
31477    fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
31478        // #gdbcacheprove: .agent-doc runtime markdown (snapshots, baselines, archives,
31479        // session docs, runtime logs) must not contribute to the source watermark, or
31480        // every agent-doc cycle would invalidate the graph-db backend-eval cache and
31481        // force a full rebuild on the next run.
31482        let cases = [
31483            ".agent-doc",
31484            ".agent-doc/snapshots/abc.md",
31485            ".agent-doc/baselines/abc.md",
31486            ".agent-doc/archives/2026.md",
31487            ".agent-doc/runtime/run.jsonl",
31488            "src/foo/.agent-doc",
31489            "src/foo/.agent-doc/snapshots/x.md",
31490            "./.agent-doc/snapshots/x.md",
31491        ];
31492        for path in cases {
31493            assert!(
31494                traversal_relative_path_is_generated_artifact(path),
31495                "expected `{path}` to be excluded from source watermark"
31496            );
31497        }
31498        // Real source paths must NOT be excluded.
31499        for path in [
31500            "src/main.rs",
31501            "tests/perf_gate.rs",
31502            "fixtures/x.json",
31503            "agent-doc/src/lib.rs", // sibling dir without the leading dot
31504            "src/.agent-doc-helper.rs",
31505        ] {
31506            assert!(
31507                !traversal_relative_path_is_generated_artifact(path),
31508                "expected `{path}` to be included in source watermark"
31509            );
31510        }
31511    }
31512
31513    #[test]
31514    fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
31515        // #cachelookupshift: the conflict-matrix preparation cache key hashes
31516        // file_state snapshot rows + every markdown file under the root. Any
31517        // .tsift/, target/, or .agent-doc/ path slipping past the filter would
31518        // shift the watermark every run because those directories mutate as a
31519        // side effect of running tsift itself. This test locks the artifact
31520        // filter against regressions for each prefix variant
31521        // (bare, root-anchored, nested, and './' leading).
31522        let cases = [
31523            ".tsift",
31524            ".tsift/index.db",
31525            ".tsift/indexes/foo/index.db",
31526            ".tsift/conflict-matrix-cache/inputs/abc.json",
31527            ".tsift/summaries.db",
31528            "src/foo/.tsift",
31529            "src/foo/.tsift/graph.db",
31530            "./.tsift/index.db",
31531            "target",
31532            "target/debug/build/x",
31533            "target/release/tsift",
31534            "src/foo/target/debug/x",
31535            "./target/release/x",
31536        ];
31537        for path in cases {
31538            assert!(
31539                traversal_relative_path_is_generated_artifact(path),
31540                "expected `{path}` to be excluded from source watermark"
31541            );
31542        }
31543        // Look-alike paths must NOT be excluded — only true artifact dirs.
31544        for path in [
31545            "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
31546            "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
31547            "src/tsift-extras/lib.rs",
31548            "tsift/README.md",
31549            "src/targeting.rs",
31550            "src/.tsiftrc",
31551            "src/agent-doc-helper.rs",
31552        ] {
31553            assert!(
31554                !traversal_relative_path_is_generated_artifact(path),
31555                "expected `{path}` to be included in source watermark"
31556            );
31557        }
31558    }
31559
31560    #[test]
31561    fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
31562        // #cachelookupshift: the conflict-matrix preparation cache only hits
31563        // when traversal_source_watermark returns the same hash for two
31564        // consecutive calls on identical source state. Lock that invariant so
31565        // a future change that folds wall-clock time, a directory mtime, or
31566        // any other non-content input into the hash trips this test before
31567        // regressing the preparation_cache_lookup hit rate. We exercise the
31568        // session_only=true path with a hinted markdown file so the test does
31569        // not need a full index DB to drive the index-snapshot branch.
31570        let dir = tempfile::tempdir().unwrap();
31571        let root = dir.path();
31572        std::fs::create_dir_all(root.join("src")).unwrap();
31573        std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
31574        let hint = root.join("README.md");
31575        std::fs::write(&hint, "# stable\n").unwrap();
31576        // Add a generated-artifact directory that must NOT affect the watermark.
31577        std::fs::create_dir_all(root.join(".tsift")).unwrap();
31578        std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
31579        std::fs::create_dir_all(root.join("target/debug")).unwrap();
31580        std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
31581
31582        let first = traversal_source_watermark(root, &hint, None, true)
31583            .expect("first watermark call must succeed")
31584            .expect("first watermark must produce a hash for hinted markdown");
31585        let second = traversal_source_watermark(root, &hint, None, true)
31586            .expect("second watermark call must succeed")
31587            .expect("second watermark must produce a hash for hinted markdown");
31588        assert_eq!(
31589            first, second,
31590            "watermark must be identical across back-to-back invocations on a quiescent root"
31591        );
31592
31593        // Mutating a generated-artifact file must NOT shift the hash.
31594        std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
31595        std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
31596        let third = traversal_source_watermark(root, &hint, None, true)
31597            .expect("third watermark call must succeed")
31598            .expect("third watermark must produce a hash for hinted markdown");
31599        assert_eq!(
31600            first, third,
31601            "watermark must ignore mutations under .tsift/ and target/"
31602        );
31603
31604        // Mutating the hinted markdown file MUST shift the hash so the
31605        // preparation cache invalidates correctly when user state changes.
31606        // Sleep briefly to push the file mtime past the original even on
31607        // coarse-resolution filesystems.
31608        std::thread::sleep(std::time::Duration::from_millis(20));
31609        std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
31610        let fourth = traversal_source_watermark(root, &hint, None, true)
31611            .expect("fourth watermark call must succeed")
31612            .expect("fourth watermark must produce a hash for hinted markdown");
31613        assert_ne!(
31614            first, fourth,
31615            "watermark must invalidate when the hinted markdown file changes"
31616        );
31617    }
31618
31619    #[test]
31620    fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
31621        // #gcachemiss: full-projection cache keys must not miss just because the
31622        // SQLite summary cache file header or mtime churned. Only the semantic rows
31623        // that feed traversal projection should participate in the source watermark.
31624        let dir = tempfile::tempdir().unwrap();
31625        let root = dir.path();
31626        std::fs::write(root.join("README.md"), "# stable\n").unwrap();
31627        let summaries_db_path = root.join(".tsift/summaries.db");
31628        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31629        let mut summary = summarize::Summary {
31630            id: 0,
31631            symbol_name: "main".to_string(),
31632            file_path: "src/main.rs".to_string(),
31633            content_hash: "hash-main".to_string(),
31634            summary: "main wires the CLI".to_string(),
31635            entities: Some(vec![summarize::Entity {
31636                name: "Cli".to_string(),
31637                kind: "type".to_string(),
31638                description: "Command-line interface".to_string(),
31639            }]),
31640            relationships: None,
31641            concept_labels: Some(vec!["cli".to_string()]),
31642            extracted_at: "1700000000".to_string(),
31643            model: "test-model".to_string(),
31644            tokens_input: Some(10),
31645            tokens_output: Some(5),
31646        };
31647        summary_db.insert(&summary).unwrap();
31648        drop(summary_db);
31649
31650        let hint = root.join("README.md");
31651        let first = traversal_source_watermark(root, &hint, None, true)
31652            .expect("first watermark call must succeed")
31653            .expect("first watermark must produce a hash");
31654
31655        std::thread::sleep(std::time::Duration::from_millis(20));
31656        let conn = Connection::open(&summaries_db_path).unwrap();
31657        conn.pragma_update(None, "user_version", 1).unwrap();
31658        conn.pragma_update(None, "user_version", 0).unwrap();
31659        drop(conn);
31660
31661        let second = traversal_source_watermark(root, &hint, None, true)
31662            .expect("second watermark call must succeed")
31663            .expect("second watermark must produce a hash");
31664        assert_eq!(
31665            first, second,
31666            "metadata-only summaries.db churn must not invalidate the source watermark"
31667        );
31668
31669        summary.entities = Some(vec![summarize::Entity {
31670            name: "GraphCache".to_string(),
31671            kind: "type".to_string(),
31672            description: "Stable full-projection cache input".to_string(),
31673        }]);
31674        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31675        summary_db.delete_by_file("src/main.rs").unwrap();
31676        summary_db.insert(&summary).unwrap();
31677        drop(summary_db);
31678
31679        let third = traversal_source_watermark(root, &hint, None, true)
31680            .expect("third watermark call must succeed")
31681            .expect("third watermark must produce a hash");
31682        assert_ne!(
31683            first, third,
31684            "semantic summary row changes must invalidate the source watermark"
31685        );
31686    }
31687
31688    #[test]
31689    fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
31690        // #gfullhot: backend-eval full-projection cache keys should be based on
31691        // the indexed graph inputs, not file_state mtimes. Touching a source file
31692        // without changing extracted symbols/call edges must still hit the cache.
31693        let dir = tempfile::tempdir().unwrap();
31694        let root = dir.path();
31695        std::fs::create_dir_all(root.join("src")).unwrap();
31696        std::fs::create_dir_all(root.join(".tsift")).unwrap();
31697        let source = root.join("src/lib.rs");
31698        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
31699        std::fs::write(&source, source_body).unwrap();
31700        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31701        db.rebuild(root).unwrap();
31702        drop(db);
31703
31704        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31705            .unwrap()
31706            .value;
31707        std::thread::sleep(std::time::Duration::from_millis(20));
31708        std::fs::write(&source, source_body).unwrap();
31709        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31710        db.apply_changes(root).unwrap();
31711        drop(db);
31712
31713        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31714            .unwrap()
31715            .value;
31716        assert_eq!(
31717            first, second,
31718            "mtime-only source index churn must not invalidate the full-projection cache"
31719        );
31720    }
31721
31722    #[test]
31723    fn full_projection_source_watermark_ignores_session_markdown_churn() {
31724        // #gfullhot: the full-projection performance cache isolates code graph
31725        // and semantic-summary inputs. Current session evidence is measured by
31726        // the bounded real dataset, so unrelated task-doc edits must not force a
31727        // million-row full-projection rebuild.
31728        let dir = tempfile::tempdir().unwrap();
31729        let root = dir.path();
31730        std::fs::create_dir_all(root.join("src")).unwrap();
31731        std::fs::create_dir_all(root.join("tasks/software")).unwrap();
31732        std::fs::create_dir_all(root.join(".tsift")).unwrap();
31733        std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
31734        let task_doc = root.join("tasks/software/tsift.md");
31735        std::fs::write(
31736            &task_doc,
31737            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
31738        )
31739        .unwrap();
31740        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31741        db.rebuild(root).unwrap();
31742        drop(db);
31743
31744        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31745            .unwrap()
31746            .value;
31747        std::fs::write(
31748            &task_doc,
31749            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
31750        )
31751        .unwrap();
31752        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31753            .unwrap()
31754            .value;
31755        assert_eq!(
31756            first, second,
31757            "session markdown churn must not invalidate the full-projection code/summary cache"
31758        );
31759    }
31760
31761    #[test]
31762    fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
31763        // #gfullhot: once a full-project projection is cached, repeated samples
31764        // with unchanged graph inputs must report zero source_graph_build and
31765        // projection_rows work even if indexed file mtimes changed.
31766        let dir = tempfile::tempdir().unwrap();
31767        let root = dir.path();
31768        std::fs::create_dir_all(root.join("src")).unwrap();
31769        std::fs::create_dir_all(root.join(".tsift")).unwrap();
31770        let source = root.join("src/lib.rs");
31771        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
31772        std::fs::write(&source, source_body).unwrap();
31773        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31774        db.rebuild(root).unwrap();
31775        drop(db);
31776
31777        let (_projection, _warnings, _phases, first_stats) =
31778            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
31779        assert!(
31780            !first_stats.hit,
31781            "the first full-projection run should populate the cache"
31782        );
31783
31784        std::thread::sleep(std::time::Duration::from_millis(20));
31785        std::fs::write(&source, source_body).unwrap();
31786        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31787        db.apply_changes(root).unwrap();
31788        drop(db);
31789
31790        let (_projection, _warnings, phases, second_stats) =
31791            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
31792        assert!(second_stats.hit, "mtime-only churn should still cache-hit");
31793        let source_graph_build = phases
31794            .iter()
31795            .find(|phase| phase.name == "full_projection.source_graph_build")
31796            .expect("cache hit must report source_graph_build");
31797        let projection_rows = phases
31798            .iter()
31799            .find(|phase| phase.name == "full_projection.projection_rows")
31800            .expect("cache hit must report projection_rows");
31801        assert_eq!(source_graph_build.duration_micros, 0);
31802        assert_eq!(projection_rows.duration_micros, 0);
31803    }
31804
31805    #[test]
31806    fn build_token_capped_preview_within_cap() {
31807        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 2", "}"];
31808        let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
31809        assert!(!capped.was_capped);
31810        assert_eq!(capped.preview.len(), 3);
31811        assert_eq!(capped.capped_end, 3);
31812    }
31813
31814    #[test]
31815    fn build_token_capped_preview_truncates_long_body() {
31816        let owned: Vec<String> = (0..200)
31817            .map(|i| format!("    let line_{i} = {i};"))
31818            .collect();
31819        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
31820        let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
31821        assert!(capped.was_capped);
31822        assert!(capped.preview.len() < 200);
31823        assert!(capped.capped_end < 200);
31824        assert!(!capped.preview.is_empty());
31825    }
31826
31827    #[test]
31828    fn build_token_capped_preview_respects_start_offset() {
31829        let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
31830        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
31831        let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
31832        assert!(capped.was_capped);
31833        assert!(capped.capped_end >= 50);
31834        assert!(capped.capped_end < 100);
31835        assert_eq!(capped.preview[0].line, 50);
31836    }
31837
31838    #[test]
31839    fn response_budget_body_token_cap_defaults() {
31840        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
31841        assert_eq!(budget.body_token_cap(), 1500);
31842
31843        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
31844        assert_eq!(budget.body_token_cap(), 500);
31845
31846        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
31847        assert_eq!(budget.body_token_cap(), 3000);
31848    }
31849
31850    #[test]
31851    fn build_token_capped_preview_empty_input() {
31852        let lines: Vec<&str> = vec![];
31853        let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
31854        assert!(!capped.was_capped);
31855        assert!(capped.preview.is_empty());
31856    }
31857
31858    #[test]
31859    fn build_token_capped_preview_single_long_line_fits() {
31860        let lines: Vec<&str> = vec!["short"];
31861        let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
31862        assert!(!capped.was_capped);
31863        assert_eq!(capped.preview.len(), 1);
31864        assert_eq!(capped.capped_end, 1);
31865    }
31866
31867    #[test]
31868    fn edge_index_replaces_from_id_to_id_with_positions() {
31869        let input = serde_json::json!({
31870            "nodes": [
31871                {"id": "symbol:src/lib.rs:foo"},
31872                {"id": "symbol:src/lib.rs:bar"},
31873                {"id": "symbol:src/lib.rs:baz"}
31874            ],
31875            "edges": [
31876                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
31877                {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
31878            ]
31879        });
31880        let result = edge_index_transform(input);
31881        let edges = result.get("edges").unwrap().as_array().unwrap();
31882        assert_eq!(edges.len(), 2);
31883        assert_eq!(edges[0]["from"], 0);
31884        assert_eq!(edges[0]["to"], 1);
31885        assert_eq!(edges[1]["from"], 1);
31886        assert_eq!(edges[1]["to"], 2);
31887        assert!(edges[0].get("from_id").is_none());
31888        assert!(edges[0].get("to_id").is_none());
31889    }
31890
31891    #[test]
31892    fn edge_index_preserves_unresolved_ids_as_strings() {
31893        let input = serde_json::json!({
31894            "nodes": [{"id": "symbol:src/lib.rs:foo"}],
31895            "edges": [
31896                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
31897            ]
31898        });
31899        let result = edge_index_transform(input);
31900        let edge = &result["edges"][0];
31901        assert_eq!(edge["from"], 0);
31902        assert_eq!(edge["to_id"], "symbol:other.rs:missing");
31903    }
31904
31905    #[test]
31906    fn edge_index_noop_without_nodes_and_edges() {
31907        let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
31908        let result = edge_index_transform(input);
31909        assert_eq!(result["report"]["entries"][0]["from_id"], "a");
31910    }
31911}
31912
31913// --- SQL introspection ---
31914
31915#[derive(Serialize)]
31916struct TableInfo {
31917    name: String,
31918    columns: Vec<ColumnInfo>,
31919    row_count: i64,
31920}
31921
31922#[derive(Serialize)]
31923struct ColumnInfo {
31924    name: String,
31925    #[serde(rename = "type")]
31926    col_type: String,
31927    notnull: bool,
31928    pk: bool,
31929    #[serde(skip_serializing_if = "Option::is_none")]
31930    default_value: Option<String>,
31931}
31932
31933/// Open a SQLite connection (read-only).
31934pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
31935    let conn = Connection::open_with_flags(
31936        path,
31937        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
31938    )
31939    .with_context(|| format!("opening database: {}", path.display()))?;
31940    Ok(conn)
31941}
31942
31943/// List all user tables with column metadata and row counts.
31944pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
31945    let mut stmt = conn.prepare(
31946        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
31947    )?;
31948    let table_names: Vec<String> = stmt
31949        .query_map([], |row| row.get(0))?
31950        .collect::<std::result::Result<Vec<_>, _>>()?;
31951
31952    let mut tables = Vec::new();
31953    for tbl in table_names {
31954        let columns = table_columns(conn, &tbl)?;
31955        let row_count: i64 =
31956            conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
31957                row.get(0)
31958            })?;
31959        tables.push(TableInfo {
31960            name: tbl,
31961            columns,
31962            row_count,
31963        });
31964    }
31965    Ok(tables)
31966}
31967
31968/// Get column metadata for a single table.
31969pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
31970    let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
31971    let cols = stmt
31972        .query_map([], |row| {
31973            Ok(ColumnInfo {
31974                name: row.get(1)?,
31975                col_type: row.get::<_, String>(2).unwrap_or_default(),
31976                notnull: row.get::<_, bool>(3).unwrap_or(false),
31977                pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
31978                default_value: row.get(4)?,
31979            })
31980        })?
31981        .collect::<std::result::Result<Vec<_>, _>>()?;
31982    Ok(cols)
31983}
31984
31985/// Execute an arbitrary SQL query and return rows as JSON values.
31986pub(crate) fn execute_query(
31987    conn: &Connection,
31988    sql: &str,
31989) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
31990    let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
31991    let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
31992    let col_count = col_names.len();
31993
31994    let mut rows = Vec::new();
31995    let mut query_rows = stmt.query([])?;
31996    while let Some(row) = query_rows.next()? {
31997        let mut vals = Vec::with_capacity(col_count);
31998        for i in 0..col_count {
31999            let val = match row.get_ref(i)? {
32000                rusqlite::types::ValueRef::Null => serde_json::Value::Null,
32001                rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
32002                rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
32003                rusqlite::types::ValueRef::Text(s) => {
32004                    serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
32005                }
32006                rusqlite::types::ValueRef::Blob(b) => {
32007                    serde_json::Value::String(format!("<blob {} bytes>", b.len()))
32008                }
32009            };
32010            vals.push(val);
32011        }
32012        rows.push(vals);
32013    }
32014    Ok((col_names, rows))
32015}
32016
32017#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32018enum DigestRunnerKind {
32019    Test,
32020    Log,
32021}
32022
32023impl DigestRunnerKind {
32024    fn parse(raw: &str) -> Result<Self> {
32025        match raw.trim().to_ascii_lowercase().as_str() {
32026            "test" => Ok(Self::Test),
32027            "log" => Ok(Self::Log),
32028            other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
32029        }
32030    }
32031
32032    fn as_str(self) -> &'static str {
32033        match self {
32034            Self::Test => "test",
32035            Self::Log => "log",
32036        }
32037    }
32038}
32039
32040/// Simple shell word splitting (handles single and double quotes).
32041pub(crate) fn shell_split(s: &str) -> Vec<&str> {
32042    let mut parts = Vec::new();
32043    let mut i = 0;
32044    let bytes = s.as_bytes();
32045    while i < bytes.len() {
32046        // Skip whitespace
32047        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
32048            i += 1;
32049        }
32050        if i >= bytes.len() {
32051            break;
32052        }
32053        let start = i;
32054        if bytes[i] == b'"' || bytes[i] == b'\'' {
32055            let quote = bytes[i];
32056            i += 1;
32057            while i < bytes.len() && bytes[i] != quote {
32058                i += 1;
32059            }
32060            if i < bytes.len() {
32061                i += 1; // closing quote
32062            }
32063        } else {
32064            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
32065                i += 1;
32066            }
32067        }
32068        parts.push(&s[start..i]);
32069    }
32070    parts
32071}
32072
32073/// Quote a string for shell if it contains special characters.
32074pub(crate) fn shell_quote(s: &str) -> String {
32075    // Strip existing quotes
32076    let unquoted =
32077        if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
32078            &s[1..s.len() - 1]
32079        } else {
32080            s
32081        };
32082
32083    if unquoted
32084        .chars()
32085        .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
32086    {
32087        format!("\"{}\"", unquoted)
32088    } else {
32089        format!(
32090            "\"{}\"",
32091            unquoted.replace('\\', "\\\\").replace('"', "\\\"")
32092        )
32093    }
32094}
32095
32096fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
32097    sift::SearchCoverageSnapshot {
32098        mode: sift::SearchCoverageMode::Sealed,
32099        total_sector_count: 0,
32100        mounted_sector_count: 0,
32101        reused_sector_count: 0,
32102        dirty_sector_count: 0,
32103        completed_dirty_sector_count: 0,
32104        rebuilding_sector_count: 0,
32105        resumed_sector_count: 0,
32106        active_rebuild: None,
32107    }
32108}
32109
32110fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
32111    let total_sector_count = responses
32112        .iter()
32113        .map(|response| response.coverage.total_sector_count)
32114        .sum();
32115    let mounted_sector_count = responses
32116        .iter()
32117        .map(|response| response.coverage.mounted_sector_count)
32118        .sum();
32119    let reused_sector_count = responses
32120        .iter()
32121        .map(|response| response.coverage.reused_sector_count)
32122        .sum();
32123    let dirty_sector_count = responses
32124        .iter()
32125        .map(|response| response.coverage.dirty_sector_count)
32126        .sum();
32127    let completed_dirty_sector_count = responses
32128        .iter()
32129        .map(|response| response.coverage.completed_dirty_sector_count)
32130        .sum();
32131    let rebuilding_sector_count = responses
32132        .iter()
32133        .map(|response| response.coverage.rebuilding_sector_count)
32134        .sum();
32135    let resumed_sector_count = responses
32136        .iter()
32137        .map(|response| response.coverage.resumed_sector_count)
32138        .sum();
32139
32140    let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
32141        sift::SearchCoverageMode::Sealed
32142    } else if completed_dirty_sector_count > 0
32143        || rebuilding_sector_count > 0
32144        || resumed_sector_count > 0
32145    {
32146        sift::SearchCoverageMode::Converging
32147    } else {
32148        sift::SearchCoverageMode::Frontier
32149    };
32150
32151    sift::SearchCoverageSnapshot {
32152        mode,
32153        total_sector_count,
32154        mounted_sector_count,
32155        reused_sector_count,
32156        dirty_sector_count,
32157        completed_dirty_sector_count,
32158        rebuilding_sector_count,
32159        resumed_sector_count,
32160        active_rebuild: responses
32161            .iter()
32162            .find_map(|response| response.coverage.active_rebuild.clone()),
32163    }
32164}
32165
32166fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
32167    sift::SearchResponse {
32168        strategy: strategy.to_string(),
32169        root: root.display().to_string(),
32170        indexed_artifacts: 0,
32171        skipped_artifacts: 0,
32172        coverage: empty_search_coverage(),
32173        hits: Vec::new(),
32174    }
32175}
32176
32177fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
32178    for hit in &mut response.hits {
32179        let path = Path::new(&hit.path);
32180        if path.is_relative() {
32181            hit.path = search_root.join(path).display().to_string();
32182        }
32183    }
32184}
32185
32186fn merge_search_responses(
32187    root: &Path,
32188    strategy: &str,
32189    limit: usize,
32190    responses: Vec<sift::SearchResponse>,
32191) -> sift::SearchResponse {
32192    let indexed_artifacts = responses
32193        .iter()
32194        .map(|response| response.indexed_artifacts)
32195        .sum();
32196    let skipped_artifacts = responses
32197        .iter()
32198        .map(|response| response.skipped_artifacts)
32199        .sum();
32200    let coverage = if responses.is_empty() {
32201        empty_search_coverage()
32202    } else {
32203        aggregate_search_coverage(&responses)
32204    };
32205    let mut hits: Vec<sift::SearchHit> = responses
32206        .into_iter()
32207        .flat_map(|response| response.hits)
32208        .collect();
32209    hits.sort_by(|left, right| {
32210        right
32211            .score
32212            .partial_cmp(&left.score)
32213            .unwrap_or(Ordering::Equal)
32214            .then_with(|| left.path.cmp(&right.path))
32215            .then_with(|| left.location.cmp(&right.location))
32216    });
32217    hits.truncate(limit);
32218    for (rank, hit) in hits.iter_mut().enumerate() {
32219        hit.rank = rank + 1;
32220    }
32221
32222    sift::SearchResponse {
32223        strategy: strategy.to_string(),
32224        root: root.display().to_string(),
32225        indexed_artifacts,
32226        skipped_artifacts,
32227        coverage,
32228        hits,
32229    }
32230}
32231
32232pub(crate) fn federated_sift_search(
32233    root: &Path,
32234    cache_dir: &Path,
32235    query: &str,
32236    limit: usize,
32237    timeout_secs: u64,
32238    strategy: &str,
32239) -> Result<sift::SearchResponse> {
32240    let targets = resolve_search_index_targets(root, root, None, true)?;
32241    if targets.is_empty() {
32242        if config::Config::submodule_dirs(root)?.is_empty() {
32243            return run_search_with_timeout(
32244                root,
32245                cache_dir,
32246                query,
32247                limit,
32248                timeout_secs,
32249                strategy,
32250                &[],
32251            );
32252        }
32253        return Ok(empty_search_response(root, strategy));
32254    }
32255
32256    let mut responses = Vec::with_capacity(targets.len());
32257    for target in &targets {
32258        let mut response = run_search_with_timeout(
32259            &target.source_root,
32260            cache_dir,
32261            query,
32262            limit,
32263            timeout_secs,
32264            strategy,
32265            std::slice::from_ref(target),
32266        )?;
32267        absolutize_search_hit_paths(&mut response, &target.source_root);
32268        response.root = root.display().to_string();
32269        responses.push(response);
32270    }
32271
32272    Ok(merge_search_responses(root, strategy, limit, responses))
32273}
32274
32275/// Federated symbol search across every scoped `.tsift/indexes/<scope>/index.db`
32276/// in the workspace. Per-scope tagpath annotation runs inside the per-scope
32277/// loop so each scope's adapter resolves against its own `.naming.toml` /
32278/// `.naming/index.json` (the workspace root usually has no tagpath of its
32279/// own). The merged `TagpathAnnotationDiagnostic` reports `loaded=true` when
32280/// at least one scope loaded, and `stale=true` with the first stale reason
32281/// when any scope was stale.
32282pub(crate) fn federated_symbol_search(
32283    root: &std::path::Path,
32284    query: &str,
32285    limit: usize,
32286    tagpath_opts: &TagpathSearchOpts,
32287) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
32288    let cfg = config::Config::load(root)?;
32289    let submodules = config::Config::submodule_dirs(root)?;
32290    let mut all_hits: Vec<index::SymbolHit> = Vec::new();
32291    let mut combined = TagpathAnnotationDiagnostic::default();
32292    for scope in &submodules {
32293        if !cfg.federation_for_scope(scope) {
32294            continue;
32295        }
32296        let db_path = cfg.db_path_for(root, &scope.id);
32297        if !db_path.exists() {
32298            continue;
32299        }
32300        let db = index::IndexDb::open_read_only(&db_path)?;
32301        let mut hits = db.symbol_search(query, limit)?;
32302        let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
32303        combined.loaded |= diag.loaded;
32304        if diag.stale && !combined.stale {
32305            combined.stale = true;
32306            combined.reason = diag.reason;
32307        }
32308        all_hits.append(&mut hits);
32309    }
32310    all_hits.sort_by(|a, b| {
32311        b.score
32312            .partial_cmp(&a.score)
32313            .unwrap_or(std::cmp::Ordering::Equal)
32314    });
32315    all_hits.truncate(limit);
32316    Ok((all_hits, combined))
32317}
32318
32319#[derive(Debug, Deserialize)]
32320#[serde(tag = "type", rename_all = "lowercase")]
32321enum RipgrepJsonEvent {
32322    Match {
32323        data: RipgrepMatchData,
32324    },
32325    #[serde(other)]
32326    Other,
32327}
32328
32329#[derive(Debug, Deserialize)]
32330struct RipgrepMatchData {
32331    path: RipgrepTextField,
32332    lines: RipgrepTextField,
32333    line_number: Option<usize>,
32334}
32335
32336#[derive(Debug, Deserialize)]
32337struct RipgrepTextField {
32338    text: Option<String>,
32339}
32340
32341pub(crate) fn federated_exact_search(
32342    root: &Path,
32343    query: &str,
32344    limit: usize,
32345    timeout_secs: u64,
32346) -> Result<sift::SearchResponse> {
32347    let cfg = config::Config::load(root)?;
32348    let mut responses = Vec::new();
32349    for scope in config::Config::submodule_dirs(root)? {
32350        if !cfg.federation_for_scope(&scope) {
32351            continue;
32352        }
32353        let mut response =
32354            run_exact_search_with_timeout(&scope.source_root, query, limit, timeout_secs)?;
32355        absolutize_search_hit_paths(&mut response, &scope.source_root);
32356        response.root = root.display().to_string();
32357        responses.push(response);
32358    }
32359
32360    Ok(merge_search_responses(root, "exact", limit, responses))
32361}
32362
32363pub(crate) fn run_sift_search(
32364    search_path: &Path,
32365    cache_dir: &Path,
32366    query: &str,
32367    limit: usize,
32368    strategy: &str,
32369) -> Result<sift::SearchResponse> {
32370    let engine = Sift::builder().with_cache_dir(cache_dir).build();
32371    let options = SearchOptions::default()
32372        .with_limit(limit)
32373        .with_strategy(strategy.to_string());
32374    let input = SearchInput::new(search_path, query).with_options(options);
32375    engine.search(input).context("sift search failed")
32376}
32377
32378fn exact_search_timeout_message(timeout_secs: u64) -> String {
32379    format!(
32380        "tsift search timed out after {}s (strategy: exact). \
32381         Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
32382        timeout_secs
32383    )
32384}
32385
32386fn exact_search_command(search_path: &Path, query: &str) -> Command {
32387    let mut command = Command::new("rg");
32388    command
32389        .arg("--json")
32390        .arg("--fixed-strings")
32391        .arg("--line-number")
32392        .arg("--hidden")
32393        .arg("--")
32394        .arg(query)
32395        .arg(search_path);
32396    command
32397}
32398
32399fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
32400    let observed_unix_secs = SystemTime::now()
32401        .duration_since(UNIX_EPOCH)
32402        .unwrap_or_default()
32403        .as_secs() as i64;
32404    let modified_unix_secs = fs::metadata(path)
32405        .ok()
32406        .and_then(|metadata| metadata.modified().ok())
32407        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
32408        .map(|duration| duration.as_secs() as i64);
32409    sift::ArtifactFreshness {
32410        observed_unix_secs,
32411        modified_unix_secs,
32412    }
32413}
32414
32415fn parse_exact_search_output(
32416    search_path: &Path,
32417    limit: usize,
32418    raw: &str,
32419) -> Result<sift::SearchResponse> {
32420    if limit == 0 {
32421        return Ok(sift::SearchResponse {
32422            strategy: "exact".to_string(),
32423            root: search_path.display().to_string(),
32424            indexed_artifacts: 0,
32425            skipped_artifacts: 0,
32426            coverage: empty_search_coverage(),
32427            hits: Vec::new(),
32428        });
32429    }
32430
32431    let mut hits = Vec::new();
32432    for line in raw.lines() {
32433        let event: RipgrepJsonEvent =
32434            serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
32435        let RipgrepJsonEvent::Match { data } = event else {
32436            continue;
32437        };
32438        let Some(path_text) = data.path.text else {
32439            continue;
32440        };
32441        let Some(lines_text) = data.lines.text else {
32442            continue;
32443        };
32444        let path = PathBuf::from(path_text);
32445        let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
32446        let rank = hits.len() + 1;
32447        hits.push(sift::SearchHit {
32448            artifact_id: format!(
32449                "exact:{}:{}:{}",
32450                path.display(),
32451                data.line_number.unwrap_or(0),
32452                rank
32453            ),
32454            artifact_kind: sift::ContextArtifactKind::File,
32455            path: path.display().to_string(),
32456            rank,
32457            score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
32458            confidence: sift::ScoreConfidence::High,
32459            location: data.line_number.map(|line| format!("line {}", line)),
32460            snippet: snippet.clone(),
32461            provenance: sift::ArtifactProvenance {
32462                adapter: sift::AcquisitionAdapterKind::FileSystem,
32463                source: "ripgrep -F".to_string(),
32464                synthetic: false,
32465            },
32466            freshness: exact_search_file_timestamp(&path),
32467            budget: sift::ArtifactBudget::from_text(&snippet, 1),
32468        });
32469        if hits.len() >= limit {
32470            break;
32471        }
32472    }
32473
32474    Ok(sift::SearchResponse {
32475        strategy: "exact".to_string(),
32476        root: search_path.display().to_string(),
32477        indexed_artifacts: hits.len(),
32478        skipped_artifacts: 0,
32479        coverage: empty_search_coverage(),
32480        hits,
32481    })
32482}
32483
32484fn exact_search_response_from_process(
32485    search_path: &Path,
32486    limit: usize,
32487    status: std::process::ExitStatus,
32488    stdout: &[u8],
32489    stderr: &[u8],
32490) -> Result<sift::SearchResponse> {
32491    if !status.success() && status.code() != Some(1) {
32492        let message = String::from_utf8_lossy(stderr);
32493        let trimmed = message.trim();
32494        if trimmed.is_empty() {
32495            bail!("ripgrep exact search exited with status {}", status);
32496        }
32497        bail!("{}", trimmed);
32498    }
32499
32500    let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
32501    parse_exact_search_output(search_path, limit, &raw)
32502}
32503
32504fn run_exact_search(search_path: &Path, query: &str, limit: usize) -> Result<sift::SearchResponse> {
32505    let output = exact_search_command(search_path, query)
32506        .output()
32507        .context("running exact search with ripgrep")?;
32508    exact_search_response_from_process(
32509        search_path,
32510        limit,
32511        output.status,
32512        &output.stdout,
32513        &output.stderr,
32514    )
32515}
32516
32517pub(crate) fn run_exact_search_with_timeout(
32518    search_path: &Path,
32519    query: &str,
32520    limit: usize,
32521    timeout_secs: u64,
32522) -> Result<sift::SearchResponse> {
32523    if timeout_secs == 0 {
32524        return run_exact_search(search_path, query, limit);
32525    }
32526
32527    let mut child = exact_search_command(search_path, query)
32528        .stdin(Stdio::null())
32529        .stdout(Stdio::piped())
32530        .stderr(Stdio::piped())
32531        .spawn()
32532        .context("spawning timed exact search worker")?;
32533
32534    let timeout = Duration::from_secs(timeout_secs);
32535    let status = wait_for_child_exit(&mut child, timeout)
32536        .context("waiting for timed exact search worker")?;
32537    if status.is_none() {
32538        let _ = child.kill();
32539        let _ = child.wait();
32540        bail!("{}", exact_search_timeout_message(timeout_secs));
32541    }
32542
32543    let status = status.unwrap();
32544    let stdout = read_child_stdout(&mut child)?;
32545    let stderr = read_child_stderr(&mut child)?;
32546    exact_search_response_from_process(
32547        search_path,
32548        limit,
32549        status,
32550        stdout.as_bytes(),
32551        stderr.as_bytes(),
32552    )
32553}
32554
32555pub(crate) fn run_search_with_timeout(
32556    search_path: &Path,
32557    cache_dir: &Path,
32558    query: &str,
32559    limit: usize,
32560    timeout_secs: u64,
32561    strategy: &str,
32562    search_targets: &[SearchIndexTarget],
32563) -> Result<sift::SearchResponse> {
32564    if timeout_secs == 0 {
32565        return run_sift_search(search_path, cache_dir, query, limit, strategy);
32566    }
32567
32568    let output_path = next_search_worker_output_path();
32569    let mut child = Command::new(
32570        std::env::current_exe().context("resolving tsift executable for timed search")?,
32571    )
32572    .arg("__search-worker")
32573    .arg("--path")
32574    .arg(search_path)
32575    .arg("--cache-dir")
32576    .arg(cache_dir)
32577    .arg("--query")
32578    .arg(query)
32579    .arg("--limit")
32580    .arg(limit.to_string())
32581    .arg("--strategy")
32582    .arg(strategy)
32583    .arg("--output")
32584    .arg(&output_path)
32585    .stdin(Stdio::null())
32586    .stdout(Stdio::null())
32587    .stderr(Stdio::piped())
32588    .spawn()
32589    .context("spawning timed sift search worker")?;
32590
32591    let timeout = Duration::from_secs(timeout_secs);
32592    let status =
32593        wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
32594    if status.is_none() {
32595        let _ = child.kill();
32596        let _ = child.wait();
32597        let _ = fs::remove_file(&output_path);
32598        bail!(
32599            "{}",
32600            search_timeout_message(timeout_secs, strategy, search_targets)?
32601        );
32602    }
32603
32604    let status = status.unwrap();
32605    let stderr = read_child_stderr(&mut child)?;
32606    if !status.success() {
32607        let _ = fs::remove_file(&output_path);
32608        let message = stderr.trim();
32609        if message.is_empty() {
32610            bail!("sift search worker exited with status {}", status);
32611        }
32612        bail!("{}", message);
32613    }
32614
32615    let raw = fs::read_to_string(&output_path)
32616        .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
32617    let _ = fs::remove_file(&output_path);
32618    serde_json::from_str(&raw).context("parsing search worker output")
32619}
32620
32621fn next_search_worker_output_path() -> PathBuf {
32622    let stamp = SystemTime::now()
32623        .duration_since(UNIX_EPOCH)
32624        .unwrap_or_default()
32625        .as_nanos();
32626    std::env::temp_dir().join(format!(
32627        "tsift-search-{}-{}.json",
32628        std::process::id(),
32629        stamp
32630    ))
32631}
32632
32633fn wait_for_child_exit(
32634    child: &mut std::process::Child,
32635    timeout: Duration,
32636) -> Result<Option<std::process::ExitStatus>> {
32637    let started = Instant::now();
32638    loop {
32639        if let Some(status) = child.try_wait()? {
32640            return Ok(Some(status));
32641        }
32642        if started.elapsed() >= timeout {
32643            return Ok(None);
32644        }
32645        let remaining = timeout.saturating_sub(started.elapsed());
32646        std::thread::sleep(remaining.min(Duration::from_millis(10)));
32647    }
32648}
32649
32650fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
32651    let mut stderr = String::new();
32652    if let Some(mut pipe) = child.stderr.take() {
32653        pipe.read_to_string(&mut stderr)
32654            .context("reading search worker stderr")?;
32655    }
32656    Ok(stderr)
32657}
32658
32659fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
32660    let mut stdout = String::new();
32661    if let Some(mut pipe) = child.stdout.take() {
32662        pipe.read_to_string(&mut stdout)
32663            .context("reading search worker stdout")?;
32664    }
32665    Ok(stdout)
32666}
32667
32668pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
32669    if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
32670        fs::write(&path, std::process::id().to_string())
32671            .with_context(|| format!("writing search worker pid file: {path}"))?;
32672    }
32673    if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
32674        let delay_ms = ms
32675            .parse::<u64>()
32676            .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
32677        std::thread::sleep(Duration::from_millis(delay_ms));
32678    }
32679    Ok(())
32680}
32681
32682#[cfg(test)]
32683thread_local! {
32684    static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
32685}
32686
32687#[cfg(test)]
32688enum SearchPostPrecheckLockMode {
32689    RollbackJournal,
32690    Wal,
32691}
32692
32693#[cfg(test)]
32694struct SearchPostPrecheckLockHook {
32695    db_path: PathBuf,
32696    mode: SearchPostPrecheckLockMode,
32697}
32698
32699#[cfg(test)]
32700struct SearchPostPrecheckLockGuard;
32701
32702#[cfg(test)]
32703impl Drop for SearchPostPrecheckLockGuard {
32704    fn drop(&mut self) {
32705        SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32706            hook.borrow_mut().take();
32707        });
32708    }
32709}
32710
32711#[cfg(test)]
32712fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32713    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
32714}
32715
32716#[cfg(test)]
32717fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32718    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
32719}
32720
32721#[cfg(test)]
32722fn install_search_post_precheck_lock_hook(
32723    db_path: PathBuf,
32724    mode: SearchPostPrecheckLockMode,
32725) -> SearchPostPrecheckLockGuard {
32726    SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32727        assert!(
32728            hook.borrow().is_none(),
32729            "search post-precheck lock hook already installed"
32730        );
32731        *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
32732    });
32733    SearchPostPrecheckLockGuard
32734}
32735
32736#[cfg(test)]
32737pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
32738    let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
32739        return Ok(());
32740    };
32741    let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
32742    std::thread::spawn(move || {
32743        let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
32744        match hook.mode {
32745            SearchPostPrecheckLockMode::RollbackJournal => {
32746                conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
32747                    .expect("acquiring rollback-journal hook lock");
32748                fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
32749                    .expect("writing rollback journal marker");
32750            }
32751            SearchPostPrecheckLockMode::Wal => {
32752                conn.execute_batch(
32753                    "PRAGMA journal_mode=WAL;
32754                     PRAGMA wal_autocheckpoint=0;
32755                     CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
32756                     INSERT INTO search_wal_lock_probe DEFAULT VALUES;
32757                     PRAGMA locking_mode=EXCLUSIVE;
32758                     BEGIN EXCLUSIVE;",
32759                )
32760                .expect("acquiring WAL hook lock");
32761                assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
32762            }
32763        }
32764        ready_tx.send(()).expect("signaling search lock hook");
32765        std::thread::sleep(Duration::from_millis(200));
32766        drop(conn);
32767        let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
32768    });
32769    ready_rx
32770        .recv_timeout(Duration::from_secs(1))
32771        .context("waiting for search post-precheck lock hook")?;
32772    Ok(())
32773}
32774
32775#[cfg(not(test))]
32776pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
32777    Ok(())
32778}