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;
142use tsift_agent_doc::session_markdown::{self, AgentDocQueueItem, AgentDocSessionDocument};
143#[cfg(test)]
144use tsift_agent_doc::session_review;
145use tsift_cache::cycle_packet_cache;
146use tsift_core::{
147    NeighborhoodScoring, RankedNeighborhoodOptions, SemanticSeededNeighborhoodOptions,
148};
149use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
150use tsift_graph as graph;
151use tsift_index::{config, index, init, multiplicity, walk};
152use tsift_memgraphrag::append_tsift_memory_graph_projection_rows;
153#[cfg(test)]
154use tsift_memory::MemoryEvent;
155use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
156use tsift_resolution as resolution;
157use tsift_search::{impact, sift};
158use tsift_sqlite as substrate;
159use tsift_status::status;
160use tsift_summarize::summarize;
161#[cfg(feature = "backend-surrealdb")]
162use tsift_surrealdb::SurrealdbGraphStore;
163use tsift_tokensave::TokensaveDb;
164
165#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
166pub(crate) enum GraphDbExperimentalBackend {
167    DuckdbDuckpgq,
168    Falkordb,
169    Ladybug,
170    Kuzu,
171    Surrealdb,
172}
173
174#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
175pub(crate) struct SearchFacetFilters {
176    #[serde(skip_serializing_if = "Vec::is_empty", default)]
177    pub(crate) languages: Vec<String>,
178    #[serde(skip_serializing_if = "Vec::is_empty", default)]
179    pub(crate) kinds: Vec<String>,
180    #[serde(skip_serializing_if = "Vec::is_empty", default)]
181    pub(crate) node_kinds: Vec<String>,
182    #[serde(skip_serializing_if = "Vec::is_empty", default)]
183    pub(crate) sections: Vec<String>,
184    #[serde(skip_serializing_if = "Vec::is_empty", default)]
185    pub(crate) parents: Vec<String>,
186    #[serde(skip_serializing_if = "Vec::is_empty", default)]
187    pub(crate) children: Vec<String>,
188    #[serde(skip_serializing_if = "Vec::is_empty", default)]
189    pub(crate) fence_languages: Vec<String>,
190    #[serde(skip_serializing_if = "Vec::is_empty", default)]
191    pub(crate) list_depths: Vec<usize>,
192    #[serde(skip_serializing_if = "Vec::is_empty", default)]
193    pub(crate) heading_levels: Vec<usize>,
194}
195
196impl SearchFacetFilters {
197    pub(crate) fn is_empty(&self) -> bool {
198        self.languages.is_empty()
199            && self.kinds.is_empty()
200            && self.node_kinds.is_empty()
201            && self.sections.is_empty()
202            && self.parents.is_empty()
203            && self.children.is_empty()
204            && self.fence_languages.is_empty()
205            && self.list_depths.is_empty()
206            && self.heading_levels.is_empty()
207    }
208
209    fn needs_ast_context(&self) -> bool {
210        !self.sections.is_empty()
211            || !self.parents.is_empty()
212            || !self.children.is_empty()
213            || !self.fence_languages.is_empty()
214            || !self.list_depths.is_empty()
215            || !self.heading_levels.is_empty()
216    }
217}
218
219#[derive(Serialize)]
220struct GraphDbBackendPromotionGate {
221    status: String,
222    native_adapter_required: bool,
223    required_checks: Vec<String>,
224}
225
226impl GraphDbExperimentalBackend {
227    fn name(self) -> &'static str {
228        match self {
229            Self::DuckdbDuckpgq => "duckdb-duckpgq",
230            Self::Falkordb => "falkordb",
231            Self::Ladybug => "ladybug",
232            Self::Kuzu => "kuzu",
233            Self::Surrealdb => "surrealdb",
234        }
235    }
236
237    fn adapter_label(self) -> &'static str {
238        match self {
239            Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
240            Self::Falkordb => "FalkorDB read-only prototype",
241            Self::Ladybug => "Ladybug read-only prototype",
242            Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
243            Self::Surrealdb => "SurrealDB read-only prototype",
244        }
245    }
246
247    fn projection_load(self) -> &'static str {
248        match self {
249            Self::Falkordb => {
250                "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"
251            }
252            Self::Kuzu => {
253                "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"
254            }
255            Self::Surrealdb => {
256                "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"
257            }
258            _ => {
259                "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
260            }
261        }
262    }
263
264    fn lock_behavior(self) -> &'static str {
265        match self {
266            Self::Falkordb => {
267                "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
268            }
269            Self::Kuzu => {
270                "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"
271            }
272            Self::Surrealdb => {
273                "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
274            }
275            _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
276        }
277    }
278
279    fn install_portability(self) -> &'static str {
280        match self {
281            Self::Falkordb => {
282                "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
283            }
284            Self::Kuzu => {
285                "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"
286            }
287            Self::Surrealdb => {
288                "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"
289            }
290            _ => {
291                "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
292            }
293        }
294    }
295
296    fn prototype_hold_reason(self) -> Option<&'static str> {
297        match self {
298            Self::DuckdbDuckpgq => Some(
299                "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
300            ),
301            Self::Falkordb => Some(
302                "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",
303            ),
304            Self::Ladybug => Some(
305                "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
306            ),
307            Self::Kuzu => Some(
308                "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
309            ),
310            Self::Surrealdb => Some(
311                "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",
312            ),
313        }
314    }
315
316    fn promotion_gate(self) -> GraphDbBackendPromotionGate {
317        match self {
318            Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
319                status: "hold_native_adapter_required".to_string(),
320                native_adapter_required: true,
321                required_checks: vec![
322                    "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
323                        .to_string(),
324                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
325                        .to_string(),
326                    "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
327                    "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
328                        .to_string(),
329                ],
330            },
331            Self::Falkordb => GraphDbBackendPromotionGate {
332                status: "hold_native_adapter_required".to_string(),
333                native_adapter_required: true,
334                required_checks: vec![
335                    "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
336                        .to_string(),
337                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
338                        .to_string(),
339                    "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
340                        .to_string(),
341                    "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
342                        .to_string(),
343                ],
344            },
345            Self::Ladybug => GraphDbBackendPromotionGate {
346                status: "hold_native_adapter_required".to_string(),
347                native_adapter_required: true,
348                required_checks: vec![
349                    "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
350                        .to_string(),
351                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
352                        .to_string(),
353                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
354                        .to_string(),
355                    "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
356                        .to_string(),
357                ],
358            },
359            Self::Kuzu => GraphDbBackendPromotionGate {
360                status: "hold_native_adapter_required".to_string(),
361                native_adapter_required: true,
362                required_checks: vec![
363                    "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
364                        .to_string(),
365                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
366                        .to_string(),
367                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
368                        .to_string(),
369                    "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
370                        .to_string(),
371                ],
372            },
373            Self::Surrealdb => GraphDbBackendPromotionGate {
374                status: "hold_native_adapter_required".to_string(),
375                native_adapter_required: true,
376                required_checks: vec![
377                    "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
378                        .to_string(),
379                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
380                        .to_string(),
381                    "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
382                        .to_string(),
383                    "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
384                        .to_string(),
385                ],
386            },
387        }
388    }
389
390    fn parse(raw: &str) -> Result<Self> {
391        match raw {
392            "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
393            "falkordb" | "falkor" => Ok(Self::Falkordb),
394            "ladybug" => Ok(Self::Ladybug),
395            "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
396            "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
397            _ => {
398                bail!(
399                    "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
400                )
401            }
402        }
403    }
404}
405
406pub fn run() -> Result<()> {
407    let cli = Cli::parse();
408    let compact = cli.compact;
409    let pretty = cli.pretty;
410    let terse = cli.terse || cli.ultra_terse;
411    let ultra_terse = cli.ultra_terse;
412    let absolute = cli.absolute;
413    let tabular = cli.tabular;
414    let schema = cli.schema;
415    let envelope = cli.envelope;
416    match cli.command {
417        Some(Commands::Search {
418            query,
419            path,
420            limit,
421            strategy,
422            exact,
423            scope,
424            federated,
425            lang,
426            kind,
427            node_kind,
428            section,
429            parent,
430            child,
431            fence_language,
432            list_depth,
433            heading_level,
434            json,
435            autoindex,
436            no_autoindex,
437            timeout,
438            max_items,
439            max_bytes,
440            budget,
441            no_tagpath,
442            tagpath_strict,
443        }) => cmd_search_with_budget(
444            query,
445            path,
446            limit,
447            if exact {
448                Some("exact".to_string())
449            } else {
450                strategy
451            },
452            scope,
453            federated,
454            json || terse || schema || envelope,
455            autoindex || !no_autoindex,
456            timeout,
457            compact,
458            pretty,
459            terse,
460            ultra_terse,
461            absolute,
462            tabular,
463            schema,
464            envelope,
465            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
466            TagpathSearchOpts {
467                no_tagpath,
468                strict: tagpath_strict,
469            },
470            SearchFacetFilters {
471                languages: lang,
472                kinds: kind,
473                node_kinds: node_kind,
474                sections: section,
475                parents: parent,
476                children: child,
477                fence_languages: fence_language,
478                list_depths: list_depth,
479                heading_levels: heading_level,
480            },
481        ),
482        Some(Commands::SearchWorker {
483            path,
484            cache_dir,
485            query,
486            limit,
487            strategy,
488            output,
489        }) => cmd_search_worker(&path, &cache_dir, &query, limit, &strategy, &output),
490        Some(Commands::DigestRunner {
491            kind,
492            path,
493            runner,
494            shell_command,
495            json,
496        }) => cmd_digest_runner(
497            &kind,
498            &path,
499            runner.as_deref(),
500            &shell_command,
501            OutputFormat {
502                json_output: json || terse || schema || envelope,
503                compact,
504                pretty,
505                terse,
506                ultra_terse,
507                schema,
508                envelope,
509            },
510        ),
511        Some(Commands::Edit { dry_run, file }) => {
512            cmd_edit(dry_run, file, compact, pretty, terse, schema)
513        }
514        Some(Commands::EditIntents {
515            path,
516            scope,
517            file,
518            json,
519            apply,
520            verify,
521            verify_command,
522            max_items,
523            max_bytes,
524            budget,
525        }) => cmd_edit_intents(
526            &path,
527            scope.as_deref(),
528            file,
529            apply,
530            SemanticEditVerifyOptions {
531                enabled: verify,
532                command: verify_command.as_deref(),
533            },
534            OutputFormat {
535                json_output: json || terse || schema || envelope,
536                compact,
537                pretty,
538                terse,
539                ultra_terse,
540                schema,
541                envelope,
542            },
543            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
544        ),
545        Some(Commands::Index {
546            path,
547            rebuild,
548            check,
549            exit_code,
550            prune,
551            quiet,
552            workspace,
553            submodule,
554            json,
555        }) => cmd_index(
556            &path,
557            rebuild,
558            check,
559            exit_code,
560            prune,
561            quiet,
562            workspace,
563            submodule.as_deref(),
564            json || terse || schema || envelope,
565            compact,
566            pretty,
567            terse,
568            absolute,
569            schema,
570        ),
571        Some(Commands::Rewrite { command, run }) => cmd_rewrite(
572            &command,
573            run,
574            OutputFormat {
575                json_output: terse || schema || envelope,
576                compact,
577                pretty,
578                terse,
579                ultra_terse,
580                schema,
581                envelope,
582            },
583        ),
584        Some(Commands::Route { task, id }) => cmd_route(&task, id),
585        Some(Commands::Memory { command }) => {
586            let json = command.json_output();
587            cmd_memory(
588                command,
589                OutputFormat {
590                    json_output: json || terse || schema || envelope,
591                    compact,
592                    pretty,
593                    terse,
594                    ultra_terse,
595                    schema,
596                    envelope,
597                },
598            )
599        }
600        Some(Commands::Finding { command }) => match command {
601            cli::FindingCommand::Add {
602                path,
603                kind,
604                title,
605                body,
606                about,
607                confidence,
608                status,
609                relates,
610                scope,
611                json,
612            } => commands::finding::cmd_finding_add(
613                &path,
614                &kind,
615                &title,
616                &body,
617                &about,
618                confidence,
619                &status,
620                relates.as_deref(),
621                scope.as_deref(),
622                json || terse || schema || envelope,
623                pretty,
624            ),
625            cli::FindingCommand::List {
626                path,
627                about,
628                kind,
629                status,
630                include_stale,
631                scope,
632                json,
633            } => commands::finding::cmd_finding_list(
634                &path,
635                about.as_deref(),
636                kind.as_deref(),
637                status.as_deref(),
638                include_stale,
639                scope.as_deref(),
640                json || terse || schema || envelope,
641                pretty,
642            ),
643            cli::FindingCommand::Harvest { path, scope, json } => {
644                commands::finding::cmd_finding_harvest(
645                    &path,
646                    scope.as_deref(),
647                    json || terse || schema || envelope,
648                    pretty,
649                )
650            }
651            cli::FindingCommand::Promote { id, path, json } => {
652                commands::finding::cmd_finding_promote(
653                    &path,
654                    &id,
655                    json || terse || schema || envelope,
656                    pretty,
657                )
658            }
659        },
660        Some(Commands::Graph {
661            symbol,
662            path,
663            callers,
664            callees,
665            scope,
666            limit,
667            json,
668            no_tagpath,
669            tagpath_strict,
670        }) => cmd_graph(
671            &symbol,
672            &path,
673            callers,
674            callees,
675            scope.as_deref(),
676            limit,
677            json || terse || schema || envelope,
678            compact,
679            pretty,
680            terse,
681            absolute,
682            tabular,
683            schema,
684            TagpathSearchOpts {
685                no_tagpath,
686                strict: tagpath_strict,
687            },
688        ),
689        Some(Commands::Sql {
690            db,
691            query,
692            table,
693            json,
694        }) => cmd_sql(
695            &db,
696            query,
697            table,
698            json || terse || schema || envelope,
699            compact,
700            pretty,
701            terse,
702            schema,
703        ),
704        Some(Commands::Communities {
705            path,
706            scope,
707            min_size,
708            limit,
709            json,
710            no_tagpath,
711            tagpath_strict,
712        }) => cmd_communities(
713            &path,
714            scope.as_deref(),
715            min_size,
716            limit,
717            json || terse || schema || envelope,
718            compact,
719            pretty,
720            terse,
721            tabular,
722            schema,
723            TagpathSearchOpts {
724                no_tagpath,
725                strict: tagpath_strict,
726            },
727        ),
728        Some(Commands::Analyze {
729            path,
730            scope,
731            entry_points,
732            limit,
733            json,
734        }) => cmd_analyze(
735            &path,
736            scope.as_deref(),
737            &entry_points,
738            limit,
739            OutputFormat {
740                json_output: json || terse || schema || envelope,
741                compact,
742                pretty,
743                terse,
744                ultra_terse,
745                schema,
746                envelope,
747            },
748        ),
749        Some(Commands::Path {
750            from,
751            to,
752            path,
753            scope,
754            json,
755            no_tagpath,
756            tagpath_strict,
757        }) => cmd_path(
758            &from,
759            &to,
760            &path,
761            scope.as_deref(),
762            json || terse || schema || envelope,
763            compact,
764            pretty,
765            terse,
766            schema,
767            TagpathSearchOpts {
768                no_tagpath,
769                strict: tagpath_strict,
770            },
771        ),
772        Some(Commands::Explain {
773            symbol,
774            path,
775            scope,
776            limit,
777            json,
778            max_items,
779            max_bytes,
780            budget,
781            no_tagpath,
782            tagpath_strict,
783        }) => cmd_explain_with_budget(
784            &symbol,
785            &path,
786            scope.as_deref(),
787            limit,
788            json || terse || schema || envelope,
789            compact,
790            pretty,
791            terse,
792            ultra_terse,
793            absolute,
794            tabular,
795            schema,
796            envelope,
797            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
798            TagpathSearchOpts {
799                no_tagpath,
800                strict: tagpath_strict,
801            },
802        ),
803        Some(Commands::Traverse {
804            node,
805            to,
806            path,
807            scope,
808            depth,
809            limit,
810            format,
811            convex_snapshot,
812        }) => cmd_traverse(
813            node.as_deref(),
814            to.as_deref(),
815            &path,
816            scope.as_deref(),
817            depth,
818            limit,
819            format,
820            pretty,
821            terse,
822            schema,
823            convex_snapshot.as_deref(),
824        ),
825        Some(Commands::ConvexSync {
826            path,
827            scope,
828            snapshot,
829            chunk_size,
830            remote_snapshot,
831            apply,
832            endpoint,
833            auth_token_env,
834            json,
835        }) => cmd_convex_sync(
836            ConvexSyncOptions {
837                path: &path,
838                scope: scope.as_deref(),
839                snapshot: snapshot.as_deref(),
840                chunk_size,
841                remote_snapshot,
842                apply,
843                endpoint: endpoint.as_deref(),
844                auth_token_env: &auth_token_env,
845            },
846            OutputFormat {
847                json_output: json || terse || schema || envelope,
848                compact,
849                pretty,
850                terse,
851                ultra_terse,
852                schema,
853                envelope,
854            },
855        ),
856        Some(Commands::GraphDb {
857            path,
858            scope,
859            backend,
860            convex_snapshot,
861            json,
862            query,
863        }) => cmd_graph_db(
864            &path,
865            scope.as_deref(),
866            backend,
867            convex_snapshot.as_deref(),
868            query,
869            OutputFormat {
870                json_output: json || terse || schema || envelope,
871                compact,
872                pretty,
873                terse,
874                ultra_terse,
875                schema,
876                envelope,
877            },
878        ),
879        Some(Commands::SourceRead {
880            file,
881            path,
882            style,
883            start,
884            lines,
885            end,
886            scope,
887            json,
888            max_items,
889            max_bytes,
890            budget,
891        }) => cmd_source_read(
892            &file,
893            &path,
894            style,
895            start,
896            lines,
897            end,
898            scope.as_deref(),
899            OutputFormat {
900                json_output: json || terse || schema || envelope,
901                compact,
902                pretty,
903                terse,
904                ultra_terse,
905                schema,
906                envelope,
907            },
908            absolute,
909            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
910        ),
911        Some(Commands::MarkdownAst {
912            file,
913            path,
914            node,
915            json,
916            max_items,
917            max_bytes,
918            budget,
919        }) => cmd_markdown_ast(
920            &file,
921            &path,
922            node.as_deref(),
923            OutputFormat {
924                json_output: json || terse || schema || envelope,
925                compact,
926                pretty,
927                terse,
928                ultra_terse,
929                schema,
930                envelope,
931            },
932            absolute,
933            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
934        ),
935        Some(Commands::SymbolRead {
936            symbol,
937            file,
938            path,
939            scope,
940            json,
941            max_items,
942            max_bytes,
943            budget,
944        }) => cmd_symbol_read(
945            &symbol,
946            file.as_deref(),
947            &path,
948            scope.as_deref(),
949            OutputFormat {
950                json_output: json || terse || schema || envelope,
951                compact,
952                pretty,
953                terse,
954                ultra_terse,
955                schema,
956                envelope,
957            },
958            absolute,
959            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
960        ),
961        Some(Commands::Audit {
962            skills_dir,
963            manifest,
964            usage,
965            cleanup,
966            report,
967            json,
968        }) => cmd_audit(
969            &skills_dir,
970            manifest,
971            usage,
972            cleanup,
973            report,
974            json || terse || schema || envelope,
975            compact,
976            pretty,
977            terse,
978            schema,
979        ),
980        Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
981            &path,
982            scope.as_deref(),
983            json || terse || schema || envelope,
984            pretty,
985            terse,
986            schema,
987        ),
988        Some(Commands::Init {
989            path,
990            codex,
991            opencode,
992            workspace,
993        }) => cmd_init(&path, codex, opencode, workspace),
994        Some(Commands::Lint {
995            file,
996            index,
997            entities_from,
998            json,
999        }) => cmd_lint(
1000            &file,
1001            index,
1002            entities_from,
1003            json || terse || schema || envelope,
1004            compact,
1005            pretty,
1006            terse,
1007            schema,
1008        ),
1009        Some(Commands::Summarize {
1010            symbol,
1011            file,
1012            extract,
1013            diff,
1014            stats,
1015            path,
1016            json,
1017        }) => cmd_summarize(
1018            symbol,
1019            file,
1020            extract,
1021            diff,
1022            stats,
1023            &path,
1024            json || terse || schema || envelope,
1025            compact,
1026            pretty,
1027            terse,
1028            schema,
1029        ),
1030        Some(Commands::Semantic {
1031            query,
1032            path,
1033            scope,
1034            limit,
1035            kind,
1036            json,
1037        }) => cmd_semantic_related(
1038            &query,
1039            &path,
1040            scope.as_deref(),
1041            limit,
1042            kind,
1043            json || terse || schema || envelope,
1044            compact,
1045            pretty,
1046            terse,
1047            schema,
1048        ),
1049        Some(Commands::DiffDigest {
1050            path,
1051            cached,
1052            revision,
1053            max_parsed_files,
1054            json,
1055        }) => cmd_diff_digest(
1056            &path,
1057            cached,
1058            revision.as_deref(),
1059            max_parsed_files,
1060            OutputFormat {
1061                json_output: json || terse || schema || envelope,
1062                compact,
1063                pretty,
1064                terse,
1065                ultra_terse,
1066                schema,
1067                envelope,
1068            },
1069        ),
1070        Some(Commands::Impact {
1071            path,
1072            cached,
1073            revision,
1074            scope,
1075            limit,
1076            json,
1077        }) => cmd_impact(
1078            &path,
1079            cached,
1080            revision.as_deref(),
1081            scope.as_deref(),
1082            limit,
1083            OutputFormat {
1084                json_output: json || terse || schema || envelope,
1085                compact,
1086                pretty,
1087                terse,
1088                ultra_terse,
1089                schema,
1090                envelope,
1091            },
1092        ),
1093        Some(Commands::TestDigest {
1094            path,
1095            input,
1096            runner,
1097            json,
1098        }) => cmd_test_digest(
1099            &path,
1100            input.as_deref(),
1101            runner.as_deref(),
1102            OutputFormat {
1103                json_output: json || terse || schema || envelope,
1104                compact,
1105                pretty,
1106                terse,
1107                ultra_terse,
1108                schema,
1109                envelope,
1110            },
1111        ),
1112        Some(Commands::LogDigest {
1113            path,
1114            input,
1115            fixture,
1116            fail_under,
1117            json,
1118        }) => cmd_log_digest(
1119            &path,
1120            input.as_deref(),
1121            fixture.as_deref(),
1122            fail_under,
1123            OutputFormat {
1124                json_output: json || terse || schema || envelope,
1125                compact,
1126                pretty,
1127                terse,
1128                ultra_terse,
1129                schema,
1130                envelope,
1131            },
1132        ),
1133        Some(Commands::ContextPack {
1134            path,
1135            test_input,
1136            runner,
1137            log_input,
1138            json,
1139            max_items,
1140            max_bytes,
1141            budget,
1142            convex_snapshot,
1143        }) => cmd_context_pack(
1144            &path,
1145            test_input.as_deref(),
1146            runner.as_deref(),
1147            log_input.as_deref(),
1148            OutputFormat {
1149                json_output: json || terse || schema || envelope,
1150                compact,
1151                pretty,
1152                terse,
1153                ultra_terse,
1154                schema,
1155                envelope,
1156            },
1157            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1158            convex_snapshot.as_deref(),
1159        ),
1160        Some(Commands::ConflictMatrix {
1161            targets,
1162            path,
1163            scope,
1164            depth,
1165            limit,
1166            impact_limit,
1167            json,
1168        }) => cmd_conflict_matrix(
1169            &path,
1170            scope.as_deref(),
1171            &targets,
1172            depth,
1173            limit,
1174            impact_limit,
1175            OutputFormat {
1176                json_output: json || terse || schema || envelope,
1177                compact,
1178                pretty,
1179                terse,
1180                ultra_terse,
1181                schema,
1182                envelope,
1183            },
1184        ),
1185        Some(Commands::DispatchTrace {
1186            targets,
1187            path,
1188            scope,
1189            depth,
1190            limit,
1191            impact_limit,
1192            format,
1193            json,
1194        }) => cmd_dispatch_trace(
1195            DispatchTraceOptions {
1196                path: &path,
1197                scope: scope.as_deref(),
1198                raw_targets: &targets,
1199                depth,
1200                limit,
1201                impact_limit,
1202                trace_format: if json {
1203                    DispatchTraceFormat::Json
1204                } else {
1205                    format
1206                },
1207            },
1208            OutputFormat {
1209                json_output: json || terse || schema || envelope,
1210                compact,
1211                pretty,
1212                terse,
1213                ultra_terse,
1214                schema,
1215                envelope,
1216            },
1217        ),
1218        Some(Commands::DependencyDag {
1219            targets,
1220            path,
1221            scope,
1222            depth,
1223            limit,
1224            json,
1225        }) => cmd_dependency_dag(
1226            &path,
1227            scope.as_deref(),
1228            &targets,
1229            depth,
1230            limit,
1231            OutputFormat {
1232                json_output: json || terse || schema || envelope,
1233                compact,
1234                pretty,
1235                terse,
1236                ultra_terse,
1237                schema,
1238                envelope,
1239            },
1240        ),
1241        Some(Commands::TokenSavings {
1242            fixture,
1243            fail_under,
1244            json,
1245        }) => token_savings::cmd_token_savings(
1246            &fixture,
1247            fail_under,
1248            OutputFormat {
1249                json_output: json || terse || schema || envelope,
1250                compact,
1251                pretty,
1252                terse,
1253                ultra_terse,
1254                schema,
1255                envelope,
1256            },
1257        ),
1258        Some(Commands::MetricDigest {
1259            input,
1260            baseline,
1261            metrics,
1262            lower_is_better,
1263            higher_is_better,
1264            history,
1265            top,
1266            json,
1267        }) => cmd_metric_digest(
1268            MetricDigestOptions {
1269                input_path: input.as_deref(),
1270                baseline_path: baseline.as_deref(),
1271                metrics: &metrics,
1272                lower_is_better: &lower_is_better,
1273                higher_is_better: &higher_is_better,
1274                history,
1275                top,
1276            },
1277            OutputFormat {
1278                json_output: json || terse || schema || envelope,
1279                compact,
1280                pretty,
1281                terse,
1282                ultra_terse,
1283                schema,
1284                envelope,
1285            },
1286        ),
1287        Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1288            &fixture,
1289            OutputFormat {
1290                json_output: json || terse || schema || envelope,
1291                compact,
1292                pretty,
1293                terse,
1294                ultra_terse,
1295                schema,
1296                envelope,
1297            },
1298        ),
1299        Some(Commands::TokenGate { command }) => {
1300            cmd_token_gate(
1301                command,
1302                OutputFormat {
1303                    json_output: true,
1304                    compact,
1305                    pretty,
1306                    terse,
1307                    ultra_terse,
1308                    schema,
1309                    envelope,
1310                },
1311            )?;
1312            Ok(())
1313        }
1314        Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1315            &topic,
1316            OutputFormat {
1317                json_output: json || terse || schema || envelope,
1318                compact,
1319                pretty,
1320                terse,
1321                ultra_terse,
1322                schema,
1323                envelope,
1324            },
1325        ),
1326        Some(Commands::SessionDigest {
1327            path,
1328            input,
1329            source,
1330            json,
1331        }) => cmd_session_digest(
1332            &path,
1333            input.as_deref(),
1334            source.as_deref(),
1335            OutputFormat {
1336                json_output: json || terse || schema || envelope,
1337                compact,
1338                pretty,
1339                terse,
1340                ultra_terse,
1341                schema,
1342                envelope,
1343            },
1344        ),
1345        Some(Commands::SessionCost {
1346            input,
1347            fixture,
1348            fail_under,
1349            source,
1350            json,
1351        }) => cmd_session_cost(
1352            input.as_deref(),
1353            fixture.as_deref(),
1354            fail_under,
1355            source.as_deref(),
1356            OutputFormat {
1357                json_output: json || terse || schema || envelope,
1358                compact,
1359                pretty,
1360                terse,
1361                ultra_terse,
1362                schema,
1363                envelope,
1364            },
1365        ),
1366        Some(Commands::SessionReview {
1367            path,
1368            next_context,
1369            json,
1370            max_items,
1371            max_bytes,
1372            budget,
1373        }) => cmd_session_review_with_budget(
1374            &path,
1375            next_context,
1376            OutputFormat {
1377                json_output: json || terse || schema || envelope,
1378                compact,
1379                pretty,
1380                terse,
1381                ultra_terse,
1382                schema,
1383                envelope,
1384            },
1385            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1386        ),
1387        Some(Commands::Status {
1388            path,
1389            fix,
1390            no_fix,
1391            json,
1392        }) => cmd_status(
1393            &path,
1394            StatusCommandOptions {
1395                fix,
1396                no_fix,
1397                json_output: json || terse || schema || envelope,
1398                compact,
1399                pretty,
1400                terse,
1401                schema,
1402            },
1403        ),
1404        Some(Commands::Locks { path, scope, json }) => cmd_locks(
1405            &path,
1406            scope.as_deref(),
1407            json || terse || schema || envelope,
1408            compact,
1409            pretty,
1410            terse,
1411            schema,
1412        ),
1413        None => {
1414            println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1415            println!("Run `tsift --help` for usage.");
1416            Ok(())
1417        }
1418    }
1419}
1420
1421/// Classify a task description into a model tier.
1422/// Returns (tier_name, model_id).
1423pub fn classify_task(task: &str) -> (&'static str, &'static str) {
1424    let lower = task.to_lowercase();
1425    // Architecture/design signals → opus
1426    for signal in &[
1427        "architect",
1428        "architecture",
1429        "design",
1430        "plan",
1431        "strateg",
1432        "analy",
1433        "review",
1434        "evaluate",
1435        "assess",
1436    ] {
1437        if lower.contains(signal) {
1438            return ("opus", "claude-opus-4-6");
1439        }
1440    }
1441    // Edit/write signals → sonnet
1442    for signal in &[
1443        "edit",
1444        "write",
1445        "fix",
1446        "change",
1447        "update",
1448        "create",
1449        "add ",
1450        "remove",
1451        "delete",
1452        "modify",
1453        "refactor",
1454        "implement",
1455        "build",
1456    ] {
1457        if lower.contains(signal) {
1458            return ("sonnet", "claude-sonnet-4-6");
1459        }
1460    }
1461    // Default: search/lookup → haiku
1462    ("haiku", "claude-haiku-4-5-20251001")
1463}
1464
1465#[cfg(test)]
1466fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
1467    to_json_schema(val, pretty, terse, false, false)
1468}
1469
1470/// Add top-level `tagpath_index_stale: true` + `tagpath_stale_reason: <reason>`
1471/// fields to a JSON response when the tagpath adapter reported any helper
1472/// going stale. JSON consumers (`tsift --envelope` / `--json` callers) can
1473/// then act on the same condition the stderr `tagpath_index_stale: …` log
1474/// already surfaces without parsing logs. No-op when `stale=false` or when
1475/// `value` is not a JSON object.
1476pub(crate) fn inject_tagpath_stale_into_json(
1477    value: &mut serde_json::Value,
1478    stale: bool,
1479    reason: Option<&str>,
1480) {
1481    if !stale {
1482        return;
1483    }
1484    if let Some(obj) = value.as_object_mut() {
1485        obj.insert(
1486            "tagpath_index_stale".to_string(),
1487            serde_json::Value::Bool(true),
1488        );
1489        if let Some(reason) = reason {
1490            obj.insert(
1491                "tagpath_stale_reason".to_string(),
1492                serde_json::Value::String(reason.to_string()),
1493            );
1494        }
1495    }
1496}
1497
1498pub(crate) fn to_json_schema<T: serde::Serialize>(
1499    val: &T,
1500    pretty: bool,
1501    terse: bool,
1502    ultra_terse: bool,
1503    schema: bool,
1504) -> anyhow::Result<String> {
1505    if terse || schema {
1506        let value = serde_json::to_value(val)?;
1507        let mut transformed = if terse { terse_transform(value) } else { value };
1508        if ultra_terse {
1509            transformed = ultra_terse_transform(transformed);
1510            transformed = edge_index_transform(transformed);
1511        }
1512        if schema {
1513            transformed = schema_transform(transformed);
1514        }
1515        if terse {
1516            let terse_schema = terse_schema_for(&transformed);
1517            let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
1518            if pretty {
1519                Ok(serde_json::to_string_pretty(&wrapped)?)
1520            } else {
1521                Ok(serde_json::to_string(&wrapped)?)
1522            }
1523        } else if pretty {
1524            Ok(serde_json::to_string_pretty(&transformed)?)
1525        } else {
1526            Ok(serde_json::to_string(&transformed)?)
1527        }
1528    } else if pretty {
1529        Ok(serde_json::to_string_pretty(val)?)
1530    } else {
1531        Ok(serde_json::to_string(val)?)
1532    }
1533}
1534
1535pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
1536    ToolEnvelopeMetric {
1537        label: label.to_string(),
1538        value: value.to_string(),
1539    }
1540}
1541
1542pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
1543    let mut seen = HashSet::new();
1544    let mut deduped = Vec::new();
1545    for value in values {
1546        if seen.insert(value.clone()) {
1547            deduped.push(value);
1548        }
1549    }
1550    deduped
1551}
1552
1553pub(crate) fn print_json_or_envelope<T: Serialize>(
1554    report: &T,
1555    format: &OutputFormat,
1556    tool: &str,
1557    view: &str,
1558    summary: ToolEnvelopeSummary,
1559    truncated: bool,
1560    follow_up: Vec<String>,
1561) -> Result<()> {
1562    if format.envelope {
1563        let schema = format.schema || tool == "source-read";
1564        let envelope = ToolEnvelope {
1565            tool,
1566            view,
1567            summary,
1568            truncated,
1569            follow_up: dedupe_preserve_order(follow_up),
1570            report,
1571        };
1572        println!(
1573            "{}",
1574            to_json_schema(
1575                &envelope,
1576                format.pretty,
1577                format.terse,
1578                format.ultra_terse,
1579                schema
1580            )?
1581        );
1582    } else {
1583        println!(
1584            "{}",
1585            to_json_schema(
1586                report,
1587                format.pretty,
1588                format.terse,
1589                format.ultra_terse,
1590                format.schema
1591            )?
1592        );
1593    }
1594    Ok(())
1595}
1596
1597pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
1598    bytes.div_ceil(4)
1599}
1600
1601fn cmd_token_gate(command: cli::TokenGateCommand, format: OutputFormat) -> Result<()> {
1602    match command {
1603        cli::TokenGateCommand::Sample {
1604            surface,
1605            path,
1606            scope,
1607            target,
1608            depth,
1609            sample_index,
1610            json: _,
1611        } => cmd_token_gate_sample(
1612            &surface,
1613            &path,
1614            scope.as_deref(),
1615            target.as_deref(),
1616            depth,
1617            sample_index,
1618        ),
1619        cli::TokenGateCommand::Evaluate {
1620            history,
1621            allowed_regression_percent,
1622            json: _,
1623        } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
1624    }
1625}
1626
1627fn cmd_token_gate_sample(
1628    surface: &str,
1629    path: &Path,
1630    scope: Option<&str>,
1631    target: Option<&str>,
1632    depth: usize,
1633    sample_index: usize,
1634) -> Result<()> {
1635    if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
1636        bail!(
1637            "unknown surface `{}`; expected one of: {}",
1638            surface,
1639            token_gate::TOKEN_GATE_SURFACES.join(", ")
1640        );
1641    }
1642
1643    let path_str = path.to_string_lossy().to_string();
1644    let tsift_bin = std::env::current_exe()?;
1645
1646    let args: Vec<String> = match surface {
1647        "context_pack" => vec!["context-pack".to_string(), "--json".to_string(), path_str],
1648        "session_review_next_context" => vec![
1649            "session-review".to_string(),
1650            "--json".to_string(),
1651            "--next-context".to_string(),
1652            path_str,
1653        ],
1654        "graph_db_evidence" => {
1655            let tgt = target.unwrap_or("default").to_string();
1656            vec![
1657                "graph-db".to_string(),
1658                "--json".to_string(),
1659                "--path".to_string(),
1660                path_str,
1661                "evidence".to_string(),
1662                tgt,
1663                "--depth".to_string(),
1664                depth.to_string(),
1665            ]
1666        }
1667        "conflict_matrix" => {
1668            let tgt = target.unwrap_or("default").to_string();
1669            let mut a = vec![
1670                "conflict-matrix".to_string(),
1671                "--json".to_string(),
1672                "--path".to_string(),
1673                path_str,
1674                "--depth".to_string(),
1675                depth.to_string(),
1676            ];
1677            if let Some(s) = scope {
1678                a.push("--scope".to_string());
1679                a.push(s.to_string());
1680            }
1681            a.push(tgt);
1682            a
1683        }
1684        "dispatch_trace" => {
1685            let tgt = target.unwrap_or("default").to_string();
1686            vec![
1687                "dispatch-trace".to_string(),
1688                "--json".to_string(),
1689                "--path".to_string(),
1690                path_str,
1691                tgt,
1692            ]
1693        }
1694        _ => bail!("unhandled surface: {}", surface),
1695    };
1696
1697    let start = Instant::now();
1698    let child = Command::new(&tsift_bin)
1699        .args(&args)
1700        .stdout(Stdio::piped())
1701        .stderr(Stdio::piped())
1702        .env("TSIFT_QUIET", "1")
1703        .spawn();
1704    let output = match child {
1705        Ok(c) => c.wait_with_output()?,
1706        Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
1707    };
1708    let runtime_micros = start.elapsed().as_micros() as f64;
1709
1710    let stdout = String::from_utf8_lossy(&output.stdout);
1711    let envelope_bytes = stdout.trim().len() as f64;
1712    let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
1713
1714    let cache_hit_rate_percent = 0.0;
1715    let raw_read_avoidance = 0.0;
1716    let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
1717
1718    let timestamp = iso_timestamp_now();
1719    let id = format!(
1720        "{surface}-baseline-{}-sample-{sample_index}",
1721        &timestamp[..10]
1722    );
1723    let label = format!(
1724        "token-gate baseline {surface} sample {sample_index} for {}",
1725        path.display()
1726    );
1727
1728    let mut metrics = BTreeMap::new();
1729    metrics.insert("prompt_tokens".to_string(), prompt_tokens);
1730    metrics.insert("envelope_bytes".to_string(), envelope_bytes);
1731    metrics.insert("runtime_micros".to_string(), runtime_micros);
1732    metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
1733    metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
1734    metrics.insert("useful_hit_density".to_string(), useful_hit_density);
1735
1736    let sample = token_gate::TokenGateSample {
1737        label,
1738        id,
1739        timestamp: Some(timestamp),
1740        surface: surface.to_string(),
1741        metrics,
1742    };
1743
1744    println!("{}", serde_json::to_string_pretty(&sample)?);
1745    Ok(())
1746}
1747
1748fn cmd_token_gate_evaluate(
1749    history_path: Option<&Path>,
1750    allowed_regression_percent: f64,
1751    format: &OutputFormat,
1752) -> Result<()> {
1753    let history_path = history_path.map(PathBuf::from).unwrap_or_else(|| {
1754        let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1755        p.push("../../fixtures/token-gate-history.json");
1756        p
1757    });
1758
1759    let raw = std::fs::read_to_string(&history_path).with_context(|| {
1760        format!(
1761            "failed to read token gate history: {}",
1762            history_path.display()
1763        )
1764    })?;
1765    let samples = token_gate::parse_token_history(&raw)?;
1766    let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
1767
1768    if format.json_output {
1769        println!(
1770            "{}",
1771            to_json_schema(&report, format.pretty, format.terse, false, format.schema)?
1772        );
1773    } else {
1774        println!("Token Gate Report");
1775        println!("  min_samples: {}", report.min_samples);
1776        println!(
1777            "  allowed_regression: {:.1}%",
1778            report.allowed_regression_percent
1779        );
1780        println!("  decision: {:?}", report.decision);
1781        for eval in &report.surface_evaluations {
1782            println!(
1783                "  {} ({} samples): {:?}",
1784                eval.display_name, eval.sample_count, eval.verdict
1785            );
1786            for me in &eval.metric_evaluations {
1787                println!("    {} ({:?}): {}", me.metric, me.direction, me.diagnostic);
1788            }
1789        }
1790        for d in &report.diagnostics {
1791            println!("  ! {}", d);
1792        }
1793    }
1794    Ok(())
1795}
1796
1797fn iso_timestamp_now() -> String {
1798    let dur = SystemTime::now()
1799        .duration_since(UNIX_EPOCH)
1800        .unwrap_or_default();
1801    let total_secs = dur.as_secs();
1802    let days_since_epoch = total_secs / 86400;
1803    let (year, month, day) = days_to_ymd(days_since_epoch);
1804    let time_of_day = total_secs % 86400;
1805    let hour = (time_of_day / 3600) as u8;
1806    let minute = ((time_of_day % 3600) / 60) as u8;
1807    let second = (time_of_day % 60) as u8;
1808    format!(
1809        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1810        year, month, day, hour, minute, second
1811    )
1812}
1813
1814fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
1815    let mut year = 1970u64;
1816    loop {
1817        let days_in_year = if is_leap(year) { 366 } else { 365 };
1818        if days < days_in_year {
1819            break;
1820        }
1821        days -= days_in_year;
1822        year += 1;
1823    }
1824    let leap = is_leap(year);
1825    let month_days: [u8; 12] = if leap {
1826        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1827    } else {
1828        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1829    };
1830    let mut month: u8 = 1;
1831    for &md in &month_days {
1832        if days < md as u64 {
1833            break;
1834        }
1835        days -= md as u64;
1836        month += 1;
1837    }
1838    let day = days as u8 + 1;
1839    (year, month, day)
1840}
1841
1842fn is_leap(year: u64) -> bool {
1843    year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
1844}
1845
1846fn persist_transcript_artifact(
1847    root: &Path,
1848    prefix: &str,
1849    suffix: &str,
1850    key: &str,
1851    body: &str,
1852    expand: String,
1853) -> Result<TranscriptArtifactRef> {
1854    let handle = stable_handle(prefix, key);
1855    let artifacts_dir = root.join(".tsift/artifacts");
1856    fs::create_dir_all(&artifacts_dir).with_context(|| {
1857        format!(
1858            "creating transcript artifacts dir: {}",
1859            artifacts_dir.display()
1860        )
1861    })?;
1862    let file_name = format!("{handle}.{suffix}");
1863    let artifact_path = artifacts_dir.join(file_name);
1864    fs::write(&artifact_path, body)
1865        .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
1866    let rel_path = relativize_pathbuf(&artifact_path, root);
1867    Ok(TranscriptArtifactRef {
1868        handle,
1869        path: rel_path.display().to_string(),
1870        bytes: body.len(),
1871        lines: body.lines().count(),
1872        expand,
1873    })
1874}
1875
1876fn terse_key(key: &str) -> &str {
1877    match key {
1878        "name" => "n",
1879        "kind" => "k",
1880        "file" => "f",
1881        "line" => "l",
1882        "path" => "p",
1883        "from" => "fr",
1884        "type" => "ty",
1885        "text" => "tx",
1886        "new" => "nw",
1887        "run" => "r",
1888        "use" => "u",
1889        "score" => "sc",
1890        "language" => "la",
1891        "status" => "st",
1892        "state" => "stt",
1893        "error" => "err",
1894        "errors" => "ers",
1895        "hops" => "hp",
1896        "tags" => "tg",
1897        "model" => "ml",
1898        "skill" => "sk",
1899        "count" => "ct",
1900        "total" => "tot",
1901        "column" => "col",
1902        "description" => "dsc",
1903        "end_line" => "el",
1904        "signature" => "sig",
1905        "parent_module" => "pm",
1906        "visibility" => "vis",
1907        "match_type" => "mt",
1908        "caller_file" => "cf",
1909        "caller_name" => "cn",
1910        "caller_line" => "cl",
1911        "callee_name" => "en",
1912        "call_site_line" => "csl",
1913        "members" => "m",
1914        "refs" => "refs",
1915        "role" => "rl",
1916        "peer" => "pr",
1917        "modularity" => "q",
1918        "modularity_contribution" => "mc",
1919        "iterations" => "it",
1920        "node_count" => "nc",
1921        "edge_count" => "ec",
1922        "community_count" => "cc",
1923        "communities" => "cms",
1924        "community" => "cm",
1925        "community_diagnostics" => "cd",
1926        "cache_hit" => "cah",
1927        "tagpath_state" => "tps",
1928        "tagpath_stale_reason" => "tsr",
1929        "annotated_community_count" => "acc",
1930        "annotated_member_count" => "amc",
1931        "ambiguous_member_count" => "ambc",
1932        "ambiguous_members" => "amb",
1933        "candidate_count" => "cand",
1934        "tagpath_candidate_count" => "tcand",
1935        "evidence" => "ev",
1936        "chosen_file" => "chf",
1937        "symbol" => "s",
1938        "symbols" => "sy",
1939        "definitions" => "df",
1940        "callers" => "crs",
1941        "callees" => "ces",
1942        "total_tracked" => "tt",
1943        "modified" => "md",
1944        "deleted" => "dl",
1945        "unchanged" => "uc",
1946        "changes" => "ch",
1947        "prune_stats" => "ps",
1948        "hits" => "h",
1949        "rank" => "rk",
1950        "snippet" => "sn",
1951        "confidence" => "co",
1952        "index" => "ix",
1953        "summaries" => "sms",
1954        "recommendations" => "rec",
1955        "total_files" => "tf",
1956        "stale_files" => "sf",
1957        "last_indexed_secs_ago" => "age",
1958        "cached_files" => "caf",
1959        "total_indexed_files" => "tif",
1960        "coverage_pct" => "cov",
1961        "symbol_name" => "syn",
1962        "file_path" => "fp",
1963        "content_hash" => "hsh",
1964        "summary" => "sum",
1965        "tool" => "tl",
1966        "view" => "vw",
1967        "truncated" => "tr",
1968        "follow_up" => "fu",
1969        "report" => "rp",
1970        "metrics" => "ms",
1971        "label" => "lb",
1972        "value" => "v",
1973        "command" => "cmd",
1974        "exit_code" => "xc",
1975        "success" => "ok",
1976        "artifact" => "art",
1977        "digest" => "dg",
1978        "bytes" => "bt",
1979        "lines" => "lns",
1980        "expand" => "xp",
1981        "entities" => "ent",
1982        "relationships" => "rel",
1983        "concept_labels" => "cls",
1984        "extracted_at" => "at",
1985        "tokens_input" => "ti",
1986        "tokens_output" => "tout",
1987        "total_summaries" => "ts",
1988        "stale_count" => "stc",
1989        "total_tokens_input" => "tti",
1990        "total_tokens_output" => "tto",
1991        "estimated_tokens_saved" => "ets",
1992        "files_processed" => "fps",
1993        "symbols_extracted" => "se",
1994        "skills_dir" => "sd",
1995        "healthy" => "ok",
1996        "broken" => "brk",
1997        "skills" => "sks",
1998        "manifest_diffs" => "mdf",
1999        "similar_pairs" => "sim",
2000        "usage" => "usg",
2001        "cleanup" => "cln",
2002        "has_skill_md" => "hsm",
2003        "is_symlink" => "isl",
2004        "issues" => "iss",
2005        "invocation_count" => "inv",
2006        "reasons" => "rsn",
2007        "token_estimate" => "te",
2008        "skill_a" => "sa",
2009        "skill_b" => "sb",
2010        "desc_a" => "da",
2011        "desc_b" => "db",
2012        "annotations" => "ann",
2013        "entity" => "ety",
2014        "suggestion" => "sug",
2015        "columns" => "cols",
2016        "row_count" => "rc",
2017        "notnull" => "nn",
2018        "default_value" => "dv",
2019        "replace_all" => "ra",
2020        other => other,
2021    }
2022}
2023
2024fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2025    match val {
2026        serde_json::Value::Object(map) => {
2027            let mut new_map = serde_json::Map::new();
2028            for (k, v) in map {
2029                new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2030            }
2031            serde_json::Value::Object(new_map)
2032        }
2033        serde_json::Value::Array(arr) => {
2034            serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2035        }
2036        other => other,
2037    }
2038}
2039
2040fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2041    match val {
2042        serde_json::Value::Object(mut map) => {
2043            let is_graph_node =
2044                map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2045            let is_graph_edge =
2046                map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2047            if is_graph_node || is_graph_edge {
2048                map.remove("properties");
2049                map.remove("provenance");
2050                map.remove("freshness");
2051            }
2052            if is_graph_edge && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2053                *s = abbreviate_edge_kind(s).to_string();
2054            }
2055            let is_coverage = map.contains_key("mode")
2056                && (map.contains_key("total_sector_count")
2057                    || map.contains_key("dirty_sector_count"));
2058            if is_coverage {
2059                map.remove("active_rebuild");
2060                map.remove("completed_dirty_sector_count");
2061                map.remove("mounted_sector_count");
2062                map.remove("rebuilding_sector_count");
2063                map.remove("resumed_sector_count");
2064                map.remove("reused_sector_count");
2065            }
2066            if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2067                *s = truncate_for_ultra_terse(s, 80);
2068            }
2069            if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2070                *s = truncate_for_ultra_terse(s, 80);
2071            }
2072            let new_map: serde_json::Map<String, serde_json::Value> = map
2073                .into_iter()
2074                .map(|(k, v)| (k, ultra_terse_transform(v)))
2075                .collect();
2076            serde_json::Value::Object(new_map)
2077        }
2078        serde_json::Value::Array(arr) => {
2079            serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2080        }
2081        other => other,
2082    }
2083}
2084
2085fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2086    match val {
2087        serde_json::Value::Object(mut map) => {
2088            let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2089                nodes.as_array().map(|arr| {
2090                    arr.iter()
2091                        .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2092                        .collect()
2093                })
2094            });
2095            if let Some(ref ids) = node_ids {
2096                let id_map: std::collections::HashMap<&str, usize> = ids
2097                    .iter()
2098                    .enumerate()
2099                    .map(|(i, id)| (id.as_str(), i))
2100                    .collect();
2101                if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2102                    for edge in edges.iter_mut() {
2103                        if let serde_json::Value::Object(edge_map) = edge {
2104                            if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id")
2105                            {
2106                                if let Some(&idx) = id_map.get(fid.as_str()) {
2107                                    edge_map.insert(
2108                                        "from".to_string(),
2109                                        serde_json::Value::Number(idx.into()),
2110                                    );
2111                                } else {
2112                                    edge_map.insert(
2113                                        "from_id".to_string(),
2114                                        serde_json::Value::String(fid),
2115                                    );
2116                                }
2117                            }
2118                            if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2119                                if let Some(&idx) = id_map.get(tid.as_str()) {
2120                                    edge_map.insert(
2121                                        "to".to_string(),
2122                                        serde_json::Value::Number(idx.into()),
2123                                    );
2124                                } else {
2125                                    edge_map.insert(
2126                                        "to_id".to_string(),
2127                                        serde_json::Value::String(tid),
2128                                    );
2129                                }
2130                            }
2131                        }
2132                    }
2133                }
2134            }
2135            let new_map: serde_json::Map<String, serde_json::Value> = map
2136                .into_iter()
2137                .map(|(k, v)| (k, edge_index_transform(v)))
2138                .collect();
2139            serde_json::Value::Object(new_map)
2140        }
2141        serde_json::Value::Array(arr) => {
2142            serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2143        }
2144        other => other,
2145    }
2146}
2147
2148fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2149    if s.len() <= max_len {
2150        s.to_string()
2151    } else {
2152        let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2153        format!("{truncated}...")
2154    }
2155}
2156
2157fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2158    let mut keys = HashSet::new();
2159    collect_terse_keys(val, &mut keys);
2160    let mut schema = serde_json::Map::new();
2161    for (long, short) in TERSE_PAIRS {
2162        if keys.contains(*short) {
2163            schema.insert(
2164                short.to_string(),
2165                serde_json::Value::String(long.to_string()),
2166            );
2167        }
2168    }
2169    serde_json::Value::Object(schema)
2170}
2171
2172fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2173    match val {
2174        serde_json::Value::Object(map) => {
2175            for (k, v) in map {
2176                keys.insert(k.clone());
2177                collect_terse_keys(v, keys);
2178            }
2179        }
2180        serde_json::Value::Array(arr) => {
2181            for v in arr {
2182                collect_terse_keys(v, keys);
2183            }
2184        }
2185        _ => {}
2186    }
2187}
2188
2189fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2190    match val {
2191        serde_json::Value::Array(arr) if arr.len() >= 2 => {
2192            if let Some(cols) = homogeneous_keys(&arr) {
2193                let rows: Vec<serde_json::Value> = arr
2194                    .into_iter()
2195                    .map(|item| {
2196                        if let serde_json::Value::Object(map) = item {
2197                            let vals: Vec<serde_json::Value> = cols
2198                                .iter()
2199                                .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2200                                .collect();
2201                            serde_json::Value::Array(vals)
2202                        } else {
2203                            item
2204                        }
2205                    })
2206                    .collect();
2207                let col_vals: Vec<serde_json::Value> =
2208                    cols.into_iter().map(serde_json::Value::String).collect();
2209                serde_json::json!({"_c": col_vals, "_r": rows})
2210            } else {
2211                serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2212            }
2213        }
2214        serde_json::Value::Array(arr) => {
2215            serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2216        }
2217        serde_json::Value::Object(map) => {
2218            let new_map: serde_json::Map<String, serde_json::Value> = map
2219                .into_iter()
2220                .map(|(k, v)| (k, schema_transform(v)))
2221                .collect();
2222            serde_json::Value::Object(new_map)
2223        }
2224        other => other,
2225    }
2226}
2227
2228fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2229    let first = arr.first()?.as_object()?;
2230    let keys: Vec<String> = first.keys().cloned().collect();
2231    for item in &arr[1..] {
2232        let obj = item.as_object()?;
2233        if obj.len() != keys.len() {
2234            return None;
2235        }
2236        for k in &keys {
2237            if !obj.contains_key(k) {
2238                return None;
2239            }
2240        }
2241    }
2242    Some(keys)
2243}
2244
2245const TERSE_PAIRS: &[(&str, &str)] = &[
2246    ("name", "n"),
2247    ("kind", "k"),
2248    ("file", "f"),
2249    ("line", "l"),
2250    ("path", "p"),
2251    ("from", "fr"),
2252    ("type", "ty"),
2253    ("text", "tx"),
2254    ("new", "nw"),
2255    ("run", "r"),
2256    ("use", "u"),
2257    ("score", "sc"),
2258    ("language", "la"),
2259    ("status", "st"),
2260    ("state", "stt"),
2261    ("error", "err"),
2262    ("errors", "ers"),
2263    ("hops", "hp"),
2264    ("tags", "tg"),
2265    ("model", "ml"),
2266    ("skill", "sk"),
2267    ("count", "ct"),
2268    ("total", "tot"),
2269    ("column", "col"),
2270    ("description", "dsc"),
2271    ("end_line", "el"),
2272    ("signature", "sig"),
2273    ("parent_module", "pm"),
2274    ("visibility", "vis"),
2275    ("match_type", "mt"),
2276    ("caller_file", "cf"),
2277    ("caller_name", "cn"),
2278    ("caller_line", "cl"),
2279    ("callee_name", "en"),
2280    ("call_site_line", "csl"),
2281    ("members", "m"),
2282    ("refs", "refs"),
2283    ("role", "rl"),
2284    ("peer", "pr"),
2285    ("modularity", "q"),
2286    ("modularity_contribution", "mc"),
2287    ("iterations", "it"),
2288    ("node_count", "nc"),
2289    ("edge_count", "ec"),
2290    ("community_count", "cc"),
2291    ("communities", "cms"),
2292    ("community", "cm"),
2293    ("community_diagnostics", "cd"),
2294    ("cache_hit", "cah"),
2295    ("tagpath_state", "tps"),
2296    ("tagpath_stale_reason", "tsr"),
2297    ("annotated_community_count", "acc"),
2298    ("annotated_member_count", "amc"),
2299    ("ambiguous_member_count", "ambc"),
2300    ("ambiguous_members", "amb"),
2301    ("candidate_count", "cand"),
2302    ("tagpath_candidate_count", "tcand"),
2303    ("evidence", "ev"),
2304    ("chosen_file", "chf"),
2305    ("symbol", "s"),
2306    ("symbols", "sy"),
2307    ("definitions", "df"),
2308    ("callers", "crs"),
2309    ("callees", "ces"),
2310    ("total_tracked", "tt"),
2311    ("modified", "md"),
2312    ("deleted", "dl"),
2313    ("unchanged", "uc"),
2314    ("changes", "ch"),
2315    ("prune_stats", "ps"),
2316    ("hits", "h"),
2317    ("rank", "rk"),
2318    ("snippet", "sn"),
2319    ("confidence", "co"),
2320    ("index", "ix"),
2321    ("summaries", "sms"),
2322    ("recommendations", "rec"),
2323    ("total_files", "tf"),
2324    ("stale_files", "sf"),
2325    ("last_indexed_secs_ago", "age"),
2326    ("cached_files", "caf"),
2327    ("total_indexed_files", "tif"),
2328    ("coverage_pct", "cov"),
2329    ("symbol_name", "syn"),
2330    ("file_path", "fp"),
2331    ("content_hash", "hsh"),
2332    ("summary", "sum"),
2333    ("tool", "tl"),
2334    ("view", "vw"),
2335    ("truncated", "tr"),
2336    ("follow_up", "fu"),
2337    ("report", "rp"),
2338    ("metrics", "ms"),
2339    ("label", "lb"),
2340    ("value", "v"),
2341    ("command", "cmd"),
2342    ("exit_code", "xc"),
2343    ("success", "ok"),
2344    ("artifact", "art"),
2345    ("digest", "dg"),
2346    ("bytes", "bt"),
2347    ("lines", "lns"),
2348    ("expand", "xp"),
2349    ("entities", "ent"),
2350    ("relationships", "rel"),
2351    ("concept_labels", "cls"),
2352    ("extracted_at", "at"),
2353    ("tokens_input", "ti"),
2354    ("tokens_output", "tout"),
2355    ("total_summaries", "ts"),
2356    ("stale_count", "stc"),
2357    ("total_tokens_input", "tti"),
2358    ("total_tokens_output", "tto"),
2359    ("estimated_tokens_saved", "ets"),
2360    ("files_processed", "fps"),
2361    ("symbols_extracted", "se"),
2362    ("skills_dir", "sd"),
2363    ("healthy", "ok"),
2364    ("broken", "brk"),
2365    ("skills", "sks"),
2366    ("manifest_diffs", "mdf"),
2367    ("similar_pairs", "sim"),
2368    ("usage", "usg"),
2369    ("cleanup", "cln"),
2370    ("has_skill_md", "hsm"),
2371    ("is_symlink", "isl"),
2372    ("issues", "iss"),
2373    ("invocation_count", "inv"),
2374    ("reasons", "rsn"),
2375    ("token_estimate", "te"),
2376    ("skill_a", "sa"),
2377    ("skill_b", "sb"),
2378    ("desc_a", "da"),
2379    ("desc_b", "db"),
2380    ("annotations", "ann"),
2381    ("entity", "ety"),
2382    ("suggestion", "sug"),
2383    ("columns", "cols"),
2384    ("row_count", "rc"),
2385    ("notnull", "nn"),
2386    ("default_value", "dv"),
2387    ("replace_all", "ra"),
2388];
2389
2390pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
2391    let root_str = root.to_string_lossy();
2392    let prefix = format!("{}/", root_str.trim_end_matches('/'));
2393    path.strip_prefix(&prefix).unwrap_or(path).to_string()
2394}
2395
2396fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
2397    let canonical = path
2398        .canonicalize()
2399        .with_context(|| format!("canonicalizing {}", path.display()))?;
2400    let start = if canonical.is_dir() {
2401        canonical.clone()
2402    } else {
2403        canonical
2404            .parent()
2405            .map(Path::to_path_buf)
2406            .unwrap_or_else(|| canonical.clone())
2407    };
2408
2409    for ancestor in start.ancestors() {
2410        if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
2411            return Ok(ancestor.to_path_buf());
2412        }
2413    }
2414
2415    Ok(start)
2416}
2417
2418pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
2419    path.strip_prefix(root)
2420        .map(|p| p.to_path_buf())
2421        .unwrap_or_else(|_| path.to_path_buf())
2422}
2423
2424pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
2425    for edge in edges {
2426        edge.caller_file = relativize(&edge.caller_file, root);
2427    }
2428}
2429
2430pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
2431    for sym in symbols {
2432        sym.file = relativize(&sym.file, root);
2433    }
2434}
2435
2436pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
2437    for hit in hits {
2438        hit.file = relativize(&hit.file, root);
2439    }
2440}
2441
2442/// Which endpoint of a `StoredEdge` is the row's primary symbol — caller
2443/// (caller list) or callee (callee list).
2444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2445pub enum EdgeSide {
2446    Caller,
2447    Callee,
2448}
2449
2450const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
2451
2452pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
2453    let root_str = root.to_string_lossy();
2454    let prefix = format!("{}/", root_str.trim_end_matches('/'));
2455    relativize_json_inner(val, &prefix);
2456}
2457
2458fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
2459    match val {
2460        serde_json::Value::Array(arr) => {
2461            for v in arr {
2462                relativize_json_inner(v, prefix);
2463            }
2464        }
2465        serde_json::Value::Object(map) => {
2466            for (k, v) in map.iter_mut() {
2467                if JSON_PATH_KEYS.contains(&k.as_str())
2468                    && let serde_json::Value::String(s) = v
2469                    && let Some(rest) = s.strip_prefix(prefix)
2470                {
2471                    *s = rest.to_string();
2472                }
2473                relativize_json_inner(v, prefix);
2474            }
2475        }
2476        _ => {}
2477    }
2478}
2479
2480pub(crate) fn format_score(score: f64, compact: bool) -> String {
2481    if compact {
2482        format!("{score:.2}")
2483    } else {
2484        format!("{score:.4}")
2485    }
2486}
2487
2488pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
2489    let trimmed = input.trim();
2490    let count = trimmed.chars().count();
2491    if count <= max_chars {
2492        return trimmed.to_string();
2493    }
2494    let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
2495    format!("{prefix}...")
2496}
2497
2498pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
2499    snippet
2500        .lines()
2501        .find(|line| !line.trim().is_empty())
2502        .map(|line| truncate_for_compact(line, 100))
2503}
2504
2505pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
2506    let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
2507    if names.len() <= limit {
2508        return names.join(", ");
2509    }
2510    format!(
2511        "{} (+{} more)",
2512        names[..limit].join(", "),
2513        names.len() - limit
2514    )
2515}
2516
2517pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
2518    let mut hasher = blake3::Hasher::new();
2519    hasher.update(prefix.as_bytes());
2520    hasher.update(&[0]);
2521    hasher.update(key.as_bytes());
2522    let hex = hasher.finalize().to_hex();
2523    format!("{prefix}-{}", &hex[..10])
2524}
2525
2526#[derive(Clone, Debug, PartialEq, Eq)]
2527struct CanonicalTagFamily {
2528    canonical: String,
2529    tag_alias: String,
2530}
2531
2532fn canonical_family_from_tagpath_family(
2533    family: tagpath_family::TagFamily,
2534) -> Option<CanonicalTagFamily> {
2535    let tag_alias = if family.dimensions.is_empty() {
2536        family.tags.join("/")
2537    } else {
2538        family
2539            .dimensions
2540            .iter()
2541            .filter(|dimension| !dimension.tags.is_empty())
2542            .map(|dimension| dimension.tags.join("."))
2543            .collect::<Vec<_>>()
2544            .join("/")
2545    };
2546
2547    if tag_alias.is_empty() {
2548        None
2549    } else {
2550        Some(CanonicalTagFamily {
2551            canonical: family.canonical,
2552            tag_alias,
2553        })
2554    }
2555}
2556
2557fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
2558    let trimmed = name.trim();
2559    if trimmed.is_empty() {
2560        return None;
2561    }
2562
2563    canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
2564}
2565
2566fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
2567    let canonical = tags
2568        .split(',')
2569        .map(str::trim)
2570        .filter(|tag| !tag.is_empty())
2571        .collect::<Vec<_>>()
2572        .join("_");
2573    if canonical.is_empty() {
2574        None
2575    } else {
2576        canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
2577    }
2578}
2579
2580pub(crate) fn canonical_tag_family_from_symbol(
2581    name: &str,
2582    tags: Option<&str>,
2583) -> Option<CanonicalTagFamily> {
2584    tags.and_then(canonical_tag_family_from_tags)
2585        .or_else(|| canonical_tag_family_from_name(name))
2586}
2587
2588fn tag_alias_from_name(name: &str) -> Option<String> {
2589    canonical_tag_family_from_name(name).map(|family| family.tag_alias)
2590}
2591
2592fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
2593    canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
2594}
2595
2596pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
2597    let query = tag_alias
2598        .split(['/', '.'])
2599        .map(str::trim)
2600        .filter(|part| !part.is_empty())
2601        .collect::<Vec<_>>()
2602        .join(" ");
2603    if query.is_empty() { None } else { Some(query) }
2604}
2605
2606#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2607struct CompactOntologyRefPreview {
2608    handle: String,
2609    tag: String,
2610    path: String,
2611    #[serde(skip_serializing_if = "Option::is_none")]
2612    title: Option<String>,
2613    #[serde(skip_serializing_if = "Option::is_none")]
2614    domain: Option<String>,
2615}
2616
2617#[derive(Clone, Debug)]
2618struct TagOntologyPreviewContext {
2619    project_root: PathBuf,
2620    tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
2621}
2622
2623#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2624struct CompactSymbolRefPreview {
2625    handle: String,
2626    name: String,
2627    #[serde(skip_serializing_if = "Option::is_none")]
2628    tag_alias: Option<String>,
2629    #[serde(skip_serializing_if = "Vec::is_empty", default)]
2630    ontology_refs: Vec<CompactOntologyRefPreview>,
2631}
2632
2633fn build_compact_symbol_ref(
2634    prefix: &str,
2635    key: &str,
2636    name: &str,
2637    tags: Option<&str>,
2638    max_bytes: usize,
2639) -> CompactSymbolRefPreview {
2640    build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
2641}
2642
2643fn build_compact_symbol_ref_with_ontology(
2644    prefix: &str,
2645    key: &str,
2646    name: &str,
2647    tags: Option<&str>,
2648    max_bytes: usize,
2649    ontology: Option<&TagOntologyPreviewContext>,
2650) -> CompactSymbolRefPreview {
2651    let tag_alias = tag_alias_from_tags(name, tags);
2652    let ontology_refs = tag_alias
2653        .as_deref()
2654        .map(|alias| ontology_refs_for_alias(ontology, alias))
2655        .unwrap_or_default();
2656    CompactSymbolRefPreview {
2657        handle: stable_handle(prefix, key),
2658        name: truncate_for_budget(name, max_bytes),
2659        tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
2660        ontology_refs,
2661    }
2662}
2663
2664fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
2665    let report = tagpath_ontology::load_project(root).ok()?;
2666    if report.tags.is_empty() {
2667        return None;
2668    }
2669    Some(TagOntologyPreviewContext {
2670        project_root: report.project_path,
2671        tags: report
2672            .tags
2673            .into_iter()
2674            .map(|tag| (tag.tag.clone(), tag))
2675            .collect(),
2676    })
2677}
2678
2679fn ontology_refs_for_alias(
2680    ontology: Option<&TagOntologyPreviewContext>,
2681    alias: &str,
2682) -> Vec<CompactOntologyRefPreview> {
2683    let Some(ontology) = ontology else {
2684        return Vec::new();
2685    };
2686    let mut seen = BTreeSet::new();
2687    alias
2688        .split('/')
2689        .flat_map(|part| part.split('.'))
2690        .map(str::trim)
2691        .filter(|tag| !tag.is_empty())
2692        .filter_map(|tag| {
2693            let key = tag.to_ascii_lowercase();
2694            if !seen.insert(key.clone()) {
2695                return None;
2696            }
2697            let ontology_tag = ontology.tags.get(&key)?;
2698            let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
2699            Some(CompactOntologyRefPreview {
2700                handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
2701                tag: ontology_tag.tag.clone(),
2702                path,
2703                title: ontology_tag.title.clone(),
2704                domain: ontology_tag.domain.clone(),
2705            })
2706        })
2707        .collect()
2708}
2709
2710fn relativize_ontology_path(path: &Path, root: &Path) -> String {
2711    path.strip_prefix(root)
2712        .unwrap_or(path)
2713        .to_string_lossy()
2714        .replace('\\', "/")
2715}
2716
2717fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
2718    match tag_alias {
2719        Some(alias) => format!("{handle} {name} tag:{alias}"),
2720        None => format!("{handle} {name}"),
2721    }
2722}
2723
2724fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
2725    match summary.tag_alias.as_deref() {
2726        Some(alias) => format!(
2727            "{} {} tag:{} expand:{}",
2728            summary.handle, summary.symbol, alias, summary.expand
2729        ),
2730        None => format!(
2731            "{} {} expand:{}",
2732            summary.handle, summary.symbol, summary.expand
2733        ),
2734    }
2735}
2736
2737fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
2738    match symbol.tag_alias.as_deref() {
2739        Some(alias) => format!("{}@{}", symbol.handle, alias),
2740        None => format!("{}@{}", symbol.handle, symbol.name),
2741    }
2742}
2743
2744pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
2745    let trimmed = input.trim();
2746    if trimmed.len() <= max_bytes {
2747        return trimmed.to_string();
2748    }
2749    if max_bytes <= 3 {
2750        return ".".repeat(max_bytes);
2751    }
2752
2753    let mut end = 0usize;
2754    for (idx, ch) in trimmed.char_indices() {
2755        let next = idx + ch.len_utf8();
2756        if next > max_bytes.saturating_sub(3) {
2757            break;
2758        }
2759        end = next;
2760    }
2761
2762    if end == 0 {
2763        "...".to_string()
2764    } else {
2765        format!("{}...", &trimmed[..end])
2766    }
2767}
2768
2769struct TokenCappedPreview {
2770    preview: Vec<SourceLinePreview>,
2771    capped_end: usize,
2772    was_capped: bool,
2773}
2774
2775fn build_token_capped_preview(
2776    all_lines: &[&str],
2777    start: usize,
2778    end: usize,
2779    max_bytes: usize,
2780    token_cap: usize,
2781) -> TokenCappedPreview {
2782    let mut preview = Vec::new();
2783    let mut accumulated_tokens = 0usize;
2784    let mut capped_end = end;
2785    let mut was_capped = false;
2786
2787    for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
2788        let truncated = truncate_for_budget(line, max_bytes);
2789        let line_tokens = estimated_tokens_from_bytes(truncated.len());
2790        if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
2791            capped_end = start + idx - 1;
2792            was_capped = true;
2793            break;
2794        }
2795        accumulated_tokens += line_tokens;
2796        preview.push(SourceLinePreview {
2797            line: start + idx,
2798            text: truncated,
2799        });
2800    }
2801
2802    TokenCappedPreview {
2803        preview,
2804        capped_end,
2805        was_capped,
2806    }
2807}
2808
2809pub(crate) fn abbreviate_kind(kind: &str) -> &str {
2810    match kind {
2811        "function" => "fn",
2812        "method" => "meth",
2813        "module" | "mod" => "mod",
2814        "struct" => "struct",
2815        "trait" => "trait",
2816        "impl" => "impl",
2817        "class" => "cls",
2818        "interface" => "iface",
2819        "type_alias" => "type",
2820        "data_class" => "data_cls",
2821        "sealed_class" => "sealed_cls",
2822        "enum_class" => "enum_cls",
2823        "companion_object" => "comp_obj",
2824        "object" => "obj",
2825        "heading" => "h",
2826        "code_block" => "code",
2827        "alias" => "alias",
2828        other => other,
2829    }
2830}
2831
2832pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
2833    match kind {
2834        "calls" => "c",
2835        "defines" => "d",
2836        "contains" => "ct",
2837        "imports" => "i",
2838        "mentions" => "m",
2839        "mentions_concept" => "mc",
2840        "mentions_entity" => "me",
2841        "semantic_relation" => "sr",
2842        "belongs_to" => "bt",
2843        "scopes_context" => "sctx",
2844        "scopes_source" => "ssrc",
2845        "requests_context" => "rctx",
2846        "explains_result" => "er",
2847        "tagged_concept" => "tc",
2848        "tagged_entity" => "te",
2849        "related_concept" => "relc",
2850        "handled_by" => "hb",
2851        "defines_route" => "dr",
2852        "handles_route" => "hr",
2853        "targets" => "tgt",
2854        "has_vector_handle" => "hv",
2855        "parent" => "p",
2856        "child" => "ch",
2857        "uses" => "u",
2858        "projects_source" => "psrc",
2859        "records_memory_source" => "rms",
2860        "records_memory_event" => "rme",
2861        "has_ast_span" => "ha",
2862        "represents_symbol" => "rs",
2863        "contains_embedded_symbol" => "ces",
2864        "embedded_in_fence" => "ef",
2865        "contains_markdown_block" => "cmb",
2866        "contains_embedded_code" => "cec",
2867        "enclosing_module" => "em",
2868        "enclosing_section" => "es",
2869        "previous_sibling" => "psib",
2870        "next_sibling" => "nsib",
2871        "explicit_depends_on" => "edo",
2872        "worker_result_follow_up" => "wrf",
2873        "shared_resource" => "shr",
2874        "community_member" => "cm",
2875        other => other,
2876    }
2877}
2878
2879pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
2880    match mt {
2881        "exact_name" => "exact",
2882        "all_tags" => "all_tags",
2883        "partial_tags" => "partial",
2884        other => other,
2885    }
2886}
2887
2888pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
2889    path.iter()
2890        .map(|n| n.name.as_str())
2891        .collect::<Vec<_>>()
2892        .join(" -> ")
2893}
2894
2895const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
2896
2897struct SearchHitGroup {
2898    path: String,
2899    first_rank: usize,
2900    top_score: f64,
2901    confidence: String,
2902    hits: usize,
2903    samples: Vec<String>,
2904}
2905
2906fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
2907    let snippet = compact_snippet(&hit.snippet)?;
2908    Some(match hit.location.as_deref() {
2909        Some(location) => format!("{location}: {snippet}"),
2910        None => snippet,
2911    })
2912}
2913
2914pub(crate) fn group_search_hits(
2915    hits: &[sift::SearchHit],
2916    root: &Path,
2917    absolute: bool,
2918) -> Vec<SearchHitGroup> {
2919    let mut positions = BTreeMap::new();
2920    let mut groups = Vec::new();
2921    for hit in hits {
2922        let path = if absolute {
2923            hit.path.clone()
2924        } else {
2925            relativize(&hit.path, root)
2926        };
2927        let entry = positions.entry(path.clone()).or_insert_with(|| {
2928            groups.push(SearchHitGroup {
2929                path: path.clone(),
2930                first_rank: hit.rank,
2931                top_score: hit.score,
2932                confidence: format!("{:?}", hit.confidence),
2933                hits: 0,
2934                samples: Vec::new(),
2935            });
2936            groups.len() - 1
2937        });
2938        let group = &mut groups[*entry];
2939        group.hits += 1;
2940        if hit.rank < group.first_rank {
2941            group.first_rank = hit.rank;
2942        }
2943        if hit.score > group.top_score {
2944            group.top_score = hit.score;
2945        }
2946        if let Some(sample) = format_search_sample(hit)
2947            && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
2948            && !group.samples.contains(&sample)
2949        {
2950            group.samples.push(sample);
2951        }
2952    }
2953    groups.sort_by_key(|group| group.first_rank);
2954    groups
2955}
2956
2957pub(crate) fn should_collapse_search_hits(
2958    hits: &[sift::SearchHit],
2959    root: &Path,
2960    absolute: bool,
2961) -> bool {
2962    let groups = group_search_hits(hits, root, absolute);
2963    let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
2964    max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
2965}
2966
2967pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
2968    let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
2969    for edge in edges {
2970        let key = edge.caller_file.as_str();
2971        let name = if use_callers {
2972            edge.caller_name.as_str()
2973        } else {
2974            edge.callee_name.as_str()
2975        };
2976        let names = grouped.entry(key).or_default();
2977        if !names.contains(&name) {
2978            names.push(name);
2979        }
2980    }
2981
2982    grouped
2983        .into_iter()
2984        .map(|(file, names)| format!("  {} ({}): {}", file, names.len(), names.join(", ")))
2985        .collect()
2986}
2987
2988pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
2989    let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
2990    for edge in edges {
2991        *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
2992    }
2993    let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
2994    max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
2995}
2996
2997fn resolve_query_index_target(
2998    root: &Path,
2999    path_hint: &Path,
3000    scope: Option<&str>,
3001) -> Result<SearchIndexTarget> {
3002    let cfg = config::Config::load(root)?;
3003    if let Some(scope_name) = scope {
3004        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3005            return Ok(SearchIndexTarget {
3006                label: format!("submodule `{}` index", scope.id),
3007                db_path: cfg.db_path_for(root, &scope.id),
3008                source_root: scope.source_root.clone(),
3009                scope_name: Some(scope.id.clone()),
3010                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3011            });
3012        }
3013        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3014            return Ok(cargo_package_index_target(root, package));
3015        }
3016        config::Config::resolve_submodule(root, scope_name)?;
3017    }
3018
3019    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3020        return Ok(SearchIndexTarget {
3021            label: format!("submodule `{}` index", scope.id),
3022            db_path: cfg.db_path_for(root, &scope.id),
3023            source_root: scope.source_root.clone(),
3024            scope_name: Some(scope.id.clone()),
3025            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3026        });
3027    }
3028
3029    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3030        return Ok(cargo_package_index_target(root, package));
3031    }
3032
3033    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3034        return Ok(SearchIndexTarget {
3035            label: format!("submodule `{}` index", scope.id),
3036            db_path: cfg.db_path_for(root, &scope.id),
3037            source_root: scope.source_root.clone(),
3038            scope_name: Some(scope.id.clone()),
3039            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3040        });
3041    }
3042
3043    let db_path = root.join(".tsift/index.db");
3044    if db_path.exists() {
3045        return Ok(SearchIndexTarget {
3046            label: "index".to_string(),
3047            db_path,
3048            source_root: root.to_path_buf(),
3049            scope_name: None,
3050            reindex_cmd: format!("tsift index {}", root.display()),
3051        });
3052    }
3053
3054    let scopes = config::Config::submodule_dirs(root)?;
3055    if scopes.is_empty() {
3056        return Ok(SearchIndexTarget {
3057            label: "index".to_string(),
3058            db_path,
3059            source_root: root.to_path_buf(),
3060            scope_name: None,
3061            reindex_cmd: format!("tsift index {}", root.display()),
3062        });
3063    }
3064
3065    let available_scopes = scopes
3066        .iter()
3067        .map(|scope| scope.id.as_str())
3068        .collect::<Vec<_>>()
3069        .join(", ");
3070    let indexed_scopes = scopes
3071        .iter()
3072        .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3073        .map(|scope| scope.id.as_str())
3074        .collect::<Vec<_>>();
3075    let indexed_label = if indexed_scopes.is_empty() {
3076        "none".to_string()
3077    } else {
3078        indexed_scopes.join(", ")
3079    };
3080
3081    bail!(
3082        "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: {}.",
3083        root.display(),
3084        db_path.display(),
3085        available_scopes,
3086        indexed_label
3087    );
3088}
3089
3090pub(crate) fn resolve_query_db_path(
3091    root: &Path,
3092    path_hint: &Path,
3093    scope: Option<&str>,
3094) -> Result<PathBuf> {
3095    Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3096}
3097
3098fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3099    let state = inspect_search_index(target)?;
3100    let Some(reason) = index_reason_for_state(state) else {
3101        return Ok(());
3102    };
3103
3104    match apply_search_index_update(root, target) {
3105        Ok(_) => {
3106            index::inspect_scope_invalidate_all();
3107            Ok(())
3108        }
3109        Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3110            eprintln!(
3111                "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3112                 Continuing with the current read-only index snapshot; graph results may lag. \
3113                 Retry `{}` after the active writer finishes for fresh graph results.",
3114                index_reason_detail(target, reason),
3115                target.reindex_cmd
3116            );
3117            Ok(())
3118        }
3119        Err(err) => Err(err),
3120    }
3121}
3122
3123pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3124    let root = lint::resolve_project_root_or_canonical_path(path)?;
3125    let target = resolve_query_index_target(&root, path, scope)?;
3126    ensure_query_index_current(&root, &target)?;
3127    let db_path = target.db_path;
3128    if !db_path.exists() {
3129        bail!(
3130            "no index found at {}. Run `tsift index` first.",
3131            db_path.display()
3132        );
3133    }
3134    index::IndexDb::open_read_only_resilient(&db_path)
3135}
3136
3137pub(crate) fn query_tagpath_root(
3138    root: &std::path::Path,
3139    path_hint: &std::path::Path,
3140    scope: Option<&str>,
3141) -> Result<PathBuf> {
3142    if let Some(scope_name) = scope {
3143        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3144            return Ok(scope.source_root);
3145        }
3146        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3147            return Ok(package.package_root);
3148        }
3149        config::Config::resolve_submodule(root, scope_name)?;
3150    }
3151    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3152        return Ok(scope.source_root);
3153    }
3154    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3155        return Ok(package.package_root);
3156    }
3157    Ok(root.to_path_buf())
3158}
3159
3160#[derive(Clone, Debug, Serialize, PartialEq)]
3161struct TraversalNode {
3162    handle: String,
3163    kind: String,
3164    label: String,
3165    #[serde(skip_serializing_if = "Option::is_none")]
3166    ref_id: Option<String>,
3167    #[serde(skip_serializing_if = "Option::is_none")]
3168    path: Option<String>,
3169    #[serde(skip_serializing_if = "Option::is_none")]
3170    line: Option<i64>,
3171    #[serde(skip_serializing_if = "Option::is_none")]
3172    detail: Option<String>,
3173    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3174    properties: BTreeMap<String, String>,
3175    expand: String,
3176}
3177
3178#[derive(Clone, Debug, Serialize, PartialEq)]
3179struct TraversalEdge {
3180    from: String,
3181    to: String,
3182    relation: String,
3183    #[serde(skip_serializing_if = "Option::is_none")]
3184    label: Option<String>,
3185    weight: usize,
3186}
3187
3188#[derive(Clone, Debug, Default)]
3189struct TraversalGraphBuild {
3190    nodes: BTreeMap<String, TraversalNode>,
3191    edges: Vec<TraversalEdge>,
3192    edge_keys: BTreeSet<(String, String, String)>,
3193    warnings: Vec<String>,
3194}
3195
3196pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3197const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3198const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3199const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3200const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3201    "context-pack-graph-orchestration-v1";
3202const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3203const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3204const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3205const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3206const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3207const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3208const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3209
3210#[derive(Debug, Serialize, PartialEq)]
3211struct TraversalTotals {
3212    nodes: usize,
3213    edges: usize,
3214}
3215
3216#[derive(Debug, Serialize, PartialEq)]
3217struct TraversalPathReport {
3218    from: TraversalNode,
3219    to: TraversalNode,
3220    hops: usize,
3221    nodes: Vec<TraversalNode>,
3222    edges: Vec<TraversalEdge>,
3223}
3224
3225#[derive(Debug, Serialize, PartialEq)]
3226struct TraversalRecommendation {
3227    handle: String,
3228    kind: String,
3229    label: String,
3230    reason: String,
3231    score: usize,
3232    expand: String,
3233}
3234
3235#[derive(Debug, Serialize, PartialEq)]
3236struct TraversalReport {
3237    root: String,
3238    #[serde(skip_serializing_if = "Option::is_none")]
3239    scope: Option<String>,
3240    mode: String,
3241    totals: TraversalTotals,
3242    #[serde(skip_serializing_if = "Option::is_none")]
3243    query: Option<String>,
3244    #[serde(skip_serializing_if = "Option::is_none")]
3245    target: Option<String>,
3246    nodes: Vec<TraversalNode>,
3247    edges: Vec<TraversalEdge>,
3248    #[serde(skip_serializing_if = "Option::is_none")]
3249    shortest_path: Option<TraversalPathReport>,
3250    recommendations: Vec<TraversalRecommendation>,
3251    exploration: ExplorationPacket,
3252    truncated: bool,
3253    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3254    warnings: Vec<String>,
3255}
3256
3257#[derive(Debug, Serialize, PartialEq)]
3258struct SemanticRelatedReport {
3259    root: String,
3260    #[serde(skip_serializing_if = "Option::is_none")]
3261    scope: Option<String>,
3262    query: String,
3263    embedding_model: String,
3264    count: usize,
3265    items: Vec<SemanticRelatedItem>,
3266    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3267    warnings: Vec<String>,
3268}
3269
3270#[derive(Clone, Debug, Serialize, PartialEq)]
3271struct SemanticRelatedItem {
3272    handle: String,
3273    kind: String,
3274    label: String,
3275    score: f64,
3276    #[serde(skip_serializing_if = "Option::is_none")]
3277    file_path: Option<String>,
3278    #[serde(skip_serializing_if = "Option::is_none")]
3279    source_symbol: Option<String>,
3280    #[serde(skip_serializing_if = "Option::is_none")]
3281    detail: Option<String>,
3282    expand: String,
3283}
3284
3285#[derive(Clone)]
3286struct TraversalSymbolIndexEntry {
3287    handle: String,
3288    node: TraversalNode,
3289    tokens: BTreeSet<String>,
3290}
3291
3292#[derive(Clone)]
3293struct TraversalFileIndexEntry {
3294    handle: String,
3295    node: TraversalNode,
3296    tokens: BTreeSet<String>,
3297}
3298
3299#[derive(Clone)]
3300struct TraversalRouteIndexEntry {
3301    handle: String,
3302    node: TraversalNode,
3303    tokens: BTreeSet<String>,
3304}
3305
3306#[derive(Clone)]
3307struct TraversalAstSpanIndexEntry {
3308    handle: String,
3309    symbol_handle: String,
3310    file_handle: Option<String>,
3311    file: String,
3312    name: String,
3313    kind: String,
3314    language: String,
3315    node_kind: String,
3316    start_byte: usize,
3317    end_byte: usize,
3318    parent_module: Option<String>,
3319    markdown: Option<MarkdownSpanMetadata>,
3320}
3321
3322#[derive(Clone)]
3323struct TraversalMultiplicityIndexEntry {
3324    handle: String,
3325    node: TraversalNode,
3326    tokens: BTreeSet<String>,
3327}
3328
3329struct TraversalCodeLookup<'a> {
3330    symbols: &'a [TraversalSymbolIndexEntry],
3331    files: &'a [TraversalFileIndexEntry],
3332    routes: &'a [TraversalRouteIndexEntry],
3333    multiplicities: &'a [TraversalMultiplicityIndexEntry],
3334    symbol_index: HashMap<String, Vec<usize>>,
3335    file_index: HashMap<String, Vec<usize>>,
3336    route_index: HashMap<String, Vec<usize>>,
3337    multiplicity_index: HashMap<String, Vec<usize>>,
3338    file_path_index: HashMap<String, String>,
3339}
3340
3341#[derive(Clone, Debug, Serialize, PartialEq)]
3342struct ExplorationBudget {
3343    project_size: String,
3344    max_source_windows: usize,
3345    lines_per_window: usize,
3346    relationship_limit: usize,
3347}
3348
3349#[derive(Clone, Debug, Serialize, PartialEq)]
3350struct ExplorationRelation {
3351    from: String,
3352    relation: String,
3353    to: String,
3354    #[serde(skip_serializing_if = "Option::is_none")]
3355    label: Option<String>,
3356}
3357
3358#[derive(Clone, Debug, Serialize, PartialEq)]
3359struct ExplorationSourceWindow {
3360    handle: String,
3361    file: String,
3362    start: usize,
3363    end: usize,
3364    reason: String,
3365    expand: String,
3366}
3367
3368#[derive(Clone, Debug, Serialize, PartialEq)]
3369struct ExplorationWorkerContext {
3370    handle: String,
3371    target: String,
3372    summary: String,
3373    expand: String,
3374}
3375
3376#[derive(Clone, Debug, Serialize, PartialEq)]
3377struct ExplorationPacket {
3378    budget: ExplorationBudget,
3379    relationship_map: Vec<ExplorationRelation>,
3380    source_windows: Vec<ExplorationSourceWindow>,
3381    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3382    worker_context: Vec<ExplorationWorkerContext>,
3383    no_reread_guidance: String,
3384}
3385
3386impl TraversalGraphBuild {
3387    fn add_node(&mut self, node: TraversalNode) {
3388        self.nodes.entry(node.handle.clone()).or_insert(node);
3389    }
3390
3391    fn add_edge(
3392        &mut self,
3393        from: &str,
3394        to: &str,
3395        relation: &str,
3396        label: Option<String>,
3397        weight: usize,
3398    ) {
3399        if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
3400            return;
3401        }
3402        let key = (from.to_string(), to.to_string(), relation.to_string());
3403        if self.edge_keys.insert(key) {
3404            self.edges.push(TraversalEdge {
3405                from: from.to_string(),
3406                to: to.to_string(),
3407                relation: relation.to_string(),
3408                label,
3409                weight,
3410            });
3411        }
3412    }
3413}
3414
3415pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
3416    match scope {
3417        Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
3418        None => root.join(".tsift/graph.db"),
3419    }
3420}
3421
3422fn graph_projection_meta_id(scope: Option<&str>) -> String {
3423    format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
3424}
3425
3426pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
3427    let bytes = serde_json::to_vec(value)?;
3428    Ok(blake3::hash(&bytes).to_hex().to_string())
3429}
3430
3431fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
3432    let mut hashable = node.clone();
3433    hashable.freshness = None;
3434    node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3435    Ok(node)
3436}
3437
3438fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
3439    let mut hashable = edge.clone();
3440    hashable.freshness = None;
3441    edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3442    Ok(edge)
3443}
3444
3445const SEMANTIC_EMBEDDING_DIM: usize = 32;
3446const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
3447
3448fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
3449    match kind {
3450        SemanticRelatedKind::Concept => "concept",
3451        SemanticRelatedKind::Entity => "entity",
3452        SemanticRelatedKind::All => "all",
3453    }
3454}
3455
3456fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
3457    format!(
3458        "tsift semantic {} --path {} --kind {} --limit 10",
3459        shell_quote(query),
3460        shell_quote(root.to_string_lossy().as_ref()),
3461        semantic_related_kind_name(kind)
3462    )
3463}
3464
3465fn semantic_embedding(input: &str) -> Vec<f64> {
3466    let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
3467    let mut tokens = traversal_tokens(input);
3468    if tokens.is_empty() {
3469        let trimmed = input.trim().to_ascii_lowercase();
3470        if !trimmed.is_empty() {
3471            tokens.insert(trimmed);
3472        }
3473    }
3474
3475    for token in tokens {
3476        let hash = blake3::hash(token.as_bytes());
3477        let bytes = hash.as_bytes();
3478        let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
3479        let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
3480        vector[idx] += sign;
3481    }
3482
3483    let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
3484    if norm > 0.0 {
3485        for value in &mut vector {
3486            *value /= norm;
3487        }
3488    }
3489    vector
3490}
3491
3492fn semantic_embedding_property(input: &str) -> String {
3493    semantic_embedding(input)
3494        .iter()
3495        .map(|value| format!("{value:.6}"))
3496        .collect::<Vec<_>>()
3497        .join(",")
3498}
3499
3500fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
3501    let parsed = value
3502        .split(',')
3503        .map(str::trim)
3504        .map(str::parse::<f64>)
3505        .collect::<std::result::Result<Vec<_>, _>>()
3506        .ok()?;
3507    (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
3508}
3509
3510fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
3511    if left.len() != right.len() {
3512        return 0.0;
3513    }
3514    left.iter()
3515        .zip(right.iter())
3516        .map(|(left, right)| left * right)
3517        .sum::<f64>()
3518}
3519
3520fn semantic_entity_handle(name: &str, kind: &str) -> String {
3521    stable_handle(
3522        "gent",
3523        &format!(
3524            "entity:{}:{}",
3525            kind.trim().to_ascii_lowercase(),
3526            name.trim().to_ascii_lowercase()
3527        ),
3528    )
3529}
3530
3531fn semantic_concept_handle(label: &str) -> String {
3532    stable_handle(
3533        "gcon",
3534        &format!("concept:{}", label.trim().to_ascii_lowercase()),
3535    )
3536}
3537
3538fn summary_source_handles(
3539    summary: &summarize::Summary,
3540    file_node_by_path: &BTreeMap<String, String>,
3541    symbol_node_by_file_label: &BTreeMap<(String, String), String>,
3542) -> Vec<String> {
3543    let mut handles = Vec::new();
3544    if let Some(handle) = file_node_by_path.get(&summary.file_path) {
3545        handles.push(handle.clone());
3546    }
3547    if let Some(handle) =
3548        symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
3549        && !handles.iter().any(|existing| existing == handle)
3550    {
3551        handles.push(handle.clone());
3552    }
3553    handles
3554}
3555
3556fn semantic_entity_node(
3557    root: &Path,
3558    summary: &summarize::Summary,
3559    name: &str,
3560    kind: &str,
3561    description: &str,
3562    provenance: &GraphProvenance,
3563) -> SubstrateGraphNode {
3564    let handle = semantic_entity_handle(name, kind);
3565    let detail = if description.trim().is_empty() {
3566        format!("{kind} entity from cached summaries")
3567    } else {
3568        format!("{kind}: {description}")
3569    };
3570    SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
3571        .with_property("handle", handle)
3572        .with_property("ref_id", name.to_string())
3573        .with_property("detail", detail)
3574        .with_property("entity_kind", kind.to_string())
3575        .with_property("description", description.to_string())
3576        .with_property("source_file", summary.file_path.clone())
3577        .with_property("source_symbol", summary.symbol_name.clone())
3578        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3579        .with_property(
3580            "embedding",
3581            semantic_embedding_property(&format!("{name} {kind} {description}")),
3582        )
3583        .with_property(
3584            "expand",
3585            semantic_related_command(root, name, SemanticRelatedKind::Entity),
3586        )
3587        .with_provenance(provenance.clone())
3588}
3589
3590fn semantic_concept_node(
3591    root: &Path,
3592    summary: &summarize::Summary,
3593    label: &str,
3594    provenance: &GraphProvenance,
3595) -> SubstrateGraphNode {
3596    let handle = semantic_concept_handle(label);
3597    SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
3598        .with_property("handle", handle)
3599        .with_property("ref_id", label.to_string())
3600        .with_property("detail", "concept label from cached summaries".to_string())
3601        .with_property("source_file", summary.file_path.clone())
3602        .with_property("source_symbol", summary.symbol_name.clone())
3603        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3604        .with_property("embedding", semantic_embedding_property(label))
3605        .with_property(
3606            "expand",
3607            semantic_related_command(root, label, SemanticRelatedKind::Concept),
3608        )
3609        .with_provenance(provenance.clone())
3610}
3611
3612fn insert_semantic_edge(
3613    edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
3614    edge: SubstrateGraphEdge,
3615) {
3616    edge_map
3617        .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
3618        .or_insert(edge);
3619}
3620
3621fn append_summary_semantic_projection_rows(
3622    root: &Path,
3623    graph: &TraversalGraphBuild,
3624    provenance: &GraphProvenance,
3625    nodes: &mut Vec<SubstrateGraphNode>,
3626    edges: &mut Vec<SubstrateGraphEdge>,
3627) -> Result<()> {
3628    let summaries_db = root.join(".tsift/summaries.db");
3629    if !summaries_db.exists() {
3630        return Ok(());
3631    }
3632
3633    let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
3634    let summaries = summary_db.all()?;
3635    if summaries.is_empty() {
3636        return Ok(());
3637    }
3638
3639    let file_node_by_path = graph
3640        .nodes
3641        .values()
3642        .filter(|node| node.kind == "file")
3643        .filter_map(|node| {
3644            node.path
3645                .as_ref()
3646                .map(|path| (path.clone(), node.handle.clone()))
3647        })
3648        .collect::<BTreeMap<_, _>>();
3649    let symbol_node_by_file_label = graph
3650        .nodes
3651        .values()
3652        .filter(|node| node.kind == "symbol")
3653        .filter_map(|node| {
3654            Some((
3655                (node.path.clone()?, node.label.clone()),
3656                node.handle.clone(),
3657            ))
3658        })
3659        .collect::<BTreeMap<_, _>>();
3660
3661    let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
3662    let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
3663
3664    for summary in &summaries {
3665        let source_handles =
3666            summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
3667        let mut entity_ids_by_name = BTreeMap::<String, String>::new();
3668
3669        if let Some(entities) = &summary.entities {
3670            for entity in entities {
3671                let node = semantic_entity_node(
3672                    root,
3673                    summary,
3674                    &entity.name,
3675                    &entity.kind,
3676                    &entity.description,
3677                    provenance,
3678                );
3679                let entity_id = node.id.clone();
3680                entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
3681                semantic_nodes.entry(entity_id.clone()).or_insert(node);
3682
3683                for source_handle in &source_handles {
3684                    insert_semantic_edge(
3685                        &mut semantic_edges,
3686                        SubstrateGraphEdge::new(
3687                            source_handle.clone(),
3688                            entity_id.clone(),
3689                            "mentions_entity",
3690                        )
3691                        .with_property("label", format!("summary entity: {}", entity.name))
3692                        .with_property("source_file", summary.file_path.clone())
3693                        .with_provenance(provenance.clone()),
3694                    );
3695                }
3696            }
3697        }
3698
3699        let mut concept_ids = Vec::new();
3700        if let Some(labels) = &summary.concept_labels {
3701            for label in labels
3702                .iter()
3703                .map(|label| label.trim())
3704                .filter(|label| !label.is_empty())
3705            {
3706                let node = semantic_concept_node(root, summary, label, provenance);
3707                let concept_id = node.id.clone();
3708                semantic_nodes.entry(concept_id.clone()).or_insert(node);
3709                concept_ids.push(concept_id.clone());
3710
3711                for source_handle in &source_handles {
3712                    insert_semantic_edge(
3713                        &mut semantic_edges,
3714                        SubstrateGraphEdge::new(
3715                            source_handle.clone(),
3716                            concept_id.clone(),
3717                            "mentions_concept",
3718                        )
3719                        .with_property("label", format!("summary concept: {label}"))
3720                        .with_property("source_file", summary.file_path.clone())
3721                        .with_provenance(provenance.clone()),
3722                    );
3723                }
3724            }
3725        }
3726
3727        for entity_id in entity_ids_by_name.values() {
3728            for concept_id in &concept_ids {
3729                insert_semantic_edge(
3730                    &mut semantic_edges,
3731                    SubstrateGraphEdge::new(
3732                        entity_id.clone(),
3733                        concept_id.clone(),
3734                        "tagged_concept",
3735                    )
3736                    .with_property("label", "entity concept label".to_string())
3737                    .with_property("source_file", summary.file_path.clone())
3738                    .with_provenance(provenance.clone()),
3739                );
3740            }
3741        }
3742
3743        for idx in 0..concept_ids.len() {
3744            for next_idx in (idx + 1)..concept_ids.len() {
3745                insert_semantic_edge(
3746                    &mut semantic_edges,
3747                    SubstrateGraphEdge::new(
3748                        concept_ids[idx].clone(),
3749                        concept_ids[next_idx].clone(),
3750                        "related_concept",
3751                    )
3752                    .with_property("label", format!("co-occurs in {}", summary.symbol_name))
3753                    .with_property("source_file", summary.file_path.clone())
3754                    .with_provenance(provenance.clone()),
3755                );
3756            }
3757        }
3758
3759        if let Some(relationships) = &summary.relationships {
3760            for relationship in relationships {
3761                let from_id = entity_ids_by_name
3762                    .get(&relationship.from.to_ascii_lowercase())
3763                    .cloned()
3764                    .unwrap_or_else(|| {
3765                        let node = semantic_entity_node(
3766                            root,
3767                            summary,
3768                            &relationship.from,
3769                            "unknown",
3770                            "",
3771                            provenance,
3772                        );
3773                        let id = node.id.clone();
3774                        semantic_nodes.entry(id.clone()).or_insert(node);
3775                        id
3776                    });
3777                let to_id = entity_ids_by_name
3778                    .get(&relationship.to.to_ascii_lowercase())
3779                    .cloned()
3780                    .unwrap_or_else(|| {
3781                        let node = semantic_entity_node(
3782                            root,
3783                            summary,
3784                            &relationship.to,
3785                            "unknown",
3786                            "",
3787                            provenance,
3788                        );
3789                        let id = node.id.clone();
3790                        semantic_nodes.entry(id.clone()).or_insert(node);
3791                        id
3792                    });
3793                insert_semantic_edge(
3794                    &mut semantic_edges,
3795                    SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
3796                        .with_property("relationship_kind", relationship.kind.clone())
3797                        .with_property("label", relationship.kind.clone())
3798                        .with_property("source_file", summary.file_path.clone())
3799                        .with_property("source_symbol", summary.symbol_name.clone())
3800                        .with_provenance(provenance.clone()),
3801                );
3802            }
3803        }
3804    }
3805
3806    for node in semantic_nodes.into_values() {
3807        nodes.push(node_with_content_freshness(node)?);
3808    }
3809    for edge in semantic_edges.into_values() {
3810        edges.push(edge_with_content_freshness(edge)?);
3811    }
3812
3813    Ok(())
3814}
3815
3816fn projection_content_hash(
3817    nodes: &[SubstrateGraphNode],
3818    edges: &[SubstrateGraphEdge],
3819) -> Result<String> {
3820    #[derive(Serialize)]
3821    struct Payload<'a> {
3822        version: &'static str,
3823        nodes: &'a [SubstrateGraphNode],
3824        edges: &'a [SubstrateGraphEdge],
3825    }
3826
3827    content_hash(&Payload {
3828        version: GRAPH_PROJECTION_VERSION,
3829        nodes,
3830        edges,
3831    })
3832}
3833
3834pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
3835    projection
3836        .nodes
3837        .iter()
3838        .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
3839        .and_then(|node| node.properties.get("content_hash").cloned())
3840}
3841
3842fn traversal_projection_from_graph(
3843    root: &Path,
3844    scope: Option<&str>,
3845    graph: &TraversalGraphBuild,
3846) -> Result<GraphProjection> {
3847    let provenance = GraphProvenance::new(
3848        "tsift.traverse",
3849        format!("{}:{}", root.display(), scope.unwrap_or("root")),
3850    );
3851    let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
3852    for node in graph.nodes.values() {
3853        let mut projected =
3854            SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
3855                .with_property("handle", node.handle.clone())
3856                .with_property("expand", node.expand.clone())
3857                .with_provenance(provenance.clone());
3858        if let Some(ref_id) = &node.ref_id {
3859            projected = projected.with_property("ref_id", ref_id.clone());
3860        }
3861        if let Some(path) = &node.path {
3862            projected = projected.with_property("path", path.clone());
3863        }
3864        if let Some(line) = node.line {
3865            projected = projected.with_property("line", line.to_string());
3866        }
3867        if let Some(detail) = &node.detail {
3868            projected = projected.with_property("detail", detail.clone());
3869        }
3870        for (key, value) in &node.properties {
3871            projected = projected.with_property(key.clone(), value.clone());
3872        }
3873        nodes.push(node_with_content_freshness(projected)?);
3874    }
3875
3876    let mut edges = Vec::with_capacity(graph.edges.len());
3877    for edge in &graph.edges {
3878        let mut projected =
3879            SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
3880                .with_property("weight", edge.weight.to_string())
3881                .with_provenance(provenance.clone());
3882        if let Some(label) = &edge.label {
3883            projected = projected.with_property("label", label.clone());
3884        }
3885        edges.push(edge_with_content_freshness(projected)?);
3886    }
3887
3888    append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3889    append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3890    append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
3891
3892    let projection_hash = projection_content_hash(&nodes, &edges)?;
3893    let meta = SubstrateGraphNode::new(
3894        graph_projection_meta_id(scope),
3895        GRAPH_PROJECTION_META_KIND,
3896        "tsift traversal projection",
3897    )
3898    .with_property("projection_version", GRAPH_PROJECTION_VERSION)
3899    .with_property("content_hash", projection_hash.clone())
3900    .with_property("root", root.to_string_lossy().to_string())
3901    .with_property("scope", scope.unwrap_or("root"))
3902    .with_property("node_count", graph.nodes.len().to_string())
3903    .with_property("edge_count", graph.edges.len().to_string())
3904    .with_provenance(provenance)
3905    .with_freshness(GraphFreshness::content_hash(projection_hash));
3906    nodes.push(meta);
3907
3908    Ok(GraphProjection { nodes, edges })
3909}
3910
3911#[allow(clippy::too_many_arguments)]
3912fn ensure_traversal_source_handle(
3913    root: &Path,
3914    provenance: &GraphProvenance,
3915    file_node_by_path: &BTreeMap<String, String>,
3916    node: &TraversalNode,
3917    budget: &ExplorationBudget,
3918    source_handle_by_node: &mut BTreeMap<String, String>,
3919    seen_windows: &mut BTreeMap<(String, usize, usize), String>,
3920    nodes: &mut Vec<SubstrateGraphNode>,
3921    edges: &mut Vec<SubstrateGraphEdge>,
3922) -> Result<Option<String>> {
3923    if let Some(handle) = source_handle_by_node.get(&node.handle) {
3924        return Ok(Some(handle.clone()));
3925    }
3926    let Some(window) = exploration_source_window_for_node(root, node, budget) else {
3927        return Ok(None);
3928    };
3929    let window_key = (window.file.clone(), window.start, window.end);
3930    let handle = if let Some(handle) = seen_windows.get(&window_key) {
3931        handle.clone()
3932    } else {
3933        let label = format!("{}:{}-{}", window.file, window.start, window.end);
3934        let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
3935            .with_property("handle", window.handle.clone())
3936            .with_property("file", window.file.clone())
3937            .with_property("start", window.start.to_string())
3938            .with_property("end", window.end.to_string())
3939            .with_property("reason", window.reason.clone())
3940            .with_property("expand", window.expand.clone())
3941            .with_provenance(provenance.clone());
3942        nodes.push(node_with_content_freshness(projected)?);
3943
3944        if let Some(file_handle) = file_node_by_path.get(&window.file) {
3945            let edge = SubstrateGraphEdge::new(
3946                window.handle.clone(),
3947                file_handle.clone(),
3948                "expands_source",
3949            )
3950            .with_property("label", window.reason.clone())
3951            .with_provenance(provenance.clone());
3952            edges.push(edge_with_content_freshness(edge)?);
3953        }
3954        if node.kind != "file" {
3955            let edge = SubstrateGraphEdge::new(
3956                window.handle.clone(),
3957                node.handle.clone(),
3958                "anchors_source",
3959            )
3960            .with_property("label", window.reason.clone())
3961            .with_provenance(provenance.clone());
3962            edges.push(edge_with_content_freshness(edge)?);
3963        }
3964        seen_windows.insert(window_key, window.handle.clone());
3965        window.handle
3966    };
3967    source_handle_by_node.insert(node.handle.clone(), handle.clone());
3968    Ok(Some(handle))
3969}
3970
3971fn push_traversal_backlog_target_handles<'a>(
3972    backlog: &TraversalNode,
3973    edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
3974    node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
3975    max_handles: usize,
3976    seen_target_nodes: &mut BTreeSet<String>,
3977    target_node_handles: &mut Vec<String>,
3978) {
3979    for edge in edges_by_from
3980        .get(backlog.handle.as_str())
3981        .into_iter()
3982        .flatten()
3983        .filter(|edge| edge.relation == "mentions")
3984    {
3985        let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
3986            continue;
3987        };
3988        if !matches!(
3989            target_node.kind.as_str(),
3990            "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
3991        ) {
3992            continue;
3993        }
3994        if target_node
3995            .path
3996            .as_deref()
3997            .zip(backlog.path.as_deref())
3998            .is_some_and(|(target_path, backlog_path)| {
3999                target_path == backlog_path && target_path.ends_with(".md")
4000            })
4001        {
4002            continue;
4003        }
4004        if seen_target_nodes.insert(target_node.handle.clone()) {
4005            target_node_handles.push(target_node.handle.clone());
4006        }
4007        if target_node_handles.len() >= max_handles {
4008            break;
4009        }
4010    }
4011}
4012
4013fn append_traversal_context_projection_rows(
4014    root: &Path,
4015    graph: &TraversalGraphBuild,
4016    provenance: &GraphProvenance,
4017    nodes: &mut Vec<SubstrateGraphNode>,
4018    edges: &mut Vec<SubstrateGraphEdge>,
4019) -> Result<()> {
4020    let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4021    let file_node_by_path = graph
4022        .nodes
4023        .values()
4024        .filter(|node| node.kind == "file")
4025        .filter_map(|node| {
4026            node.path
4027                .as_ref()
4028                .map(|path| (path.clone(), node.handle.clone()))
4029        })
4030        .collect::<BTreeMap<_, _>>();
4031
4032    let node_by_handle = graph
4033        .nodes
4034        .values()
4035        .map(|node| (node.handle.as_str(), node))
4036        .collect::<BTreeMap<_, _>>();
4037    let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4038    for edge in &graph.edges {
4039        edges_by_from
4040            .entry(edge.from.as_str())
4041            .or_default()
4042            .push(edge);
4043    }
4044    for rows in edges_by_from.values_mut() {
4045        rows.sort_by(|left, right| {
4046            right
4047                .weight
4048                .cmp(&left.weight)
4049                .then(left.relation.cmp(&right.relation))
4050                .then(left.to.cmp(&right.to))
4051        });
4052    }
4053
4054    let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4055    let mut source_handle_by_node = BTreeMap::<String, String>::new();
4056
4057    let mut code_context_count = 0usize;
4058    let code_context_limit = budget.relationship_limit.min(8);
4059    for node in graph.nodes.values() {
4060        if !matches!(
4061            node.kind.as_str(),
4062            "backlog" | "job_packet" | "worker_result"
4063        ) {
4064            continue;
4065        }
4066        let mut target_node_handles = Vec::new();
4067        let mut fallback_target_handles = Vec::new();
4068        let mut seen_target_nodes = BTreeSet::new();
4069        if node.kind == "backlog" || node.kind == "worker_result" {
4070            push_traversal_backlog_target_handles(
4071                node,
4072                &edges_by_from,
4073                &node_by_handle,
4074                budget.max_source_windows,
4075                &mut seen_target_nodes,
4076                &mut target_node_handles,
4077            );
4078            fallback_target_handles.push(node.handle.clone());
4079        } else {
4080            for edge in edges_by_from
4081                .get(node.handle.as_str())
4082                .into_iter()
4083                .flatten()
4084                .filter(|edge| edge.relation == "targets")
4085            {
4086                let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4087                    continue;
4088                };
4089                fallback_target_handles.push(backlog.handle.clone());
4090                push_traversal_backlog_target_handles(
4091                    backlog,
4092                    &edges_by_from,
4093                    &node_by_handle,
4094                    budget.max_source_windows,
4095                    &mut seen_target_nodes,
4096                    &mut target_node_handles,
4097                );
4098                if target_node_handles.len() >= budget.max_source_windows {
4099                    break;
4100                }
4101            }
4102            if fallback_target_handles.is_empty() {
4103                continue;
4104            }
4105        }
4106        let code_context = !target_node_handles.is_empty();
4107        if target_node_handles.is_empty() {
4108            target_node_handles = dedupe_preserve_order(fallback_target_handles);
4109        } else if code_context_count >= code_context_limit {
4110            continue;
4111        }
4112
4113        let mut worker_source_handles = Vec::new();
4114        let mut seen_worker_handles = BTreeSet::new();
4115        for target_handle in target_node_handles {
4116            if worker_source_handles.len() >= budget.max_source_windows {
4117                break;
4118            }
4119            let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4120                continue;
4121            };
4122            let Some(handle) = ensure_traversal_source_handle(
4123                root,
4124                provenance,
4125                &file_node_by_path,
4126                target_node,
4127                &budget,
4128                &mut source_handle_by_node,
4129                &mut seen_windows,
4130                nodes,
4131                edges,
4132            )?
4133            else {
4134                continue;
4135            };
4136            if seen_worker_handles.insert(handle.clone()) {
4137                worker_source_handles.push(handle);
4138            }
4139        }
4140        if worker_source_handles.is_empty() {
4141            continue;
4142        }
4143        let target = node
4144            .path
4145            .clone()
4146            .unwrap_or_else(|| root.to_string_lossy().to_string());
4147        let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4148        let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4149        let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4150            .with_property("handle", handle.clone())
4151            .with_property("target", target.clone())
4152            .with_property("summary", summary)
4153            .with_property(
4154                "source_handle_count",
4155                worker_source_handles.len().to_string(),
4156            )
4157            .with_property(
4158                "expand",
4159                format!(
4160                    "tsift --envelope context-pack {} --budget normal",
4161                    shell_quote(&target)
4162                ),
4163            )
4164            .with_provenance(provenance.clone());
4165        nodes.push(node_with_content_freshness(projected)?);
4166
4167        let request_edge =
4168            SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4169                .with_property("label", "bounded worker context".to_string())
4170                .with_provenance(provenance.clone());
4171        edges.push(edge_with_content_freshness(request_edge)?);
4172
4173        for source_handle in &worker_source_handles {
4174            let scope_edge =
4175                SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4176                    .with_property("label", "bounded worker source window".to_string())
4177                    .with_provenance(provenance.clone());
4178            edges.push(edge_with_content_freshness(scope_edge)?);
4179        }
4180        if code_context {
4181            code_context_count += 1;
4182        }
4183    }
4184
4185    Ok(())
4186}
4187
4188fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4189    let handle = node
4190        .properties
4191        .get("handle")
4192        .cloned()
4193        .unwrap_or_else(|| node.id.clone());
4194    TraversalNode {
4195        expand: node
4196            .properties
4197            .get("expand")
4198            .cloned()
4199            .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4200        handle,
4201        kind: node.kind,
4202        label: node.label,
4203        ref_id: node.properties.get("ref_id").cloned(),
4204        path: node.properties.get("path").cloned(),
4205        line: node
4206            .properties
4207            .get("line")
4208            .and_then(|value| value.parse::<i64>().ok()),
4209        detail: node.properties.get("detail").cloned(),
4210        properties: node.properties,
4211    }
4212}
4213
4214fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4215    let mut graph = TraversalGraphBuild::default();
4216    for node in store.all_nodes()? {
4217        if node.kind == GRAPH_PROJECTION_META_KIND {
4218            continue;
4219        }
4220        graph.add_node(traversal_node_from_graph_node(root, node));
4221    }
4222    for edge in store.all_edges()? {
4223        graph.add_edge(
4224            &edge.from_id,
4225            &edge.to_id,
4226            &edge.kind,
4227            edge.properties.get("label").cloned(),
4228            edge.properties
4229                .get("weight")
4230                .and_then(|value| value.parse::<usize>().ok())
4231                .unwrap_or(1),
4232        );
4233    }
4234    Ok(graph)
4235}
4236
4237pub(crate) fn convex_rows_from_graph_store(
4238    store: &impl GraphStore,
4239) -> Result<ConvexProjectionRows> {
4240    Ok(GraphProjection {
4241        nodes: store.all_nodes()?,
4242        edges: store.all_edges()?,
4243    }
4244    .to_convex_rows())
4245}
4246
4247#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4248struct ConvexRequiredIndex {
4249    table: String,
4250    name: String,
4251    fields: Vec<String>,
4252}
4253
4254#[derive(Clone, Debug, Serialize, PartialEq)]
4255struct ConvexSyncChunk {
4256    operation: String,
4257    chunk: usize,
4258    count: usize,
4259    keys: Vec<String>,
4260    max_attempts: usize,
4261    retry_policy: String,
4262}
4263
4264#[derive(Clone, Debug, Serialize, PartialEq)]
4265struct ConvexTransportSummary {
4266    endpoint_env: String,
4267    endpoint_configured: bool,
4268    auth_token_env: String,
4269    auth_configured: bool,
4270    remote_snapshot: bool,
4271    applied_chunks: usize,
4272}
4273
4274#[derive(Clone, Debug, Serialize, PartialEq)]
4275struct ConvexTransportReceipt {
4276    operation: String,
4277    chunk: usize,
4278    attempt: usize,
4279    status: String,
4280    message: Option<String>,
4281}
4282
4283#[derive(Serialize)]
4284#[serde(rename_all = "camelCase")]
4285struct ConvexTransportRequest<'a> {
4286    operation: &'a str,
4287    chunk: usize,
4288    projection_version: &'a str,
4289    projection_hash: Option<&'a str>,
4290    #[serde(skip_serializing_if = "Option::is_none")]
4291    projection_meta_id: Option<&'a str>,
4292    node_rows: Vec<ConvexNodeRow>,
4293    edge_rows: Vec<ConvexEdgeRow>,
4294    keys: Vec<String>,
4295    #[serde(skip_serializing_if = "Option::is_none")]
4296    cursor: Option<String>,
4297    #[serde(skip_serializing_if = "Option::is_none")]
4298    limit: Option<usize>,
4299}
4300
4301#[derive(Deserialize)]
4302#[serde(rename_all = "camelCase")]
4303struct ConvexTransportResponse {
4304    status: Option<String>,
4305    message: Option<String>,
4306    rows: Option<ConvexProjectionRows>,
4307    #[serde(default)]
4308    meta: Option<ConvexSnapshotMeta>,
4309    #[serde(default)]
4310    page: Option<ConvexSnapshotPage>,
4311}
4312
4313#[derive(Deserialize, Debug, Clone)]
4314#[serde(rename_all = "camelCase")]
4315struct ConvexSnapshotMeta {
4316    // Captured for completeness/debugging; not currently consumed by the
4317    // freshness diff (indexes are already validated against the required set
4318    // via `convex_required_indexes`, and `page_size` is informational only).
4319    #[serde(default)]
4320    #[allow(dead_code)]
4321    indexes: Vec<ConvexRequiredIndex>,
4322    #[serde(default)]
4323    #[allow(dead_code)]
4324    node_count: Option<usize>,
4325    #[serde(default)]
4326    #[allow(dead_code)]
4327    edge_count: Option<usize>,
4328    #[serde(default)]
4329    projection_hash: Option<String>,
4330    #[serde(default)]
4331    #[allow(dead_code)]
4332    page_size: Option<usize>,
4333}
4334
4335/// Paginated snapshot page response. `rows` is either node rows or edge rows
4336/// depending on which operation was called; we deserialize as raw values to
4337/// keep the transport struct shared between both shapes, then narrow per call
4338/// site.
4339#[derive(Deserialize, Debug, Clone)]
4340#[serde(rename_all = "camelCase")]
4341struct ConvexSnapshotPage {
4342    rows: Vec<serde_json::Value>,
4343    #[serde(default)]
4344    next_cursor: Option<String>,
4345}
4346
4347#[derive(Clone, Debug, Serialize, PartialEq)]
4348struct ConvexProjectionFreshness {
4349    status: String,
4350    fail_closed: bool,
4351    local_hash: Option<String>,
4352    snapshot_hash: Option<String>,
4353    missing_nodes: Vec<String>,
4354    stale_nodes: Vec<String>,
4355    missing_edges: Vec<String>,
4356    stale_edges: Vec<String>,
4357    diagnostics: Vec<String>,
4358}
4359
4360const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
4361
4362impl ConvexProjectionFreshness {
4363    fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
4364        Self {
4365            status: "current".to_string(),
4366            fail_closed: false,
4367            local_hash,
4368            snapshot_hash,
4369            missing_nodes: Vec::new(),
4370            stale_nodes: Vec::new(),
4371            missing_edges: Vec::new(),
4372            stale_edges: Vec::new(),
4373            diagnostics: Vec::new(),
4374        }
4375    }
4376}
4377
4378#[derive(Clone, Debug, Serialize, PartialEq)]
4379struct ConvexSyncReport {
4380    root: String,
4381    #[serde(skip_serializing_if = "Option::is_none")]
4382    scope: Option<String>,
4383    graph_db: String,
4384    dry_run: bool,
4385    projection_version: String,
4386    projection_hash: Option<String>,
4387    required_indexes: Vec<ConvexRequiredIndex>,
4388    node_upserts: Vec<ConvexNodeRow>,
4389    edge_upserts: Vec<ConvexEdgeRow>,
4390    node_tombstones: Vec<String>,
4391    edge_tombstones: Vec<String>,
4392    chunks: Vec<ConvexSyncChunk>,
4393    freshness: ConvexProjectionFreshness,
4394    transport: Option<ConvexTransportSummary>,
4395    receipts: Vec<ConvexTransportReceipt>,
4396    diagnostics: Vec<String>,
4397    warnings: Vec<String>,
4398}
4399
4400fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
4401    vec![
4402        ConvexRequiredIndex {
4403            table: "nodes".to_string(),
4404            name: "by_external_id".to_string(),
4405            fields: vec!["externalId".to_string()],
4406        },
4407        ConvexRequiredIndex {
4408            table: "nodes".to_string(),
4409            name: "by_kind".to_string(),
4410            fields: vec!["kind".to_string()],
4411        },
4412        ConvexRequiredIndex {
4413            table: "edges".to_string(),
4414            name: "by_edge_key".to_string(),
4415            fields: vec!["edgeKey".to_string()],
4416        },
4417        ConvexRequiredIndex {
4418            table: "edges".to_string(),
4419            name: "by_from_kind".to_string(),
4420            fields: vec!["fromExternalId".to_string(), "kind".to_string()],
4421        },
4422        ConvexRequiredIndex {
4423            table: "edges".to_string(),
4424            name: "by_to_kind".to_string(),
4425            fields: vec!["toExternalId".to_string(), "kind".to_string()],
4426        },
4427    ]
4428}
4429
4430pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
4431    let content = fs::read_to_string(path)
4432        .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
4433    serde_json::from_str(&content)
4434        .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
4435}
4436
4437fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
4438    let mut diagnostics = Vec::new();
4439    let mut node_counts = BTreeMap::<&str, usize>::new();
4440    for row in &rows.nodes {
4441        *node_counts.entry(row.external_id.as_str()).or_default() += 1;
4442    }
4443    for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
4444        diagnostics.push(format!(
4445            "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
4446        ));
4447    }
4448
4449    let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
4450    let mut edge_counts = BTreeMap::<&str, usize>::new();
4451    for edge in &rows.edges {
4452        *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
4453        if !node_ids.contains(edge.from_external_id.as_str()) {
4454            diagnostics.push(format!(
4455                "Convex snapshot edge {} references missing from node {}",
4456                edge.edge_key, edge.from_external_id
4457            ));
4458        }
4459        if !node_ids.contains(edge.to_external_id.as_str()) {
4460            diagnostics.push(format!(
4461                "Convex snapshot edge {} references missing to node {}",
4462                edge.edge_key, edge.to_external_id
4463            ));
4464        }
4465        let expected_key =
4466            ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
4467        if edge.edge_key != expected_key {
4468            diagnostics.push(format!(
4469                "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
4470                edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
4471            ));
4472        }
4473    }
4474    for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
4475        diagnostics.push(format!(
4476            "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
4477        ));
4478    }
4479    diagnostics
4480}
4481
4482pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
4483    let diagnostics = convex_projection_row_diagnostics(rows);
4484    if diagnostics.is_empty() {
4485        Ok(())
4486    } else {
4487        bail!("{}", diagnostics.join("; "))
4488    }
4489}
4490
4491pub(crate) struct ConvexHttpTransport {
4492    endpoint: String,
4493    auth_token_env: String,
4494    auth_token: Option<String>,
4495}
4496
4497impl ConvexHttpTransport {
4498    fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
4499        let endpoint = endpoint
4500            .map(str::to_string)
4501            .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
4502            .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
4503        let auth_token = env::var(auth_token_env)
4504            .ok()
4505            .filter(|value| !value.trim().is_empty());
4506        Ok(Self {
4507            endpoint,
4508            auth_token_env: auth_token_env.to_string(),
4509            auth_token,
4510        })
4511    }
4512
4513    fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
4514        ConvexTransportSummary {
4515            endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
4516            endpoint_configured: true,
4517            auth_token_env: self.auth_token_env.clone(),
4518            auth_configured: self.auth_token.is_some(),
4519            remote_snapshot,
4520            applied_chunks,
4521        }
4522    }
4523
4524    fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
4525        let mut builder = ureq::post(&self.endpoint);
4526        if let Some(token) = &self.auth_token {
4527            builder = builder.header("Authorization", &format!("Bearer {token}"));
4528        }
4529        builder
4530            .send_json(request)
4531            .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
4532            .body_mut()
4533            .read_json::<ConvexTransportResponse>()
4534            .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
4535    }
4536
4537    /// Fetch a full snapshot of the Convex graph backend.
4538    ///
4539    /// Uses the paginated `snapshot_meta` + `snapshot_nodes_page` +
4540    /// `snapshot_edges_page` triplet so the call works on tables larger than
4541    /// ~5k rows (the single-shot `snapshot` query hits Convex's 15s per-request
4542    /// syscall budget at that scale; see `#convexsnapshotscale`).
4543    ///
4544    /// Falls back to the legacy single-shot `snapshot` operation if the
4545    /// backend doesn't recognize `snapshot_meta` (older deployments that
4546    /// haven't redeployed the new schema).
4547    fn fetch_snapshot(
4548        &self,
4549        projection_version: &str,
4550        scope: Option<&str>,
4551        local_hash: Option<&str>,
4552        local_rows: Option<&ConvexProjectionRows>,
4553    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4554        match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
4555            Ok(rows) => Ok(rows),
4556            Err(err) => {
4557                // Only fall through to the legacy path if the failure looks
4558                // like "operation unknown" (older backend). Any other failure
4559                // (HTTP timeout, deserialization mismatch) should surface so
4560                // the operator sees the real cause.
4561                let msg = format!("{err:#}");
4562                let is_unknown_op = msg.contains("unknown operation")
4563                    || msg.contains("snapshot_meta")
4564                    || msg.contains("404");
4565                if !is_unknown_op {
4566                    return Err(err);
4567                }
4568                self.fetch_snapshot_legacy(projection_version)
4569                    .map(|rows| (rows, Vec::new()))
4570            }
4571        }
4572    }
4573
4574    fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
4575        let response = self.post(&ConvexTransportRequest {
4576            operation: "snapshot",
4577            chunk: 0,
4578            projection_version,
4579            projection_hash: None,
4580            projection_meta_id: None,
4581            node_rows: Vec::new(),
4582            edge_rows: Vec::new(),
4583            keys: Vec::new(),
4584            cursor: None,
4585            limit: None,
4586        })?;
4587        response
4588            .rows
4589            .context("Convex snapshot response did not include rows")
4590    }
4591
4592    fn fetch_snapshot_paginated(
4593        &self,
4594        projection_version: &str,
4595        scope: Option<&str>,
4596        local_hash: Option<&str>,
4597        local_rows: Option<&ConvexProjectionRows>,
4598    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4599        let projection_meta_id = graph_projection_meta_id(scope);
4600        let meta_response = self.post(&ConvexTransportRequest {
4601            operation: "snapshot_meta",
4602            chunk: 0,
4603            projection_version,
4604            projection_hash: None,
4605            projection_meta_id: Some(&projection_meta_id),
4606            node_rows: Vec::new(),
4607            edge_rows: Vec::new(),
4608            keys: Vec::new(),
4609            cursor: None,
4610            limit: None,
4611        })?;
4612        if matches!(meta_response.status.as_deref(), Some("error")) {
4613            anyhow::bail!(
4614                "Convex snapshot_meta returned error: {}",
4615                meta_response.message.unwrap_or_default()
4616            );
4617        }
4618        let meta = meta_response
4619            .meta
4620            .context("Convex snapshot_meta response did not include meta")?;
4621        if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
4622            (meta.projection_hash.as_deref(), local_hash, local_rows)
4623            && remote_hash == local_hash
4624        {
4625            return Ok((
4626                local_rows.clone(),
4627                vec![
4628                    "remote projection hash matched local graph; skipped full row-page snapshot diff"
4629                        .to_string(),
4630                ],
4631            ));
4632        }
4633
4634        let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
4635        let mut node_cursor: Option<String> = None;
4636        loop {
4637            let response = self.post(&ConvexTransportRequest {
4638                operation: "snapshot_nodes_page",
4639                chunk: 0,
4640                projection_version,
4641                projection_hash: None,
4642                projection_meta_id: None,
4643                node_rows: Vec::new(),
4644                edge_rows: Vec::new(),
4645                keys: Vec::new(),
4646                cursor: node_cursor.clone(),
4647                limit: None,
4648            })?;
4649            let page = response
4650                .page
4651                .context("Convex snapshot_nodes_page response did not include page")?;
4652            for raw in page.rows {
4653                let row: ConvexNodeRow =
4654                    serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
4655                nodes.push(row);
4656            }
4657            match page.next_cursor {
4658                Some(next) => node_cursor = Some(next),
4659                None => break,
4660            }
4661        }
4662
4663        let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
4664        let mut edge_cursor: Option<String> = None;
4665        loop {
4666            let response = self.post(&ConvexTransportRequest {
4667                operation: "snapshot_edges_page",
4668                chunk: 0,
4669                projection_version,
4670                projection_hash: None,
4671                projection_meta_id: None,
4672                node_rows: Vec::new(),
4673                edge_rows: Vec::new(),
4674                keys: Vec::new(),
4675                cursor: edge_cursor.clone(),
4676                limit: None,
4677            })?;
4678            let page = response
4679                .page
4680                .context("Convex snapshot_edges_page response did not include page")?;
4681            for raw in page.rows {
4682                let row: ConvexEdgeRow =
4683                    serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
4684                edges.push(row);
4685            }
4686            match page.next_cursor {
4687                Some(next) => edge_cursor = Some(next),
4688                None => break,
4689            }
4690        }
4691
4692        Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
4693    }
4694
4695    fn apply_chunk(
4696        &self,
4697        report: &ConvexSyncReport,
4698        chunk: &ConvexSyncChunk,
4699    ) -> Result<ConvexTransportReceipt> {
4700        let node_rows = if chunk.operation == "upsert_nodes" {
4701            report
4702                .node_upserts
4703                .iter()
4704                .filter(|row| chunk.keys.contains(&row.external_id))
4705                .cloned()
4706                .collect()
4707        } else {
4708            Vec::new()
4709        };
4710        let edge_rows = if chunk.operation == "upsert_edges" {
4711            report
4712                .edge_upserts
4713                .iter()
4714                .filter(|row| chunk.keys.contains(&row.edge_key))
4715                .cloned()
4716                .collect()
4717        } else {
4718            Vec::new()
4719        };
4720        let request = ConvexTransportRequest {
4721            operation: &chunk.operation,
4722            chunk: chunk.chunk,
4723            projection_version: &report.projection_version,
4724            projection_hash: report.projection_hash.as_deref(),
4725            projection_meta_id: None,
4726            node_rows,
4727            edge_rows,
4728            keys: chunk.keys.clone(),
4729            cursor: None,
4730            limit: None,
4731        };
4732        let mut last_error = None;
4733        for attempt in 1..=chunk.max_attempts {
4734            match self.post(&request) {
4735                Ok(response) => {
4736                    return Ok(ConvexTransportReceipt {
4737                        operation: chunk.operation.clone(),
4738                        chunk: chunk.chunk,
4739                        attempt,
4740                        status: response.status.unwrap_or_else(|| "ok".to_string()),
4741                        message: response.message,
4742                    });
4743                }
4744                Err(err) => {
4745                    last_error = Some(err);
4746                    if attempt < chunk.max_attempts {
4747                        std::thread::sleep(Duration::from_millis(100 * attempt as u64));
4748                    }
4749                }
4750            }
4751        }
4752        Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
4753            .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
4754    }
4755}
4756
4757fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
4758    let meta_id = graph_projection_meta_id(scope);
4759    rows.nodes
4760        .iter()
4761        .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
4762        .and_then(|row| row.properties.get("content_hash").cloned())
4763}
4764
4765fn convex_projection_freshness(
4766    local: &ConvexProjectionRows,
4767    snapshot: Option<&ConvexProjectionRows>,
4768    scope: Option<&str>,
4769) -> ConvexProjectionFreshness {
4770    let local_hash = convex_projection_hash(local, scope);
4771    let Some(snapshot) = snapshot else {
4772        return ConvexProjectionFreshness {
4773            status: "unchecked".to_string(),
4774            fail_closed: false,
4775            local_hash,
4776            snapshot_hash: None,
4777            missing_nodes: Vec::new(),
4778            stale_nodes: Vec::new(),
4779            missing_edges: Vec::new(),
4780            stale_edges: Vec::new(),
4781            diagnostics: vec![
4782                "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
4783            ],
4784        };
4785    };
4786
4787    let snapshot_hash = convex_projection_hash(snapshot, scope);
4788    let snapshot_nodes = snapshot
4789        .nodes
4790        .iter()
4791        .map(|row| (row.external_id.as_str(), row))
4792        .collect::<BTreeMap<_, _>>();
4793    let snapshot_edges = snapshot
4794        .edges
4795        .iter()
4796        .map(|row| (row.edge_key.as_str(), row))
4797        .collect::<BTreeMap<_, _>>();
4798
4799    let mut missing_nodes = Vec::new();
4800    let mut stale_nodes = Vec::new();
4801    for row in &local.nodes {
4802        match snapshot_nodes.get(row.external_id.as_str()) {
4803            Some(snapshot_row) if *snapshot_row == row => {}
4804            Some(_) => stale_nodes.push(row.external_id.clone()),
4805            None => missing_nodes.push(row.external_id.clone()),
4806        }
4807    }
4808
4809    let mut missing_edges = Vec::new();
4810    let mut stale_edges = Vec::new();
4811    for row in &local.edges {
4812        match snapshot_edges.get(row.edge_key.as_str()) {
4813            Some(snapshot_row) if *snapshot_row == row => {}
4814            Some(_) => stale_edges.push(row.edge_key.clone()),
4815            None => missing_edges.push(row.edge_key.clone()),
4816        }
4817    }
4818
4819    let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
4820    let rows_current = missing_nodes.is_empty()
4821        && stale_nodes.is_empty()
4822        && missing_edges.is_empty()
4823        && stale_edges.is_empty();
4824    if hash_current && rows_current {
4825        return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
4826    }
4827
4828    let mut diagnostics = Vec::new();
4829    if local_hash != snapshot_hash {
4830        diagnostics.push(format!(
4831            "projection hash mismatch: local={} snapshot={}",
4832            local_hash.as_deref().unwrap_or("missing"),
4833            snapshot_hash.as_deref().unwrap_or("missing")
4834        ));
4835    }
4836    if !missing_nodes.is_empty() || !missing_edges.is_empty() {
4837        diagnostics.push(format!(
4838            "Convex snapshot is missing {} node(s) and {} edge(s)",
4839            missing_nodes.len(),
4840            missing_edges.len()
4841        ));
4842    }
4843    if !stale_nodes.is_empty() || !stale_edges.is_empty() {
4844        diagnostics.push(format!(
4845            "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
4846            stale_nodes.len(),
4847            stale_edges.len()
4848        ));
4849    }
4850
4851    ConvexProjectionFreshness {
4852        status: "stale".to_string(),
4853        fail_closed: true,
4854        local_hash,
4855        snapshot_hash,
4856        missing_nodes,
4857        stale_nodes,
4858        missing_edges,
4859        stale_edges,
4860        diagnostics,
4861    }
4862}
4863
4864pub(crate) fn verify_convex_projection_snapshot(
4865    root: &Path,
4866    scope: Option<&str>,
4867    snapshot_path: &Path,
4868) -> Result<()> {
4869    let graph_db = graph_substrate_db_path(root, scope);
4870    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4871    let local = convex_rows_from_graph_store(&store)?;
4872    let snapshot = load_convex_projection_rows(snapshot_path)?;
4873    validate_convex_projection_rows(&snapshot)?;
4874    let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
4875    if freshness.fail_closed {
4876        bail!(
4877            "Convex graph projection is not current for {}: {}",
4878            root.display(),
4879            freshness.diagnostics.join("; ")
4880        );
4881    }
4882    Ok(())
4883}
4884
4885fn convex_rows_diff(
4886    local: &ConvexProjectionRows,
4887    snapshot: Option<&ConvexProjectionRows>,
4888) -> (
4889    Vec<ConvexNodeRow>,
4890    Vec<ConvexEdgeRow>,
4891    Vec<String>,
4892    Vec<String>,
4893) {
4894    let Some(snapshot) = snapshot else {
4895        return (
4896            local.nodes.clone(),
4897            local.edges.clone(),
4898            Vec::new(),
4899            Vec::new(),
4900        );
4901    };
4902    let local_nodes = local
4903        .nodes
4904        .iter()
4905        .map(|row| (row.external_id.as_str(), row))
4906        .collect::<BTreeMap<_, _>>();
4907    let local_edges = local
4908        .edges
4909        .iter()
4910        .map(|row| (row.edge_key.as_str(), row))
4911        .collect::<BTreeMap<_, _>>();
4912    let snapshot_nodes = snapshot
4913        .nodes
4914        .iter()
4915        .map(|row| (row.external_id.as_str(), row))
4916        .collect::<BTreeMap<_, _>>();
4917    let snapshot_edges = snapshot
4918        .edges
4919        .iter()
4920        .map(|row| (row.edge_key.as_str(), row))
4921        .collect::<BTreeMap<_, _>>();
4922
4923    let node_upserts = local
4924        .nodes
4925        .iter()
4926        .filter(|row| {
4927            snapshot_nodes
4928                .get(row.external_id.as_str())
4929                .is_none_or(|snapshot_row| *snapshot_row != *row)
4930        })
4931        .cloned()
4932        .collect::<Vec<_>>();
4933    let edge_upserts = local
4934        .edges
4935        .iter()
4936        .filter(|row| {
4937            snapshot_edges
4938                .get(row.edge_key.as_str())
4939                .is_none_or(|snapshot_row| *snapshot_row != *row)
4940        })
4941        .cloned()
4942        .collect::<Vec<_>>();
4943    let node_tombstones = snapshot
4944        .nodes
4945        .iter()
4946        .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
4947        .map(|row| row.external_id.clone())
4948        .collect::<Vec<_>>();
4949    let edge_tombstones = snapshot
4950        .edges
4951        .iter()
4952        .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
4953        .map(|row| row.edge_key.clone())
4954        .collect::<Vec<_>>();
4955
4956    (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
4957}
4958
4959fn push_sync_chunks(
4960    chunks: &mut Vec<ConvexSyncChunk>,
4961    operation: &str,
4962    keys: Vec<String>,
4963    size: usize,
4964) {
4965    if keys.is_empty() {
4966        return;
4967    }
4968    for (idx, chunk) in keys.chunks(size).enumerate() {
4969        chunks.push(ConvexSyncChunk {
4970            operation: operation.to_string(),
4971            chunk: idx + 1,
4972            count: chunk.len(),
4973            keys: chunk.to_vec(),
4974            max_attempts: 3,
4975            retry_policy:
4976                "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
4977                    .to_string(),
4978        });
4979    }
4980}
4981
4982pub(crate) fn build_convex_sync_report_with_snapshot(
4983    path: &Path,
4984    scope: Option<&str>,
4985    snapshot: Option<ConvexProjectionRows>,
4986    chunk_size: usize,
4987    dry_run: bool,
4988) -> Result<ConvexSyncReport> {
4989    if chunk_size == 0 {
4990        bail!("--chunk-size must be greater than zero");
4991    }
4992    let root = lint::resolve_project_root_or_canonical_path(path)?;
4993    let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
4994    let graph_db = graph_substrate_db_path(&root, scope);
4995    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4996    let local = convex_rows_from_graph_store(&store)?;
4997    let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
4998    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
4999        convex_rows_diff(&local, snapshot.as_ref());
5000
5001    let mut chunks = Vec::new();
5002    push_sync_chunks(
5003        &mut chunks,
5004        "delete_edges",
5005        edge_tombstones.clone(),
5006        chunk_size,
5007    );
5008    push_sync_chunks(
5009        &mut chunks,
5010        "upsert_nodes",
5011        node_upserts
5012            .iter()
5013            .map(|row| row.external_id.clone())
5014            .collect(),
5015        chunk_size,
5016    );
5017    push_sync_chunks(
5018        &mut chunks,
5019        "upsert_edges",
5020        edge_upserts
5021            .iter()
5022            .map(|row| row.edge_key.clone())
5023            .collect(),
5024        chunk_size,
5025    );
5026    push_sync_chunks(
5027        &mut chunks,
5028        "delete_nodes",
5029        node_tombstones.clone(),
5030        chunk_size,
5031    );
5032
5033    let mut diagnostics = vec![
5034        "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5035            .to_string(),
5036    ];
5037    if dry_run {
5038        diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5039    }
5040    if freshness.fail_closed {
5041        diagnostics.push(
5042            "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5043                .to_string(),
5044        );
5045    }
5046
5047    Ok(ConvexSyncReport {
5048        root: root.to_string_lossy().to_string(),
5049        scope: scope.map(str::to_string),
5050        graph_db: graph_db.to_string_lossy().to_string(),
5051        dry_run,
5052        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5053        projection_hash: convex_projection_hash(&local, scope),
5054        required_indexes: convex_required_indexes(),
5055        node_upserts,
5056        edge_upserts,
5057        node_tombstones,
5058        edge_tombstones,
5059        chunks,
5060        freshness,
5061        transport: None,
5062        receipts: Vec::new(),
5063        diagnostics,
5064        warnings: graph.warnings,
5065    })
5066}
5067
5068#[cfg(test)]
5069fn build_convex_sync_report(
5070    path: &Path,
5071    scope: Option<&str>,
5072    snapshot_path: Option<&Path>,
5073    chunk_size: usize,
5074) -> Result<ConvexSyncReport> {
5075    let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5076    build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5077}
5078
5079pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5080    if compact {
5081        println!(
5082            "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5083            report.node_upserts.len(),
5084            report.node_tombstones.len(),
5085            report.edge_upserts.len(),
5086            report.edge_tombstones.len(),
5087            report.chunks.len(),
5088            report.freshness.status
5089        );
5090        return;
5091    }
5092
5093    println!(
5094        "Convex graph sync {}",
5095        if report.dry_run { "dry-run" } else { "apply" }
5096    );
5097    println!("root: {}", report.root);
5098    println!("graph_db: {}", report.graph_db);
5099    println!(
5100        "upserts: {} node(s), {} edge(s)",
5101        report.node_upserts.len(),
5102        report.edge_upserts.len()
5103    );
5104    println!(
5105        "tombstones: {} node(s), {} edge(s)",
5106        report.node_tombstones.len(),
5107        report.edge_tombstones.len()
5108    );
5109    println!("chunks: {}", report.chunks.len());
5110    println!("freshness: {}", report.freshness.status);
5111    if let Some(transport) = &report.transport {
5112        println!(
5113            "transport: endpoint_env={} auth_env={} applied_chunks={}",
5114            transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5115        );
5116    }
5117    for receipt in &report.receipts {
5118        println!(
5119            "receipt: {} chunk {} attempt {} {}",
5120            receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5121        );
5122    }
5123    for diagnostic in report
5124        .diagnostics
5125        .iter()
5126        .chain(report.freshness.diagnostics.iter())
5127    {
5128        println!("- {}", diagnostic);
5129    }
5130}
5131
5132pub(crate) struct ConvexSyncOptions<'a> {
5133    path: &'a Path,
5134    scope: Option<&'a str>,
5135    snapshot: Option<&'a Path>,
5136    chunk_size: usize,
5137    remote_snapshot: bool,
5138    apply: bool,
5139    endpoint: Option<&'a str>,
5140    auth_token_env: &'a str,
5141}
5142
5143#[derive(Serialize)]
5144struct GraphDbSchemaField {
5145    name: &'static str,
5146    value_type: &'static str,
5147    description: &'static str,
5148}
5149
5150#[derive(Serialize)]
5151struct GraphDbSchemaOperation {
5152    command: &'static str,
5153    description: &'static str,
5154}
5155
5156#[derive(Serialize)]
5157struct GraphDbSchemaContract {
5158    name: &'static str,
5159    version: &'static str,
5160    description: &'static str,
5161}
5162
5163#[derive(Serialize)]
5164struct GraphDbSchema {
5165    contract_versions: Vec<GraphDbSchemaContract>,
5166    node_fields: Vec<GraphDbSchemaField>,
5167    edge_fields: Vec<GraphDbSchemaField>,
5168    operations: Vec<GraphDbSchemaOperation>,
5169}
5170
5171#[derive(Clone, Serialize, Deserialize)]
5172struct GraphDbFreshnessReport {
5173    status: String,
5174    fail_closed: bool,
5175    projection_version: Option<String>,
5176    content_hash: Option<String>,
5177    source_watermark: Option<String>,
5178    diagnostics: Vec<String>,
5179}
5180
5181#[derive(Clone, Debug, Serialize)]
5182pub(crate) struct GraphEffectivenessReadiness {
5183    pub(crate) status: String,
5184    pub(crate) fail_closed: bool,
5185    pub(crate) reason: String,
5186    pub(crate) diagnostics: Vec<String>,
5187    pub(crate) next_commands: Vec<String>,
5188}
5189
5190#[derive(Clone, Debug, Serialize, PartialEq)]
5191struct GraphDbPropertyFilter {
5192    key: String,
5193    value: String,
5194}
5195
5196#[derive(Clone, Debug, Default)]
5197struct GraphDbQueryOptions {
5198    cursor: Option<String>,
5199    limit: Option<usize>,
5200    property_filters: Vec<GraphDbPropertyFilter>,
5201}
5202
5203#[derive(Clone, Debug, Serialize, PartialEq)]
5204struct GraphDbPageReport {
5205    #[serde(skip_serializing_if = "Option::is_none")]
5206    cursor: Option<String>,
5207    #[serde(skip_serializing_if = "Option::is_none")]
5208    limit: Option<usize>,
5209    #[serde(skip_serializing_if = "Option::is_none")]
5210    next_cursor: Option<String>,
5211    returned_nodes: usize,
5212    returned_edges: usize,
5213    truncated: bool,
5214    property_filters: Vec<GraphDbPropertyFilter>,
5215    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5216    diagnostics: Vec<String>,
5217}
5218
5219type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5220
5221#[derive(Clone, Debug, Serialize)]
5222struct CommunityTruncationSummary {
5223    total_communities: usize,
5224    fully_kept: usize,
5225    partially_pruned: usize,
5226    fully_pruned: usize,
5227    pruned_community_kinds: Vec<String>,
5228    pruned_community_top_labels: Vec<String>,
5229}
5230
5231#[derive(Clone, Debug, Serialize)]
5232struct GraphDbRankedNeighborhoodComparison {
5233    traversal_nodes: usize,
5234    traversal_edges: usize,
5235    pruned_count: usize,
5236    total_discovered: usize,
5237    latency_micros: u128,
5238    overlap_with_unranked_pct: f64,
5239    useful_hit_density_ranked: f64,
5240    useful_hit_density_unranked: f64,
5241    duplicate_name_count_ranked: usize,
5242    duplicate_name_count_unranked: usize,
5243    handle_coverage_ranked_pct: f64,
5244    handle_coverage_unranked_pct: f64,
5245    #[serde(skip_serializing_if = "Option::is_none")]
5246    community_truncation_summary: Option<CommunityTruncationSummary>,
5247    diagnostics: Vec<String>,
5248}
5249
5250#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5251struct GraphDbDroppedByBudget {
5252    item: String,
5253    kind: String,
5254    dropped: usize,
5255    reason: String,
5256}
5257
5258#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5259struct GraphDbOutputBudgetReport {
5260    max_tokens: usize,
5261    estimated_tokens: usize,
5262    selected_nodes: usize,
5263    selected_edges: usize,
5264    candidate_nodes: usize,
5265    candidate_edges: usize,
5266    dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5267    diagnostics: Vec<String>,
5268}
5269
5270#[derive(Clone, Debug, Serialize, PartialEq)]
5271struct GraphDbKnowledgeRetrieval {
5272    mode: String,
5273    query: String,
5274    seed_kind: String,
5275    seed_limit: usize,
5276    seed_count: usize,
5277    depth: usize,
5278    limit: usize,
5279    node_count: usize,
5280    edge_count: usize,
5281    truncated: bool,
5282    traversal: String,
5283    freshness_boundary: String,
5284    privacy_boundary: String,
5285    diagnostics: Vec<String>,
5286}
5287
5288struct GraphDbSemanticSeededSubgraph {
5289    nodes: Vec<SubstrateGraphNode>,
5290    edges: Vec<SubstrateGraphEdge>,
5291    truncated: bool,
5292    diagnostics: Vec<String>,
5293}
5294
5295type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5296
5297#[derive(Serialize)]
5298struct GraphDbReport {
5299    root: String,
5300    #[serde(skip_serializing_if = "Option::is_none")]
5301    scope: Option<String>,
5302    backend: String,
5303    query: String,
5304    freshness: GraphDbFreshnessReport,
5305    #[serde(skip_serializing_if = "Option::is_none")]
5306    readiness: Option<GraphEffectivenessReadiness>,
5307    #[serde(skip_serializing_if = "Option::is_none")]
5308    schema: Option<GraphDbSchema>,
5309    #[serde(skip_serializing_if = "Option::is_none")]
5310    node: Option<SubstrateTerseGraphNode>,
5311    #[serde(skip_serializing_if = "Option::is_none")]
5312    edge: Option<SubstrateTerseGraphEdge>,
5313    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5314    nodes: Vec<SubstrateTerseGraphNode>,
5315    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5316    edges: Vec<SubstrateTerseGraphEdge>,
5317    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5318    ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5319    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5320    semantic_related: Vec<SemanticRelatedItem>,
5321    #[serde(skip_serializing_if = "Option::is_none")]
5322    neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5323    #[serde(skip_serializing_if = "Option::is_none")]
5324    ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5325    #[serde(skip_serializing_if = "Option::is_none")]
5326    knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5327    #[serde(skip_serializing_if = "Option::is_none")]
5328    output_budget: Option<GraphDbOutputBudgetReport>,
5329    #[serde(skip_serializing_if = "Option::is_none")]
5330    path: Option<substrate::GraphPath>,
5331    #[serde(skip_serializing_if = "Option::is_none")]
5332    page: Option<GraphDbPageReport>,
5333    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5334    warnings: Vec<String>,
5335}
5336
5337struct ExperimentalReadOnlyGraphStore {
5338    backend: GraphDbExperimentalBackend,
5339    nodes: BTreeMap<String, SubstrateGraphNode>,
5340    edges: BTreeMap<String, SubstrateGraphEdge>,
5341    node_ids_by_kind: BTreeMap<String, Vec<String>>,
5342    outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5343}
5344
5345impl ExperimentalReadOnlyGraphStore {
5346    fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5347        validate_convex_projection_rows(rows)?;
5348        let nodes = rows
5349            .nodes
5350            .iter()
5351            .map(|row| {
5352                let node = SubstrateGraphNode {
5353                    id: row.external_id.clone(),
5354                    kind: row.kind.clone(),
5355                    label: row.label.clone(),
5356                    properties: row.properties.clone(),
5357                    provenance: row.provenance.clone(),
5358                    freshness: row.freshness.clone(),
5359                };
5360                (node.id.clone(), node)
5361            })
5362            .collect::<BTreeMap<_, _>>();
5363        let edges = rows
5364            .edges
5365            .iter()
5366            .map(|row| {
5367                let edge = SubstrateGraphEdge {
5368                    id: row.edge_key.clone(),
5369                    from_id: row.from_external_id.clone(),
5370                    to_id: row.to_external_id.clone(),
5371                    kind: row.kind.clone(),
5372                    properties: row.properties.clone(),
5373                    provenance: row.provenance.clone(),
5374                    freshness: row.freshness.clone(),
5375                };
5376                (graph_db_edge_key(&edge), edge)
5377            })
5378            .collect::<BTreeMap<_, _>>();
5379        let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
5380        for node in nodes.values() {
5381            node_ids_by_kind
5382                .entry(node.kind.clone())
5383                .or_default()
5384                .push(node.id.clone());
5385        }
5386        for ids in node_ids_by_kind.values_mut() {
5387            ids.sort();
5388        }
5389        let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
5390        for edge in edges.values() {
5391            outgoing_edge_keys_by_from
5392                .entry(edge.from_id.clone())
5393                .or_default()
5394                .push(graph_db_edge_key(edge));
5395        }
5396        for edge_keys in outgoing_edge_keys_by_from.values_mut() {
5397            edge_keys.sort_by(|left_key, right_key| {
5398                let left = &edges[left_key];
5399                let right = &edges[right_key];
5400                left.to_id
5401                    .cmp(&right.to_id)
5402                    .then(left.kind.cmp(&right.kind))
5403                    .then(left_key.cmp(right_key))
5404            });
5405        }
5406        Ok(Self {
5407            backend,
5408            nodes,
5409            edges,
5410            node_ids_by_kind,
5411            outgoing_edge_keys_by_from,
5412        })
5413    }
5414}
5415
5416impl GraphStore for ExperimentalReadOnlyGraphStore {
5417    fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
5418        bail!("{} backend-eval adapter is read-only", self.backend.name())
5419    }
5420
5421    fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
5422        bail!("{} backend-eval adapter is read-only", self.backend.name())
5423    }
5424
5425    fn delete_node(&self, _id: &str) -> Result<usize> {
5426        bail!("{} backend-eval adapter is read-only", self.backend.name())
5427    }
5428
5429    fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
5430        bail!("{} backend-eval adapter is read-only", self.backend.name())
5431    }
5432
5433    fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
5434        Ok(self.nodes.get(id).cloned())
5435    }
5436
5437    fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
5438        Ok(self.nodes.values().cloned().collect())
5439    }
5440
5441    fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
5442        let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
5443        edges.sort_by(|left, right| {
5444            left.from_id
5445                .cmp(&right.from_id)
5446                .then(left.kind.cmp(&right.kind))
5447                .then(left.to_id.cmp(&right.to_id))
5448        });
5449        Ok(edges)
5450    }
5451
5452    fn graph_counts(&self) -> Result<(usize, usize)> {
5453        Ok((self.nodes.len(), self.edges.len()))
5454    }
5455
5456    fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
5457        let mut edges = self
5458            .edges
5459            .values()
5460            .filter(|edge| edge.from_id != edge.to_id)
5461            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5462            .cloned()
5463            .collect::<Vec<_>>();
5464        edges.sort_by(|left, right| {
5465            left.from_id
5466                .cmp(&right.from_id)
5467                .then(left.kind.cmp(&right.kind))
5468                .then(left.to_id.cmp(&right.to_id))
5469        });
5470        Ok(edges.into_iter().next())
5471    }
5472
5473    fn sample_edge_with_property(
5474        &self,
5475    ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
5476        Ok(self
5477            .edges
5478            .values()
5479            .filter(|edge| edge.from_id != edge.to_id)
5480            .filter_map(|edge| {
5481                edge.properties.iter().next().map(|(key, value)| {
5482                    (
5483                        edge,
5484                        GraphPropertyFilter {
5485                            key: key.clone(),
5486                            value: value.clone(),
5487                        },
5488                    )
5489                })
5490            })
5491            .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
5492                left_filter
5493                    .key
5494                    .cmp(&right_filter.key)
5495                    .then(left_filter.value.cmp(&right_filter.value))
5496                    .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
5497            })
5498            .map(|(edge, filter)| (edge.clone(), filter)))
5499    }
5500
5501    fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
5502        Ok(self
5503            .node_ids_by_kind
5504            .get(kind)
5505            .into_iter()
5506            .flatten()
5507            .filter_map(|id| self.nodes.get(id).cloned())
5508            .collect())
5509    }
5510
5511    fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
5512        Ok(self
5513            .outgoing_edge_keys_by_from
5514            .get(from_id)
5515            .into_iter()
5516            .flatten()
5517            .filter_map(|key| self.edges.get(key))
5518            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5519            .cloned()
5520            .collect())
5521    }
5522
5523    fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
5524        Ok(self
5525            .edges
5526            .values()
5527            .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
5528            .cloned()
5529            .collect())
5530    }
5531
5532    fn shortest_path(
5533        &self,
5534        from_id: &str,
5535        to_id: &str,
5536        kind: Option<&str>,
5537    ) -> Result<Option<substrate::GraphPath>> {
5538        if from_id == to_id {
5539            return Ok(Some(substrate::GraphPath {
5540                nodes: vec![from_id.to_string()],
5541                hops: 0,
5542            }));
5543        }
5544
5545        let mut queue = VecDeque::new();
5546        let mut parent = BTreeMap::<String, String>::new();
5547        parent.insert(from_id.to_string(), String::new());
5548        queue.push_back(from_id.to_string());
5549
5550        while let Some(current) = queue.pop_front() {
5551            for edge in self.outgoing_edges(&current, kind)? {
5552                if parent.contains_key(&edge.to_id) {
5553                    continue;
5554                }
5555                parent.insert(edge.to_id.clone(), current.clone());
5556                if edge.to_id == to_id {
5557                    let mut nodes = vec![to_id.to_string()];
5558                    let mut cursor = to_id;
5559                    while let Some(previous) = parent.get(cursor) {
5560                        if previous.is_empty() {
5561                            break;
5562                        }
5563                        nodes.push(previous.clone());
5564                        cursor = previous;
5565                    }
5566                    nodes.reverse();
5567                    return Ok(Some(substrate::GraphPath {
5568                        hops: nodes.len().saturating_sub(1),
5569                        nodes,
5570                    }));
5571                }
5572                queue.push_back(edge.to_id);
5573            }
5574        }
5575
5576        Ok(None)
5577    }
5578
5579    fn reachable_nodes_by_kinds(
5580        &self,
5581        from_id: &str,
5582        kinds: &[&str],
5583        depth: usize,
5584        limit: usize,
5585    ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
5586        let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
5587        let mut rows = requested
5588            .iter()
5589            .map(|kind| {
5590                (
5591                    (*kind).to_string(),
5592                    BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
5593                )
5594            })
5595            .collect::<BTreeMap<_, _>>();
5596        if requested.is_empty() {
5597            return Ok(BTreeMap::new());
5598        }
5599
5600        let mut seen = BTreeSet::from([from_id.to_string()]);
5601        let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
5602        while let Some((current, path)) = queue.pop_front() {
5603            let current_depth = path.len().saturating_sub(1);
5604            if current_depth >= depth {
5605                continue;
5606            }
5607            for edge in self.outgoing_edges(&current, None)? {
5608                if !seen.insert(edge.to_id.clone()) {
5609                    continue;
5610                }
5611                let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
5612                    continue;
5613                };
5614                let mut next_path = path.clone();
5615                next_path.push(edge.to_id.clone());
5616                let graph_path = substrate::GraphPath {
5617                    hops: next_path.len().saturating_sub(1),
5618                    nodes: next_path.clone(),
5619                };
5620                if requested.contains(node.kind.as_str()) {
5621                    rows.entry(node.kind.clone())
5622                        .or_default()
5623                        .entry(node.id.clone())
5624                        .or_insert((node.clone(), graph_path));
5625                }
5626                queue.push_back((edge.to_id, next_path));
5627            }
5628        }
5629
5630        Ok(rows
5631            .into_iter()
5632            .map(|(kind, values)| {
5633                let mut values = values.into_values().collect::<Vec<_>>();
5634                values.sort_by(|(left_node, left_path), (right_node, right_path)| {
5635                    left_path
5636                        .hops
5637                        .cmp(&right_path.hops)
5638                        .then(left_node.label.cmp(&right_node.label))
5639                        .then(left_node.id.cmp(&right_node.id))
5640                });
5641                if limit > 0 && values.len() > limit {
5642                    values.truncate(limit);
5643                }
5644                (kind, values)
5645            })
5646            .collect())
5647    }
5648}
5649
5650pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
5651pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
5652pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
5653const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
5654pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
5655const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
5656const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
5657const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
5658const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
5659
5660#[derive(Clone, Serialize, Deserialize)]
5661pub(crate) struct GraphDbBackendEvalPhaseTiming {
5662    name: String,
5663    duration_micros: u128,
5664    detail: String,
5665}
5666
5667#[derive(Serialize, Deserialize)]
5668struct GraphDbBackendEvalFullProjectionCache {
5669    version: String,
5670    key: String,
5671    source_watermark: String,
5672    projection: GraphProjection,
5673    warnings: Vec<String>,
5674}
5675
5676#[derive(Clone, Default)]
5677struct GraphDbBackendEvalFullProjectionCacheStats {
5678    hit: bool,
5679    disk_bytes: u64,
5680    json_bytes: u64,
5681    pruned_files: usize,
5682    pruned_bytes: u64,
5683}
5684
5685#[derive(Serialize)]
5686struct GraphDbBackendEvalRawSourceWatermarkRow {
5687    path: String,
5688    bytes: u64,
5689    content_hash: String,
5690}
5691
5692#[derive(Clone)]
5693struct GraphDbBackendEvalFullProjectionSourceWatermark {
5694    value: String,
5695    detail: String,
5696}
5697
5698#[derive(Serialize)]
5699pub(crate) struct GraphDbBackendEvalConfig {
5700    high_degree_nodes: usize,
5701    high_degree_fanout: usize,
5702    deep_chain_nodes: usize,
5703    deep_chain_fanout: usize,
5704    depth: usize,
5705    limit: usize,
5706    impact_limit: usize,
5707    path_max_hops: usize,
5708    path_direct_hop_budget: usize,
5709    path_deep_chain_hop_budget: usize,
5710    path_extended_hop_budgets: Vec<usize>,
5711    path_hop_policy: String,
5712    path_probe_strategy: String,
5713    path_query_plan_checks: Vec<String>,
5714    full_projection_enabled: bool,
5715    full_projection_profile: String,
5716    normalization_row_unit: usize,
5717}
5718
5719#[derive(Clone)]
5720struct GraphDbBackendEvalSignature {
5721    operation: String,
5722    value: serde_json::Value,
5723}
5724
5725#[derive(Serialize)]
5726struct GraphDbBackendEvalOperation {
5727    name: String,
5728    supported: bool,
5729    status: String,
5730    duration_micros: u128,
5731    #[serde(skip_serializing_if = "Option::is_none")]
5732    rows: Option<usize>,
5733    #[serde(skip_serializing_if = "Option::is_none")]
5734    error: Option<String>,
5735}
5736
5737#[derive(Serialize)]
5738struct GraphDbBackendEvalParity {
5739    matches_sqlite: bool,
5740    diagnostics: Vec<String>,
5741}
5742
5743#[derive(Serialize)]
5744struct GraphDbBackendEvalBackendReport {
5745    backend: String,
5746    adapter: String,
5747    read_only: bool,
5748    projection_load: String,
5749    operations: Vec<GraphDbBackendEvalOperation>,
5750    total_micros: u128,
5751    parity: GraphDbBackendEvalParity,
5752    lock_behavior: String,
5753    install_portability: String,
5754}
5755
5756#[derive(Serialize)]
5757struct GraphDbBackendEvalDataset {
5758    name: String,
5759    target_count: usize,
5760    nodes: usize,
5761    edges: usize,
5762    backends: Vec<GraphDbBackendEvalBackendReport>,
5763}
5764
5765#[derive(Serialize)]
5766struct GraphDbBackendPromotionDecision {
5767    backend: String,
5768    decision: String,
5769    reasons: Vec<String>,
5770    gate: GraphDbBackendPromotionGate,
5771}
5772
5773#[derive(Serialize)]
5774struct GraphDbBackendEvalPerformanceGate {
5775    baseline_fixture: String,
5776    ci_profile: String,
5777    opt_in_real_profile: String,
5778    full_projection_cache_hit_gate: String,
5779    allowed_regression_percent: f64,
5780    minimum_sample_runs: usize,
5781    normalized_metric_unit: String,
5782    required_metrics: Vec<String>,
5783    digest_command: String,
5784    repeated_sample_command: String,
5785    hop_cap_promotion: GraphDbHopCapPromotionGate,
5786    backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
5787}
5788
5789#[derive(Serialize)]
5790struct GraphDbHopCapPromotionGate {
5791    status: String,
5792    current_default_hops: usize,
5793    candidate_hop_tiers: Vec<usize>,
5794    required_backend: String,
5795    required_workloads: Vec<String>,
5796    required_metrics: Vec<String>,
5797    allowed_regression_percent: f64,
5798    minimum_sample_runs: usize,
5799    decision_rule: String,
5800}
5801
5802#[derive(Serialize)]
5803struct GraphDbBackendAdapterSpikeGate {
5804    status: String,
5805    candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
5806    required_workloads: Vec<String>,
5807    required_checks: Vec<String>,
5808    decision_rule: String,
5809    evidence_plan: String,
5810}
5811
5812#[derive(Serialize)]
5813struct GraphDbBackendAdapterSpikeCandidate {
5814    backend: String,
5815    adapter_label: String,
5816    projection_load: String,
5817    lock_behavior: String,
5818    install_portability: String,
5819}
5820
5821#[derive(Serialize)]
5822pub(crate) struct GraphDbBackendEvalReport {
5823    root: String,
5824    #[serde(skip_serializing_if = "Option::is_none")]
5825    scope: Option<String>,
5826    label: String,
5827    baseline_backend: String,
5828    candidates: Vec<String>,
5829    targets: Vec<String>,
5830    config: GraphDbBackendEvalConfig,
5831    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5832    datasets: Vec<GraphDbBackendEvalDataset>,
5833    promotion: Vec<GraphDbBackendPromotionDecision>,
5834    performance_gate: GraphDbBackendEvalPerformanceGate,
5835    metrics: BTreeMap<String, f64>,
5836    metric_digest_command: String,
5837    warnings: Vec<String>,
5838}
5839
5840#[derive(Clone, Debug, Serialize)]
5841struct GraphDbDoctorCheck {
5842    name: String,
5843    status: String,
5844    fail_closed: bool,
5845    diagnostics: Vec<String>,
5846    repair_commands: Vec<String>,
5847}
5848
5849#[derive(Serialize)]
5850pub(crate) struct GraphDbDoctorReport {
5851    root: String,
5852    #[serde(skip_serializing_if = "Option::is_none")]
5853    scope: Option<String>,
5854    backend: String,
5855    graph_db: String,
5856    #[serde(skip_serializing_if = "Option::is_none")]
5857    convex_snapshot: Option<String>,
5858    status: String,
5859    fail_closed: bool,
5860    checks: Vec<GraphDbDoctorCheck>,
5861    repair_commands: Vec<String>,
5862    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5863    required_indexes: Vec<ConvexRequiredIndex>,
5864}
5865
5866#[derive(Serialize)]
5867struct GraphDbDriftSummary {
5868    node_upserts: usize,
5869    edge_upserts: usize,
5870    node_tombstones: usize,
5871    edge_tombstones: usize,
5872    stale_nodes: usize,
5873    stale_edges: usize,
5874    stale_projection_metadata: usize,
5875    duplicate_failures: usize,
5876    orphan_failures: usize,
5877    missing_required_indexes: usize,
5878}
5879
5880#[derive(Serialize)]
5881struct GraphDbDriftReport {
5882    root: String,
5883    #[serde(skip_serializing_if = "Option::is_none")]
5884    scope: Option<String>,
5885    graph_db: String,
5886    convex_snapshot: String,
5887    status: String,
5888    graph_reads_allowed: bool,
5889    projection_version: String,
5890    local_hash: Option<String>,
5891    snapshot_hash: Option<String>,
5892    summary: GraphDbDriftSummary,
5893    node_upserts: Vec<String>,
5894    edge_upserts: Vec<String>,
5895    node_tombstones: Vec<String>,
5896    edge_tombstones: Vec<String>,
5897    stale_nodes: Vec<String>,
5898    stale_edges: Vec<String>,
5899    diagnostics: Vec<String>,
5900    next_commands: Vec<String>,
5901    required_indexes: Vec<ConvexRequiredIndex>,
5902    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5903    warnings: Vec<String>,
5904}
5905
5906#[derive(Clone, Serialize)]
5907struct GraphDbTombstoneCounts {
5908    nodes: usize,
5909    edges: usize,
5910    total: usize,
5911}
5912
5913#[derive(Clone, Serialize)]
5914struct GraphDbOperatorCounts {
5915    nodes: usize,
5916    edges: usize,
5917    tombstones: GraphDbTombstoneCounts,
5918    #[serde(skip_serializing_if = "Option::is_none")]
5919    file_size_bytes: Option<u64>,
5920    #[serde(skip_serializing_if = "Option::is_none")]
5921    freelist_bytes: Option<u64>,
5922}
5923
5924#[derive(Clone, Serialize)]
5925struct GraphDbCompactionPolicy {
5926    status: String,
5927    tombstone_scan_rows: usize,
5928    live_rows: usize,
5929    file_size_bytes: Option<u64>,
5930    freelist_bytes: Option<u64>,
5931    safe_to_prune_tombstones: bool,
5932    requires_convex_reconciliation: bool,
5933    recommendations: Vec<String>,
5934    proof: Vec<String>,
5935}
5936
5937#[derive(Serialize)]
5938pub(crate) struct GraphDbRefreshSummary {
5939    scope: String,
5940    projection_version: String,
5941    mode: String,
5942    #[serde(skip_serializing_if = "Option::is_none")]
5943    source_watermark: Option<String>,
5944    tombstoned_nodes: usize,
5945    tombstoned_edges: usize,
5946    upserted_nodes: usize,
5947    upserted_edges: usize,
5948    unchanged_nodes: usize,
5949    unchanged_edges: usize,
5950    upserted_properties: usize,
5951    unchanged_properties: usize,
5952    deleted_properties: usize,
5953    deleted_nodes: usize,
5954    deleted_edges: usize,
5955    pruned_tombstones: usize,
5956    #[serde(skip_serializing_if = "Option::is_none")]
5957    file_size_bytes_before: Option<u64>,
5958    #[serde(skip_serializing_if = "Option::is_none")]
5959    file_size_bytes_after: Option<u64>,
5960    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5961    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5962}
5963
5964#[derive(Serialize)]
5965struct GraphDbOperatorReport {
5966    root: String,
5967    #[serde(skip_serializing_if = "Option::is_none")]
5968    scope: Option<String>,
5969    graph_db: String,
5970    operation: String,
5971    status: String,
5972    materialized: bool,
5973    freshness: GraphDbFreshnessReport,
5974    readiness: GraphEffectivenessReadiness,
5975    counts: GraphDbOperatorCounts,
5976    #[serde(skip_serializing_if = "Option::is_none")]
5977    refresh: Option<GraphDbRefreshSummary>,
5978    compaction: GraphDbCompactionPolicy,
5979    #[serde(skip_serializing_if = "Option::is_none")]
5980    recovery: Option<index::ReadOnlyRecovery>,
5981    next_commands: Vec<String>,
5982    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5983    warnings: Vec<String>,
5984}
5985
5986#[derive(Serialize)]
5987pub(crate) struct GraphDbCompactionReport {
5988    root: String,
5989    #[serde(skip_serializing_if = "Option::is_none")]
5990    scope: Option<String>,
5991    graph_db: String,
5992    applied: bool,
5993    pruned_tombstones: usize,
5994    counts_before: GraphDbOperatorCounts,
5995    counts_after: GraphDbOperatorCounts,
5996    compaction_before: GraphDbCompactionPolicy,
5997    compaction_after: GraphDbCompactionPolicy,
5998    reclaimed_bytes: i64,
5999    next_commands: Vec<String>,
6000    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6001    warnings: Vec<String>,
6002}
6003
6004#[derive(Clone, Serialize, Deserialize)]
6005struct GraphDbEvidencePath {
6006    to: String,
6007    kind: String,
6008    label: String,
6009    #[serde(skip_serializing_if = "Option::is_none")]
6010    path: Option<substrate::GraphPath>,
6011    #[serde(skip_serializing_if = "Option::is_none")]
6012    expand: Option<String>,
6013}
6014
6015#[derive(Clone, Serialize, Deserialize)]
6016struct GraphDbFixtureCoverage {
6017    test: String,
6018    fixture: String,
6019    assertions: Vec<String>,
6020}
6021
6022#[derive(Clone, Serialize, Deserialize)]
6023struct GraphDbEvidenceReport {
6024    root: String,
6025    #[serde(skip_serializing_if = "Option::is_none")]
6026    scope: Option<String>,
6027    backend: String,
6028    contract_version: String,
6029    target: String,
6030    packet_id: String,
6031    #[serde(skip_serializing_if = "Option::is_none")]
6032    projection_hash: Option<String>,
6033    freshness: GraphDbFreshnessReport,
6034    target_node: SubstrateTerseGraphNode,
6035    worker_context: Vec<SubstrateTerseGraphNode>,
6036    source_handles: Vec<SubstrateTerseGraphNode>,
6037    worker_results: Vec<SubstrateTerseGraphNode>,
6038    semantic_related: Vec<SubstrateTerseGraphNode>,
6039    shortest_paths: Vec<GraphDbEvidencePath>,
6040    #[serde(skip_serializing_if = "Option::is_none")]
6041    output_budget: Option<GraphDbOutputBudgetReport>,
6042    #[serde(default)]
6043    truncated: bool,
6044    #[serde(skip_serializing_if = "Option::is_none")]
6045    next_cursor: Option<String>,
6046    next_commands: Vec<String>,
6047    replay_commands: Vec<String>,
6048    repair_commands: Vec<String>,
6049    fixture_coverage: GraphDbFixtureCoverage,
6050    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6051    warnings: Vec<String>,
6052}
6053
6054pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6055    root: &'a Path,
6056    scope: Option<&'a str>,
6057    backend: &'a str,
6058    target: &'a str,
6059    preferred_path: Option<&'a str>,
6060    depth: usize,
6061    limit: usize,
6062    cursor: Option<&'a str>,
6063    store: &'a S,
6064    freshness: GraphDbFreshnessReport,
6065    warnings: Vec<String>,
6066}
6067
6068impl GraphDbDoctorReport {
6069    fn new(
6070        root: &Path,
6071        scope: Option<&str>,
6072        backend: &str,
6073        graph_db: &Path,
6074        convex_snapshot: Option<&Path>,
6075    ) -> Self {
6076        Self {
6077            root: root.to_string_lossy().to_string(),
6078            scope: scope.map(str::to_string),
6079            backend: backend.to_string(),
6080            graph_db: graph_db.to_string_lossy().to_string(),
6081            convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6082            status: "ok".to_string(),
6083            fail_closed: false,
6084            checks: Vec::new(),
6085            repair_commands: Vec::new(),
6086            required_indexes: Vec::new(),
6087        }
6088    }
6089
6090    fn push_check(&mut self, check: GraphDbDoctorCheck) {
6091        self.checks.push(check);
6092    }
6093
6094    fn finalize(&mut self) {
6095        self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6096        self.status = if self.fail_closed {
6097            "fail_closed"
6098        } else {
6099            "ok"
6100        }
6101        .to_string();
6102        let mut commands = BTreeSet::new();
6103        for check in &self.checks {
6104            commands.extend(check.repair_commands.iter().cloned());
6105        }
6106        self.repair_commands = commands.into_iter().collect();
6107    }
6108
6109    fn summary(&self) -> String {
6110        self.checks
6111            .iter()
6112            .filter(|check| check.fail_closed)
6113            .flat_map(|check| check.diagnostics.iter())
6114            .take(3)
6115            .cloned()
6116            .collect::<Vec<_>>()
6117            .join("; ")
6118    }
6119}
6120
6121fn graph_db_doctor_check(
6122    name: impl Into<String>,
6123    diagnostics: Vec<String>,
6124    repair_commands: Vec<String>,
6125) -> GraphDbDoctorCheck {
6126    let fail_closed = !diagnostics.is_empty();
6127    GraphDbDoctorCheck {
6128        name: name.into(),
6129        status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6130        fail_closed,
6131        diagnostics,
6132        repair_commands: if fail_closed {
6133            repair_commands
6134        } else {
6135            Vec::new()
6136        },
6137    }
6138}
6139
6140pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6141    scope
6142        .map(|scope| format!(" --scope {}", shell_quote(scope)))
6143        .unwrap_or_default()
6144}
6145
6146fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6147    format!(
6148        "tsift graph-db --path {}{} refresh --json",
6149        shell_quote(root.to_string_lossy().as_ref()),
6150        graph_db_scope_arg(scope)
6151    )
6152}
6153
6154fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6155    graph_db_refresh_command(root, scope)
6156}
6157
6158fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6159    let backup = format!("{}.bak", graph_db.to_string_lossy());
6160    format!(
6161        "mv {} {} && {}",
6162        shell_quote(graph_db.to_string_lossy().as_ref()),
6163        shell_quote(&backup),
6164        graph_db_rebuild_command(root, scope)
6165    )
6166}
6167
6168fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6169    format!(
6170        "tsift convex-sync {}{} --remote-snapshot --apply --json",
6171        shell_quote(root.to_string_lossy().as_ref()),
6172        graph_db_scope_arg(scope)
6173    )
6174}
6175
6176fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6177    substrate::open_graph_read_only_connection_resilient(graph_db)
6178}
6179
6180fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6181    conn.query_row(
6182        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6183        [table],
6184        |row| row.get::<_, bool>(0),
6185    )
6186    .map_err(Into::into)
6187}
6188
6189fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6190    let sql = match table {
6191        "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6192        "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6193        "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6194        other => bail!("unsupported graph count table {other}"),
6195    };
6196    conn.query_row(sql, [], |row| row.get::<_, usize>(0))
6197        .map_err(Into::into)
6198}
6199
6200fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6201    if !sqlite_table_exists(conn, "graph_tombstones")? {
6202        return Ok(GraphDbTombstoneCounts {
6203            nodes: 0,
6204            edges: 0,
6205            total: 0,
6206        });
6207    }
6208    let mut stmt =
6209        conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6210    let mut rows = stmt.query([])?;
6211    let mut nodes = 0usize;
6212    let mut edges = 0usize;
6213    while let Some(row) = rows.next()? {
6214        let row_kind: String = row.get(0)?;
6215        let count: usize = row.get(1)?;
6216        match row_kind.as_str() {
6217            "node" => nodes = count,
6218            "edge" => edges = count,
6219            _ => {}
6220        }
6221    }
6222    Ok(GraphDbTombstoneCounts {
6223        nodes,
6224        edges,
6225        total: nodes + edges,
6226    })
6227}
6228
6229fn sqlite_graph_counts_from_cache(
6230    conn: &Connection,
6231    scope: &str,
6232) -> Result<Option<GraphDbOperatorCounts>> {
6233    if !sqlite_table_exists(conn, "graph_operator_stats")? {
6234        return Ok(None);
6235    }
6236    let row = conn
6237        .query_row(
6238            r#"
6239        SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6240        FROM graph_operator_stats
6241        WHERE scope = ?1
6242        "#,
6243            [scope],
6244            |row| {
6245                Ok((
6246                    row.get::<_, usize>(0)?,
6247                    row.get::<_, usize>(1)?,
6248                    row.get::<_, usize>(2)?,
6249                    row.get::<_, usize>(3)?,
6250                    row.get::<_, Option<i64>>(4)?,
6251                    row.get::<_, Option<i64>>(5)?,
6252                ))
6253            },
6254        )
6255        .optional()?;
6256    Ok(row.map(
6257        |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6258            GraphDbOperatorCounts {
6259                nodes,
6260                edges,
6261                tombstones: GraphDbTombstoneCounts {
6262                    nodes: tombstone_nodes,
6263                    edges: tombstone_edges,
6264                    total: tombstone_nodes + tombstone_edges,
6265                },
6266                file_size_bytes: file_size_bytes
6267                    .and_then(|value| u64::try_from(value).ok())
6268                    .or_else(|| sqlite_database_size_bytes(conn).ok()),
6269                freelist_bytes: freelist_bytes
6270                    .and_then(|value| u64::try_from(value).ok())
6271                    .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6272            }
6273        },
6274    ))
6275}
6276
6277fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6278    if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6279        return Ok(counts);
6280    }
6281    let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6282        sqlite_known_table_count(conn, "graph_nodes")?
6283    } else {
6284        0
6285    };
6286    let edges = if sqlite_table_exists(conn, "graph_edges")? {
6287        sqlite_known_table_count(conn, "graph_edges")?
6288    } else {
6289        0
6290    };
6291    Ok(GraphDbOperatorCounts {
6292        nodes,
6293        edges,
6294        tombstones: sqlite_tombstone_counts(conn)?,
6295        file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6296        freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6297    })
6298}
6299
6300fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6301    if !sqlite_table_exists(conn, "graph_nodes")? {
6302        return Ok(0);
6303    }
6304    let count: i64 = conn.query_row(
6305        "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6306        [],
6307        |row| row.get(0),
6308    )?;
6309    Ok(count as usize)
6310}
6311
6312pub(crate) fn graph_db_compaction_policy(
6313    root: &Path,
6314    scope: Option<&str>,
6315    counts: &GraphDbOperatorCounts,
6316    prune_confirmed: bool,
6317) -> GraphDbCompactionPolicy {
6318    let live_rows = counts.nodes + counts.edges;
6319    let tombstone_scan_rows = counts.tombstones.total;
6320    let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6321    let freelist_heavy = counts
6322        .file_size_bytes
6323        .zip(counts.freelist_bytes)
6324        .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6325    let status = if tombstone_heavy || freelist_heavy {
6326        "recommended"
6327    } else {
6328        "not_needed"
6329    }
6330    .to_string();
6331    let mut recommendations = vec![
6332        convex_refresh_command(root, scope),
6333        graph_db_refresh_command(root, scope),
6334        format!(
6335            "tsift graph-db --path {}{} compact --apply --json",
6336            shell_quote(root.to_string_lossy().as_ref()),
6337            graph_db_scope_arg(scope)
6338        ),
6339    ];
6340    if prune_confirmed {
6341        recommendations.push(format!(
6342            "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6343            shell_quote(root.to_string_lossy().as_ref()),
6344            graph_db_scope_arg(scope)
6345        ));
6346    }
6347    let proof = vec![
6348        format!("{live_rows} live graph row(s)"),
6349        format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
6350        format!(
6351            "graph.db file_size={} byte(s), freelist={} byte(s)",
6352            counts.file_size_bytes.unwrap_or(0),
6353            counts.freelist_bytes.unwrap_or(0)
6354        ),
6355    ];
6356    GraphDbCompactionPolicy {
6357        status,
6358        tombstone_scan_rows,
6359        live_rows,
6360        file_size_bytes: counts.file_size_bytes,
6361        freelist_bytes: counts.freelist_bytes,
6362        safe_to_prune_tombstones: prune_confirmed,
6363        requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
6364        recommendations,
6365        proof,
6366    }
6367}
6368
6369fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
6370    let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
6371    let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6372    Ok(page_count.saturating_mul(page_size))
6373}
6374
6375fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
6376    let freelist_count: u64 = conn.query_row("PRAGMA freelist_count", [], |row| row.get(0))?;
6377    let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6378    Ok(freelist_count.saturating_mul(page_size))
6379}
6380
6381fn sqlite_graph_tombstone_retention_diagnostics(
6382    conn: &Connection,
6383    scope: &str,
6384) -> Result<Vec<String>> {
6385    if !sqlite_table_exists(conn, "graph_tombstones")? {
6386        return Ok(Vec::new());
6387    }
6388    let cached = sqlite_graph_counts_from_cache(conn, scope)?;
6389    let counts = match cached.clone() {
6390        Some(counts) => counts,
6391        None => sqlite_graph_counts(conn, scope)?,
6392    };
6393    let live_rows = counts.nodes + counts.edges;
6394    let file_size = counts.file_size_bytes.unwrap_or(0);
6395    let freelist = counts.freelist_bytes.unwrap_or(0);
6396    let stale_live_tombstones = if cached.is_some() {
6397        0
6398    } else {
6399        let mut live_keys = BTreeSet::new();
6400        if sqlite_table_exists(conn, "graph_nodes")? {
6401            let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
6402            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6403                live_keys.insert(format!("node:{}", row?));
6404            }
6405        }
6406        if sqlite_table_exists(conn, "graph_edges")? {
6407            let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
6408            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6409                live_keys.insert(format!("edge:{}", row?));
6410            }
6411        }
6412        let mut stale_live_tombstones = 0usize;
6413        let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
6414        for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6415            if live_keys.contains(&row?) {
6416                stale_live_tombstones += 1;
6417            }
6418        }
6419        stale_live_tombstones
6420    };
6421
6422    let mut diagnostics = Vec::new();
6423    if stale_live_tombstones > 0 {
6424        diagnostics.push(format!(
6425            "{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"
6426        ));
6427    }
6428    if counts.tombstones.total > live_rows.max(1) {
6429        let source = if cached.is_some() {
6430            "cached refresh stats"
6431        } else {
6432            "live row scan"
6433        };
6434        diagnostics.push(format!(
6435            "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.",
6436            counts.tombstones.total,
6437            live_rows,
6438            source,
6439            file_size,
6440            freelist,
6441            counts.tombstones.total
6442        ));
6443    }
6444    Ok(diagnostics)
6445}
6446
6447fn sqlite_graph_freshness_from_conn(
6448    conn: &Connection,
6449    scope: &str,
6450) -> Result<GraphDbFreshnessReport> {
6451    if !sqlite_table_exists(conn, "graph_projection_versions")? {
6452        return Ok(GraphDbFreshnessReport {
6453            status: "missing".to_string(),
6454            fail_closed: true,
6455            projection_version: None,
6456            content_hash: None,
6457            source_watermark: None,
6458            diagnostics: vec![
6459                "graph projection metadata table is missing; refresh graph.db before trusting reads"
6460                    .to_string(),
6461            ],
6462        });
6463    }
6464    let version = conn
6465        .query_row(
6466            r#"
6467            SELECT projection_version, content_hash, source_watermark
6468            FROM graph_projection_versions
6469            WHERE scope = ?1
6470            "#,
6471            [scope],
6472            |row| {
6473                Ok((
6474                    row.get::<_, String>(0)?,
6475                    row.get::<_, Option<String>>(1)?,
6476                    row.get::<_, Option<String>>(2)?,
6477                ))
6478            },
6479        )
6480        .optional()?;
6481    let Some((projection_version, content_hash, source_watermark)) = version else {
6482        return Ok(GraphDbFreshnessReport {
6483            status: "missing".to_string(),
6484            fail_closed: true,
6485            projection_version: None,
6486            content_hash: None,
6487            source_watermark: None,
6488            diagnostics: vec![
6489                "graph projection metadata is missing; refresh graph.db before trusting reads"
6490                    .to_string(),
6491            ],
6492        });
6493    };
6494
6495    let mut diagnostics = Vec::new();
6496    if projection_version != GRAPH_PROJECTION_VERSION {
6497        diagnostics.push(format!(
6498            "projection version mismatch: expected {} got {}",
6499            GRAPH_PROJECTION_VERSION, projection_version
6500        ));
6501    }
6502    if content_hash.is_none() {
6503        diagnostics.push("projection content hash is missing".to_string());
6504    }
6505    let fail_closed = !diagnostics.is_empty();
6506    Ok(GraphDbFreshnessReport {
6507        status: if fail_closed { "stale" } else { "current" }.to_string(),
6508        fail_closed,
6509        projection_version: Some(projection_version),
6510        content_hash,
6511        source_watermark,
6512        diagnostics,
6513    })
6514}
6515
6516fn graph_db_operator_next_commands(
6517    root: &Path,
6518    scope: Option<&str>,
6519    include_refresh: bool,
6520) -> Vec<String> {
6521    let mut commands = Vec::new();
6522    if include_refresh {
6523        commands.push(graph_db_refresh_command(root, scope));
6524    }
6525    commands.push(format!(
6526        "tsift graph-db --path {}{} doctor --json",
6527        shell_quote(root.to_string_lossy().as_ref()),
6528        graph_db_scope_arg(scope)
6529    ));
6530    commands.push(format!(
6531        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
6532        shell_quote(root.to_string_lossy().as_ref()),
6533        graph_db_scope_arg(scope)
6534    ));
6535    commands.push(format!(
6536        "tsift convex-sync {}{} --remote-snapshot --apply --json",
6537        shell_quote(root.to_string_lossy().as_ref()),
6538        graph_db_scope_arg(scope)
6539    ));
6540    commands
6541}
6542
6543pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
6544    match recovery {
6545        index::ReadOnlyRecovery::SnapshotFallback => {
6546            "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
6547        }
6548        index::ReadOnlyRecovery::SnapshotFallbackWal => {
6549            "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
6550        }
6551    }
6552}
6553
6554fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
6555    let mut stmt = conn.prepare(sql)?;
6556    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6557    let mut values = BTreeSet::new();
6558    for row in rows {
6559        values.insert(row?);
6560    }
6561    Ok(values)
6562}
6563
6564fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
6565    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
6566    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
6567    let mut columns = BTreeSet::new();
6568    for row in rows {
6569        columns.insert(row?);
6570    }
6571    Ok(columns)
6572}
6573
6574fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6575    let mut diagnostics = Vec::new();
6576    let user_version: i64 =
6577        conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
6578    if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
6579        diagnostics.push(format!(
6580            "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6581        ));
6582    } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
6583        diagnostics.push(format!(
6584            "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6585        ));
6586    }
6587
6588    let tables = sqlite_string_set(
6589        conn,
6590        "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
6591    )?;
6592    let required_tables = [
6593        (
6594            "graph_nodes",
6595            vec![
6596                "id",
6597                "kind",
6598                "label",
6599                "properties_json",
6600                "provenance_json",
6601                "freshness_json",
6602                "row_hash",
6603                "source_watermark",
6604            ],
6605        ),
6606        (
6607            "graph_edges",
6608            vec![
6609                "edge_key",
6610                "from_id",
6611                "to_id",
6612                "kind",
6613                "properties_json",
6614                "provenance_json",
6615                "freshness_json",
6616                "row_hash",
6617                "source_watermark",
6618            ],
6619        ),
6620        (
6621            "graph_projection_versions",
6622            vec![
6623                "scope",
6624                "projection_version",
6625                "content_hash",
6626                "source_watermark",
6627                "observed_at_unix",
6628            ],
6629        ),
6630        (
6631            "graph_tombstones",
6632            vec!["row_key", "row_kind", "deleted_at_unix"],
6633        ),
6634        ("graph_node_properties", vec!["node_id", "key", "value"]),
6635        ("graph_edge_properties", vec!["edge_key", "key", "value"]),
6636    ];
6637    for (table, required_columns) in required_tables {
6638        if !tables.contains(table) {
6639            diagnostics.push(format!("graph.db schema drift: missing table {table}"));
6640            continue;
6641        }
6642        let columns = sqlite_column_names(conn, table)?;
6643        for column in required_columns {
6644            if !columns.contains(column) {
6645                diagnostics.push(format!(
6646                    "graph.db schema drift: missing column {table}.{column}"
6647                ));
6648            }
6649        }
6650    }
6651
6652    let indexes = sqlite_string_set(
6653        conn,
6654        "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
6655    )?;
6656    for index in [
6657        "idx_graph_nodes_kind",
6658        "idx_graph_edges_from_kind",
6659        "idx_graph_edges_to_kind",
6660        "idx_graph_edges_edge_key",
6661        "idx_graph_node_properties_key_value_node",
6662        "idx_graph_edge_properties_key_value_edge",
6663    ] {
6664        if !indexes.contains(index) {
6665            diagnostics.push(format!("graph.db schema drift: missing index {index}"));
6666        }
6667    }
6668
6669    if tables.contains("graph_edges") {
6670        let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
6671        let rows = stmt.query_map([], |row| {
6672            Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
6673        })?;
6674        let mut fks = BTreeSet::new();
6675        for row in rows {
6676            fks.insert(row?);
6677        }
6678        for expected in [
6679            ("from_id".to_string(), "id".to_string()),
6680            ("to_id".to_string(), "id".to_string()),
6681        ] {
6682            if !fks.contains(&expected) {
6683                diagnostics.push(format!(
6684                    "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
6685                    expected.0, expected.1
6686                ));
6687            }
6688        }
6689    }
6690
6691    Ok(diagnostics)
6692}
6693
6694fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
6695    let mut stmt = conn.prepare(sql)?;
6696    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6697    let mut diagnostics = Vec::new();
6698    for row in rows {
6699        diagnostics.push(row?);
6700    }
6701    Ok(diagnostics)
6702}
6703
6704fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6705    let mut diagnostics = sqlite_query_diagnostics(
6706        conn,
6707        r#"
6708        SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
6709        FROM graph_nodes
6710        GROUP BY id
6711        HAVING COUNT(*) > 1
6712        ORDER BY id
6713        "#,
6714    )?;
6715    diagnostics.extend(sqlite_query_diagnostics(
6716        conn,
6717        r#"
6718        SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
6719        FROM graph_edges
6720        GROUP BY from_id, to_id, kind
6721        HAVING COUNT(*) > 1
6722        ORDER BY from_id, kind, to_id
6723        "#,
6724    )?);
6725    diagnostics.extend(sqlite_query_diagnostics(
6726        conn,
6727        r#"
6728        SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
6729        FROM graph_edges
6730        GROUP BY edge_key
6731        HAVING COUNT(*) > 1
6732        ORDER BY edge_key
6733        "#,
6734    )?);
6735    Ok(diagnostics)
6736}
6737
6738fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6739    sqlite_query_diagnostics(
6740        conn,
6741        r#"
6742        SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6743        FROM graph_edges e
6744        LEFT JOIN graph_nodes n ON n.id = e.from_id
6745        WHERE n.id IS NULL
6746        UNION ALL
6747        SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6748        FROM graph_edges e
6749        LEFT JOIN graph_nodes n ON n.id = e.to_id
6750        WHERE n.id IS NULL
6751        ORDER BY 1
6752        "#,
6753    )
6754}
6755
6756fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6757    let mut diagnostics = Vec::new();
6758    let mut node_stmt = conn.prepare(
6759        "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6760    )?;
6761    let node_rows = node_stmt.query_map([], |row| {
6762        Ok((
6763            row.get::<_, String>(0)?,
6764            row.get::<_, String>(1)?,
6765            row.get::<_, String>(2)?,
6766            row.get::<_, Option<String>>(3)?,
6767        ))
6768    })?;
6769    for row in node_rows {
6770        let (id, properties_json, provenance_json, freshness_json) = row?;
6771        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6772            diagnostics.push(format!(
6773                "graph_nodes {id} properties_json is invalid: {err}"
6774            ));
6775        }
6776        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6777            diagnostics.push(format!(
6778                "graph_nodes {id} provenance_json is invalid: {err}"
6779            ));
6780        }
6781        if let Some(freshness_json) = freshness_json
6782            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6783        {
6784            diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
6785        }
6786    }
6787
6788    let mut edge_stmt = conn.prepare(
6789        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6790    )?;
6791    let edge_rows = edge_stmt.query_map([], |row| {
6792        Ok((
6793            row.get::<_, String>(0)?,
6794            row.get::<_, String>(1)?,
6795            row.get::<_, String>(2)?,
6796            row.get::<_, String>(3)?,
6797            row.get::<_, String>(4)?,
6798            row.get::<_, String>(5)?,
6799            row.get::<_, Option<String>>(6)?,
6800        ))
6801    })?;
6802    for row in edge_rows {
6803        let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
6804            row?;
6805        let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
6806        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6807            diagnostics.push(format!(
6808                "graph_edges {edge} properties_json is invalid: {err}"
6809            ));
6810        }
6811        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6812            diagnostics.push(format!(
6813                "graph_edges {edge} provenance_json is invalid: {err}"
6814            ));
6815        }
6816        if let Some(freshness_json) = freshness_json
6817            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6818        {
6819            diagnostics.push(format!(
6820                "graph_edges {edge} freshness_json is invalid: {err}"
6821            ));
6822        }
6823    }
6824    Ok(diagnostics)
6825}
6826
6827fn sqlite_graph_projection_metadata_diagnostics(
6828    conn: &Connection,
6829    scope: Option<&str>,
6830) -> Result<Vec<String>> {
6831    let mut diagnostics = Vec::new();
6832    let scope_key = scope.unwrap_or("root");
6833    let version = conn
6834        .query_row(
6835            r#"
6836            SELECT projection_version, content_hash, source_watermark
6837            FROM graph_projection_versions
6838            WHERE scope = ?1
6839            "#,
6840            [scope_key],
6841            |row| {
6842                Ok((
6843                    row.get::<_, String>(0)?,
6844                    row.get::<_, Option<String>>(1)?,
6845                    row.get::<_, Option<String>>(2)?,
6846                ))
6847            },
6848        )
6849        .optional()?;
6850    let Some((projection_version, content_hash, _source_watermark)) = version else {
6851        diagnostics.push(format!(
6852            "graph projection metadata is missing for scope {scope_key}"
6853        ));
6854        return Ok(diagnostics);
6855    };
6856    if projection_version != GRAPH_PROJECTION_VERSION {
6857        diagnostics.push(format!(
6858            "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
6859        ));
6860    }
6861    if content_hash.is_none() {
6862        diagnostics.push("projection content hash is missing".to_string());
6863    }
6864
6865    let meta_id = graph_projection_meta_id(scope);
6866    let meta_properties = conn
6867        .query_row(
6868            "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
6869            (&meta_id, GRAPH_PROJECTION_META_KIND),
6870            |row| row.get::<_, String>(0),
6871        )
6872        .optional()?;
6873    let Some(meta_properties) = meta_properties else {
6874        diagnostics.push(format!("projection_meta node {meta_id} is missing"));
6875        return Ok(diagnostics);
6876    };
6877    let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
6878        .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
6879    if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
6880        diagnostics.push(format!(
6881            "projection_meta node {meta_id} has stale projection_version"
6882        ));
6883    }
6884    if properties.get("content_hash") != content_hash.as_ref() {
6885        diagnostics.push(format!(
6886            "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
6887        ));
6888    }
6889    Ok(diagnostics)
6890}
6891
6892pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
6893    let mut node_stmt = conn.prepare(
6894        "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6895    )?;
6896    let node_rows = node_stmt.query_map([], |row| {
6897        let properties_json: String = row.get(3)?;
6898        let provenance_json: String = row.get(4)?;
6899        let freshness_json: Option<String> = row.get(5)?;
6900        Ok((
6901            row.get::<_, String>(0)?,
6902            row.get::<_, String>(1)?,
6903            row.get::<_, String>(2)?,
6904            properties_json,
6905            provenance_json,
6906            freshness_json,
6907        ))
6908    })?;
6909    let mut nodes = Vec::new();
6910    for row in node_rows {
6911        let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
6912        nodes.push(ConvexNodeRow {
6913            external_id,
6914            kind,
6915            label,
6916            properties: serde_json::from_str(&properties_json)?,
6917            provenance: serde_json::from_str(&provenance_json)?,
6918            freshness: freshness_json
6919                .map(|value| serde_json::from_str(&value))
6920                .transpose()?,
6921        });
6922    }
6923
6924    let mut edge_stmt = conn.prepare(
6925        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6926    )?;
6927    let edge_rows = edge_stmt.query_map([], |row| {
6928        let properties_json: String = row.get(4)?;
6929        let provenance_json: String = row.get(5)?;
6930        let freshness_json: Option<String> = row.get(6)?;
6931        Ok((
6932            row.get::<_, String>(0)?,
6933            row.get::<_, String>(1)?,
6934            row.get::<_, String>(2)?,
6935            row.get::<_, String>(3)?,
6936            properties_json,
6937            provenance_json,
6938            freshness_json,
6939        ))
6940    })?;
6941    let mut edges = Vec::new();
6942    for row in edge_rows {
6943        let (
6944            edge_key,
6945            from_external_id,
6946            to_external_id,
6947            kind,
6948            properties_json,
6949            provenance_json,
6950            freshness_json,
6951        ) = row?;
6952        edges.push(ConvexEdgeRow {
6953            edge_key,
6954            from_external_id,
6955            to_external_id,
6956            kind,
6957            properties: serde_json::from_str(&properties_json)?,
6958            provenance: serde_json::from_str(&provenance_json)?,
6959            freshness: freshness_json
6960                .map(|value| serde_json::from_str(&value))
6961                .transpose()?,
6962        });
6963    }
6964    Ok(ConvexProjectionRows { nodes, edges })
6965}
6966
6967fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
6968    format!("{}.{}({})", index.table, index.name, index.fields.join(","))
6969}
6970
6971fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
6972    value
6973        .get("indexes")
6974        .or_else(|| value.get("requiredIndexes"))
6975        .or_else(|| {
6976            value
6977                .get("metadata")
6978                .and_then(|metadata| metadata.get("indexes"))
6979        })
6980}
6981
6982fn convex_snapshot_declared_indexes(
6983    value: &serde_json::Value,
6984) -> Result<Option<Vec<ConvexRequiredIndex>>> {
6985    convex_snapshot_index_value(value)
6986        .map(|indexes| {
6987            serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
6988                .context("parsing Convex snapshot index metadata")
6989        })
6990        .transpose()
6991}
6992
6993fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
6994    let required = convex_required_indexes();
6995    let Some(declared) = convex_snapshot_declared_indexes(value)? else {
6996        return Ok(vec![format!(
6997            "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
6998            required
6999                .iter()
7000                .map(convex_required_index_label)
7001                .collect::<Vec<_>>()
7002                .join(", ")
7003        )]);
7004    };
7005    let declared = declared.into_iter().collect::<BTreeSet<_>>();
7006    let missing = required
7007        .iter()
7008        .filter(|index| !declared.contains(*index))
7009        .map(convex_required_index_label)
7010        .collect::<Vec<_>>();
7011    if missing.is_empty() {
7012        Ok(Vec::new())
7013    } else {
7014        Ok(vec![format!(
7015            "Convex snapshot is missing required index metadata: {}",
7016            missing.join(", ")
7017        )])
7018    }
7019}
7020
7021pub(crate) fn load_convex_projection_snapshot_value(
7022    snapshot_path: &Path,
7023) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7024    let content = fs::read_to_string(snapshot_path).with_context(|| {
7025        format!(
7026            "reading Convex projection snapshot {}",
7027            snapshot_path.display()
7028        )
7029    })?;
7030    let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7031        format!(
7032            "parsing Convex projection snapshot {}",
7033            snapshot_path.display()
7034        )
7035    })?;
7036    let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7037        .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7038    Ok((rows, value))
7039}
7040
7041pub(crate) fn append_sqlite_graph_doctor_checks(
7042    report: &mut GraphDbDoctorReport,
7043    root: &Path,
7044    scope: Option<&str>,
7045    graph_db: &Path,
7046) -> Option<substrate::SqliteReadOnlyConnection> {
7047    let rebuild = graph_db_rebuild_command(root, scope);
7048    let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7049    if !graph_db.exists() {
7050        report.push_check(graph_db_doctor_check(
7051            "sqlite_graph_db_exists",
7052            vec![format!("graph.db is missing at {}", graph_db.display())],
7053            vec![rebuild],
7054        ));
7055        return None;
7056    }
7057    report.push_check(graph_db_doctor_check(
7058        "sqlite_graph_db_exists",
7059        Vec::new(),
7060        vec![rebuild.clone()],
7061    ));
7062
7063    let conn = match open_sqlite_graph_db_readonly(graph_db) {
7064        Ok(conn) => conn,
7065        Err(err) => {
7066            report.push_check(graph_db_doctor_check(
7067                "sqlite_graph_db_open",
7068                vec![err.to_string()],
7069                vec![backup_rebuild],
7070            ));
7071            return None;
7072        }
7073    };
7074    report.push_check(graph_db_doctor_check(
7075        "sqlite_graph_db_open",
7076        Vec::new(),
7077        vec![rebuild.clone()],
7078    ));
7079    if let Some(recovery) = conn.recovery() {
7080        report.push_check(GraphDbDoctorCheck {
7081            name: "sqlite_graph_db_read_recovery".to_string(),
7082            status: "recovered".to_string(),
7083            fail_closed: false,
7084            diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7085            repair_commands: Vec::new(),
7086        });
7087    }
7088
7089    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7090        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7091    report.push_check(graph_db_doctor_check(
7092        "sqlite_schema",
7093        schema_diagnostics,
7094        vec![backup_rebuild.clone()],
7095    ));
7096
7097    let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7098        .unwrap_or_else(|err| {
7099            vec![format!(
7100                "graph projection metadata inspection failed: {err}"
7101            )]
7102        });
7103    report.push_check(graph_db_doctor_check(
7104        "sqlite_projection_metadata",
7105        metadata_diagnostics,
7106        vec![rebuild.clone()],
7107    ));
7108
7109    let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7110        .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7111    report.push_check(graph_db_doctor_check(
7112        "sqlite_duplicate_ids",
7113        duplicate_diagnostics,
7114        vec![backup_rebuild.clone()],
7115    ));
7116
7117    let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7118        .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7119    report.push_check(graph_db_doctor_check(
7120        "sqlite_orphan_edges",
7121        orphan_diagnostics,
7122        vec![rebuild.clone()],
7123    ));
7124
7125    let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7126        .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7127    report.push_check(graph_db_doctor_check(
7128        "sqlite_row_json",
7129        json_diagnostics,
7130        vec![backup_rebuild],
7131    ));
7132
7133    let tombstone_diagnostics =
7134        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7135            .unwrap_or_else(|err| {
7136                vec![format!(
7137                    "graph tombstone retention inspection failed: {err}"
7138                )]
7139            });
7140    report.push_check(GraphDbDoctorCheck {
7141        name: "sqlite_tombstone_retention".to_string(),
7142        status: if tombstone_diagnostics.is_empty() {
7143            "ok".to_string()
7144        } else {
7145            "warning".to_string()
7146        },
7147        fail_closed: false,
7148        diagnostics: tombstone_diagnostics,
7149        repair_commands: Vec::new(),
7150    });
7151    let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7152        Ok(counts) => {
7153            let policy = graph_db_compaction_policy(root, scope, &counts, false);
7154            GraphDbDoctorCheck {
7155                name: "sqlite_compaction_policy".to_string(),
7156                status: policy.status.clone(),
7157                fail_closed: false,
7158                diagnostics: policy.proof,
7159                repair_commands: if policy.status == "recommended" {
7160                    policy.recommendations
7161                } else {
7162                    Vec::new()
7163                },
7164            }
7165        }
7166        Err(err) => GraphDbDoctorCheck {
7167            name: "sqlite_compaction_policy".to_string(),
7168            status: "warning".to_string(),
7169            fail_closed: false,
7170            diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7171            repair_commands: Vec::new(),
7172        },
7173    };
7174    report.push_check(compaction_check);
7175
7176    Some(conn)
7177}
7178
7179pub(crate) fn append_convex_snapshot_doctor_checks(
7180    report: &mut GraphDbDoctorReport,
7181    root: &Path,
7182    scope: Option<&str>,
7183    local_rows: Option<&ConvexProjectionRows>,
7184    snapshot_path: Option<&Path>,
7185) {
7186    let repair = convex_refresh_command(root, scope);
7187    let Some(snapshot_path) = snapshot_path else {
7188        report.push_check(graph_db_doctor_check(
7189            "convex_snapshot_present",
7190            vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7191            vec![format!(
7192                "tsift convex-sync {}{} --json > convex-rows.json",
7193                shell_quote(root.to_string_lossy().as_ref()),
7194                graph_db_scope_arg(scope)
7195            )],
7196        ));
7197        return;
7198    };
7199    report.push_check(graph_db_doctor_check(
7200        "convex_snapshot_present",
7201        Vec::new(),
7202        vec![repair.clone()],
7203    ));
7204
7205    let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7206        Ok(snapshot) => snapshot,
7207        Err(err) => {
7208            report.push_check(graph_db_doctor_check(
7209                "convex_snapshot_parse",
7210                vec![err.to_string()],
7211                vec![repair],
7212            ));
7213            return;
7214        }
7215    };
7216    report.push_check(graph_db_doctor_check(
7217        "convex_snapshot_parse",
7218        Vec::new(),
7219        vec![repair.clone()],
7220    ));
7221
7222    let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7223    report.push_check(graph_db_doctor_check(
7224        "convex_snapshot_rows",
7225        row_diagnostics,
7226        vec![repair.clone()],
7227    ));
7228
7229    let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7230        .unwrap_or_else(|err| vec![err.to_string()]);
7231    report.required_indexes = convex_required_indexes();
7232    report.push_check(graph_db_doctor_check(
7233        "convex_required_indexes",
7234        index_diagnostics,
7235        vec![
7236            "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7237                .to_string(),
7238        ],
7239    ));
7240
7241    if let Some(local_rows) = local_rows {
7242        let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7243        report.push_check(graph_db_doctor_check(
7244            "convex_projection_freshness",
7245            freshness.diagnostics,
7246            vec![repair],
7247        ));
7248    } else {
7249        report.push_check(graph_db_doctor_check(
7250            "convex_projection_freshness",
7251            vec![
7252                "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7253                    .to_string(),
7254            ],
7255            vec![graph_db_rebuild_command(root, scope)],
7256        ));
7257    }
7258}
7259
7260fn graph_db_convex_snapshot_doctor_command(
7261    root: &Path,
7262    scope: Option<&str>,
7263    snapshot_path: &Path,
7264) -> String {
7265    format!(
7266        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7267        shell_quote(root.to_string_lossy().as_ref()),
7268        graph_db_scope_arg(scope),
7269        shell_quote(snapshot_path.to_string_lossy().as_ref())
7270    )
7271}
7272
7273fn graph_db_convex_snapshot_read_command(
7274    root: &Path,
7275    scope: Option<&str>,
7276    snapshot_path: &Path,
7277) -> String {
7278    format!(
7279        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7280        shell_quote(root.to_string_lossy().as_ref()),
7281        graph_db_scope_arg(scope),
7282        shell_quote(snapshot_path.to_string_lossy().as_ref())
7283    )
7284}
7285
7286fn convex_sync_snapshot_diff_command(
7287    root: &Path,
7288    scope: Option<&str>,
7289    snapshot_path: &Path,
7290) -> String {
7291    format!(
7292        "tsift convex-sync {}{} --snapshot {} --json",
7293        shell_quote(root.to_string_lossy().as_ref()),
7294        graph_db_scope_arg(scope),
7295        shell_quote(snapshot_path.to_string_lossy().as_ref())
7296    )
7297}
7298
7299pub(crate) struct GraphDbDriftInput<'a> {
7300    root: &'a Path,
7301    scope: Option<&'a str>,
7302    graph_db: &'a Path,
7303    snapshot_path: &'a Path,
7304    local: &'a ConvexProjectionRows,
7305    snapshot: &'a ConvexProjectionRows,
7306    snapshot_value: &'a serde_json::Value,
7307    warnings: Vec<String>,
7308}
7309
7310pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7311    let GraphDbDriftInput {
7312        root,
7313        scope,
7314        graph_db,
7315        snapshot_path,
7316        local,
7317        snapshot,
7318        snapshot_value,
7319        warnings,
7320    } = input;
7321    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7322    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7323        convex_rows_diff(local, Some(snapshot));
7324    let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7325    let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7326        .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7327    let local_hash = freshness.local_hash.clone();
7328    let snapshot_hash = freshness.snapshot_hash.clone();
7329    let stale_nodes = freshness.stale_nodes.clone();
7330    let stale_edges = freshness.stale_edges.clone();
7331
7332    let duplicate_failures = row_diagnostics
7333        .iter()
7334        .filter(|diagnostic| diagnostic.contains("duplicate"))
7335        .count();
7336    let orphan_failures = row_diagnostics
7337        .iter()
7338        .filter(|diagnostic| diagnostic.contains("references missing"))
7339        .count();
7340    let missing_required_indexes = index_diagnostics.len();
7341    let stale_projection_metadata =
7342        usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7343    let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7344    let has_drift = freshness.fail_closed
7345        || !node_upserts.is_empty()
7346        || !edge_upserts.is_empty()
7347        || !node_tombstones.is_empty()
7348        || !edge_tombstones.is_empty();
7349    let status = if hard_failures > 0 {
7350        "fail_closed"
7351    } else if has_drift {
7352        "drift"
7353    } else {
7354        "current"
7355    }
7356    .to_string();
7357
7358    let mut diagnostics = Vec::new();
7359    diagnostics.extend(row_diagnostics);
7360    diagnostics.extend(index_diagnostics);
7361    diagnostics.extend(freshness.diagnostics.clone());
7362    if has_drift {
7363        diagnostics.push(format!(
7364            "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
7365            node_upserts.len(),
7366            edge_upserts.len(),
7367            node_tombstones.len(),
7368            edge_tombstones.len()
7369        ));
7370    }
7371
7372    let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
7373        root,
7374        scope,
7375        snapshot_path,
7376    )];
7377    if status == "current" {
7378        next_commands.push(graph_db_convex_snapshot_read_command(
7379            root,
7380            scope,
7381            snapshot_path,
7382        ));
7383    } else {
7384        next_commands.push(convex_sync_snapshot_diff_command(
7385            root,
7386            scope,
7387            snapshot_path,
7388        ));
7389        next_commands.push(convex_refresh_command(root, scope));
7390    }
7391
7392    GraphDbDriftReport {
7393        root: root.to_string_lossy().to_string(),
7394        scope: scope.map(str::to_string),
7395        graph_db: graph_db.to_string_lossy().to_string(),
7396        convex_snapshot: snapshot_path.to_string_lossy().to_string(),
7397        status: status.clone(),
7398        graph_reads_allowed: status == "current",
7399        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
7400        local_hash,
7401        snapshot_hash,
7402        summary: GraphDbDriftSummary {
7403            node_upserts: node_upserts.len(),
7404            edge_upserts: edge_upserts.len(),
7405            node_tombstones: node_tombstones.len(),
7406            edge_tombstones: edge_tombstones.len(),
7407            stale_nodes: stale_nodes.len(),
7408            stale_edges: stale_edges.len(),
7409            stale_projection_metadata,
7410            duplicate_failures,
7411            orphan_failures,
7412            missing_required_indexes,
7413        },
7414        node_upserts: node_upserts
7415            .into_iter()
7416            .map(|row| row.external_id)
7417            .collect(),
7418        edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
7419        node_tombstones,
7420        edge_tombstones,
7421        stale_nodes,
7422        stale_edges,
7423        diagnostics,
7424        next_commands,
7425        required_indexes: convex_required_indexes(),
7426        warnings,
7427    }
7428}
7429
7430pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
7431    println!(
7432        "graph-db drift status: {} reads_allowed: {}",
7433        report.status, report.graph_reads_allowed
7434    );
7435    println!("graph_db: {}", report.graph_db);
7436    println!("convex_snapshot: {}", report.convex_snapshot);
7437    println!(
7438        "upserts: {} node(s), {} edge(s)",
7439        report.summary.node_upserts, report.summary.edge_upserts
7440    );
7441    println!(
7442        "tombstones: {} node(s), {} edge(s)",
7443        report.summary.node_tombstones, report.summary.edge_tombstones
7444    );
7445    for diagnostic in &report.diagnostics {
7446        println!("diagnostic: {diagnostic}");
7447    }
7448    for command in &report.next_commands {
7449        println!("next: {command}");
7450    }
7451}
7452
7453pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
7454    println!(
7455        "graph-db doctor backend: {} status: {}",
7456        report.backend, report.status
7457    );
7458    println!("graph_db: {}", report.graph_db);
7459    if let Some(snapshot) = &report.convex_snapshot {
7460        println!("convex_snapshot: {snapshot}");
7461    }
7462    for check in &report.checks {
7463        println!("check: {} {}", check.name, check.status);
7464        for diagnostic in &check.diagnostics {
7465            println!("  diagnostic: {diagnostic}");
7466        }
7467    }
7468    for command in &report.repair_commands {
7469        println!("repair: {command}");
7470    }
7471}
7472
7473pub(crate) fn graph_db_operator_report_from_disk(
7474    root: &Path,
7475    scope: Option<&str>,
7476    graph_db: &Path,
7477    operation: &str,
7478    refresh: Option<GraphDbRefreshSummary>,
7479    warnings: Vec<String>,
7480) -> Result<GraphDbOperatorReport> {
7481    if !graph_db.exists() {
7482        let next_commands = graph_db_operator_next_commands(root, scope, true);
7483        let counts = GraphDbOperatorCounts {
7484            nodes: 0,
7485            edges: 0,
7486            tombstones: GraphDbTombstoneCounts {
7487                nodes: 0,
7488                edges: 0,
7489                total: 0,
7490            },
7491            file_size_bytes: None,
7492            freelist_bytes: None,
7493        };
7494        return Ok(GraphDbOperatorReport {
7495            root: root.to_string_lossy().to_string(),
7496            scope: scope.map(str::to_string),
7497            graph_db: graph_db.to_string_lossy().to_string(),
7498            operation: operation.to_string(),
7499            status: "missing".to_string(),
7500            materialized: false,
7501            freshness: GraphDbFreshnessReport {
7502                status: "missing".to_string(),
7503                fail_closed: true,
7504                projection_version: None,
7505                content_hash: None,
7506                source_watermark: None,
7507                diagnostics: vec![
7508                    "graph.db is missing; run graph-db refresh before trusting graph reads"
7509                        .to_string(),
7510                ],
7511            },
7512            readiness: graph_effectiveness_blocked(
7513                "graph_db_missing",
7514                vec![
7515                    "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
7516                ],
7517                next_commands.clone(),
7518            ),
7519            counts: counts.clone(),
7520            refresh,
7521            compaction: graph_db_compaction_policy(root, scope, &counts, false),
7522            recovery: None,
7523            next_commands,
7524            warnings,
7525        });
7526    }
7527
7528    let conn = open_sqlite_graph_db_readonly(graph_db)?;
7529    let recovery = conn.recovery();
7530    let mut warnings = warnings;
7531    if let Some(recovery) = recovery {
7532        warnings.push(graph_db_read_recovery_diagnostic(recovery));
7533    }
7534    let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
7535    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7536        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7537    if !schema_diagnostics.is_empty() {
7538        freshness.diagnostics.extend(schema_diagnostics);
7539        freshness.fail_closed = true;
7540        freshness.status = "stale".to_string();
7541    }
7542    let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
7543    let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
7544    warnings.extend(
7545        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7546            .unwrap_or_else(|err| {
7547                vec![format!(
7548                    "graph tombstone retention inspection failed: {err}"
7549                )]
7550            }),
7551    );
7552    let status = if freshness.fail_closed {
7553        "stale"
7554    } else {
7555        "current"
7556    }
7557    .to_string();
7558
7559    Ok(GraphDbOperatorReport {
7560        root: root.to_string_lossy().to_string(),
7561        scope: scope.map(str::to_string),
7562        graph_db: graph_db.to_string_lossy().to_string(),
7563        operation: operation.to_string(),
7564        status,
7565        materialized: true,
7566        freshness,
7567        readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
7568        compaction: graph_db_compaction_policy(root, scope, &counts, false),
7569        counts,
7570        refresh,
7571        recovery,
7572        next_commands: graph_db_operator_next_commands(root, scope, false),
7573        warnings,
7574    })
7575}
7576
7577fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
7578    println!(
7579        "graph-db {} status: {} materialized: {}",
7580        report.operation, report.status, report.materialized
7581    );
7582    println!("graph_db: {}", report.graph_db);
7583    println!(
7584        "projection: version={} hash={} watermark={}",
7585        report
7586            .freshness
7587            .projection_version
7588            .as_deref()
7589            .unwrap_or("<missing>"),
7590        report
7591            .freshness
7592            .content_hash
7593            .as_deref()
7594            .unwrap_or("<missing>"),
7595        report
7596            .freshness
7597            .source_watermark
7598            .as_deref()
7599            .unwrap_or("<missing>")
7600    );
7601    println!(
7602        "rows: {} node(s), {} edge(s), {} tombstone(s)",
7603        report.counts.nodes, report.counts.edges, report.counts.tombstones.total
7604    );
7605    println!(
7606        "readiness: {} reason: {} fail_closed: {}",
7607        report.readiness.status, report.readiness.reason, report.readiness.fail_closed
7608    );
7609    if let Some(file_size) = report.counts.file_size_bytes {
7610        println!(
7611            "storage: {} byte(s), {} free byte(s)",
7612            file_size,
7613            report.counts.freelist_bytes.unwrap_or(0)
7614        );
7615    }
7616    if let Some(refresh) = &report.refresh {
7617        println!(
7618            "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
7619            refresh.tombstoned_nodes, refresh.tombstoned_edges
7620        );
7621        println!(
7622            "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)",
7623            refresh.upserted_nodes,
7624            refresh.upserted_edges,
7625            refresh.upserted_properties,
7626            refresh.unchanged_nodes,
7627            refresh.unchanged_edges,
7628            refresh.unchanged_properties,
7629            refresh.deleted_properties,
7630            refresh.pruned_tombstones
7631        );
7632    }
7633    println!(
7634        "compaction: {} tombstone_scan_rows={} live_rows={}",
7635        report.compaction.status,
7636        report.compaction.tombstone_scan_rows,
7637        report.compaction.live_rows
7638    );
7639    for proof in &report.compaction.proof {
7640        println!("compaction proof: {proof}");
7641    }
7642    if let Some(recovery) = report.recovery {
7643        println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
7644    }
7645    for diagnostic in &report.freshness.diagnostics {
7646        println!("diagnostic: {diagnostic}");
7647    }
7648    for diagnostic in &report.readiness.diagnostics {
7649        println!("readiness diagnostic: {diagnostic}");
7650    }
7651    for warning in &report.warnings {
7652        println!("warning: {warning}");
7653    }
7654    for command in &report.readiness.next_commands {
7655        println!("readiness next: {command}");
7656    }
7657    for command in &report.next_commands {
7658        println!("next: {command}");
7659    }
7660}
7661
7662pub(crate) fn print_graph_db_operator_report(
7663    report: &GraphDbOperatorReport,
7664    format: OutputFormat,
7665) -> Result<()> {
7666    if format.json_output {
7667        print_json_or_envelope(
7668            report,
7669            &format,
7670            "graph-db",
7671            &report.operation,
7672            ToolEnvelopeSummary {
7673                text: format!(
7674                    "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
7675                    report.operation,
7676                    report.status,
7677                    report.counts.nodes,
7678                    report.counts.edges,
7679                    report.counts.tombstones.total
7680                ),
7681                metrics: vec![
7682                    envelope_metric("operation", &report.operation),
7683                    envelope_metric("status", &report.status),
7684                    envelope_metric("nodes", report.counts.nodes),
7685                    envelope_metric("edges", report.counts.edges),
7686                    envelope_metric("tombstones", report.counts.tombstones.total),
7687                    envelope_metric("compaction", &report.compaction.status),
7688                    envelope_metric("readiness", &report.readiness.status),
7689                ],
7690            },
7691            false,
7692            report.next_commands.clone(),
7693        )
7694    } else {
7695        print_graph_db_operator_human(report);
7696        Ok(())
7697    }
7698}
7699
7700fn status_run_command_without_notes(run: &str) -> &str {
7701    run.split_once("  (")
7702        .map(|(command, _)| command)
7703        .unwrap_or(run)
7704}
7705
7706fn status_summarize_extract_command(run: &str) -> &str {
7707    let run = status_run_command_without_notes(run);
7708    run.split(" && ")
7709        .find(|command| command.contains("summarize --extract"))
7710        .unwrap_or(run)
7711}
7712
7713fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
7714    report
7715        .recommendations
7716        .run
7717        .as_deref()
7718        .filter(|command| command.contains("summarize --extract"))
7719        .map(status_summarize_extract_command)
7720        .unwrap_or("tsift summarize --extract .")
7721        .to_string()
7722}
7723
7724fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
7725    let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7726    readiness.diagnostics.push(format!(
7727        "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
7728    ));
7729    readiness
7730}
7731
7732fn graph_db_semantic_readiness(
7733    root: &Path,
7734    scope: Option<&str>,
7735    semantic_row_count: Option<usize>,
7736) -> GraphEffectivenessReadiness {
7737    if let Some(row_count) = semantic_row_count
7738        && row_count > 0
7739    {
7740        return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
7741    }
7742
7743    let report = match status::check_status(root) {
7744        Ok(report) => report,
7745        Err(err) => {
7746            return graph_effectiveness_blocked(
7747                "status_check_unavailable",
7748                vec![format!(
7749                    "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
7750                )],
7751                vec![graph_db_refresh_command(root, scope)],
7752            );
7753        }
7754    };
7755
7756    match &report.summaries {
7757        status::SummaryStatus::Available {
7758            cached_files,
7759            total_indexed_files,
7760            coverage_pct,
7761            ..
7762        } => {
7763            let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7764            readiness.diagnostics.push(format!(
7765                "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
7766            ));
7767            readiness
7768        }
7769        status::SummaryStatus::None { .. } => {
7770            let summarize = graph_db_status_summarize_command(&report);
7771            let index_command = report
7772                .recommendations
7773                .run
7774                .as_deref()
7775                .filter(|cmd| cmd.contains("index"))
7776                .map(str::to_string);
7777            let mut repair = Vec::new();
7778            if let Some(cmd) = index_command {
7779                repair.push(cmd);
7780            }
7781            repair.push(summarize.clone());
7782            repair.push(graph_db_refresh_command(root, scope));
7783            graph_effectiveness_blocked(
7784                "summary_cache_empty",
7785                vec![format!(
7786                    "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7787                    summarize,
7788                    root.display(),
7789                    graph_db_refresh_command(root, scope)
7790                )],
7791                repair,
7792            )
7793        }
7794        status::SummaryStatus::Unavailable => {
7795            let mut repair: Vec<String> = report.recommendations.run.clone().into_iter().collect();
7796            let summarize = "tsift summarize --extract .".to_string();
7797            repair.push(summarize);
7798            repair.push(graph_db_refresh_command(root, scope));
7799            graph_effectiveness_blocked(
7800                "summary_cache_unavailable",
7801                vec![
7802                    "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(),
7803                ],
7804                repair,
7805            )
7806        }
7807    }
7808}
7809
7810pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
7811    let report = match status::check_status(root) {
7812        Ok(report) => report,
7813        Err(err) => {
7814            return vec![format!(
7815                "status check unavailable after graph-db refresh: {err:#}"
7816            )];
7817        }
7818    };
7819
7820    let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7821        Some(graph_db_status_summarize_command(&report))
7822    } else {
7823        None
7824    };
7825    let mut warnings = report.reminders;
7826    if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7827        let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
7828        warnings.push(format!(
7829            "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7830            run,
7831            root.display(),
7832            graph_db_refresh_command(root, scope)
7833        ));
7834    }
7835    dedupe_preserve_order(warnings)
7836}
7837
7838pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
7839    println!(
7840        "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
7841        report.applied, report.pruned_tombstones, report.reclaimed_bytes
7842    );
7843    println!("graph_db: {}", report.graph_db);
7844    println!(
7845        "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7846        report.counts_before.nodes,
7847        report.counts_before.edges,
7848        report.counts_before.tombstones.total,
7849        report.counts_before.file_size_bytes.unwrap_or(0),
7850        report.counts_before.freelist_bytes.unwrap_or(0)
7851    );
7852    println!(
7853        "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7854        report.counts_after.nodes,
7855        report.counts_after.edges,
7856        report.counts_after.tombstones.total,
7857        report.counts_after.file_size_bytes.unwrap_or(0),
7858        report.counts_after.freelist_bytes.unwrap_or(0)
7859    );
7860    for proof in &report.compaction_after.proof {
7861        println!("proof: {proof}");
7862    }
7863    for warning in &report.warnings {
7864        println!("warning: {warning}");
7865    }
7866    for command in &report.next_commands {
7867        println!("next: {command}");
7868    }
7869}
7870
7871fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
7872    raw.iter()
7873        .map(|value| {
7874            let (key, filter_value) = value
7875                .split_once('=')
7876                .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
7877            let key = key.trim();
7878            let filter_value = filter_value.trim();
7879            if key.is_empty() || filter_value.is_empty() {
7880                bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
7881            }
7882            Ok(GraphDbPropertyFilter {
7883                key: key.to_string(),
7884                value: filter_value.to_string(),
7885            })
7886        })
7887        .collect()
7888}
7889
7890fn graph_db_query_options(
7891    cursor: Option<String>,
7892    limit: Option<usize>,
7893    property_filters: &[String],
7894) -> Result<GraphDbQueryOptions> {
7895    Ok(GraphDbQueryOptions {
7896        cursor,
7897        limit: limit.filter(|limit| *limit > 0),
7898        property_filters: parse_graph_db_property_filters(property_filters)?,
7899    })
7900}
7901
7902fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
7903    GraphQueryOptions {
7904        cursor: options.cursor.clone(),
7905        limit: options.limit,
7906        property_filters: options
7907            .property_filters
7908            .iter()
7909            .map(|filter| GraphPropertyFilter {
7910                key: filter.key.clone(),
7911                value: filter.value.clone(),
7912            })
7913            .collect(),
7914    }
7915}
7916
7917fn graph_db_page_report_from_store(
7918    page: GraphQueryPage,
7919    property_filters: Vec<GraphDbPropertyFilter>,
7920) -> GraphDbPageReport {
7921    GraphDbPageReport {
7922        cursor: page.cursor,
7923        limit: page.limit,
7924        next_cursor: page.next_cursor,
7925        returned_nodes: page.returned_nodes,
7926        returned_edges: page.returned_edges,
7927        truncated: page.truncated,
7928        property_filters,
7929        diagnostics: page.diagnostics,
7930    }
7931}
7932
7933fn graph_db_neighborhood_ranking_gate(
7934    ranked_neighbor_cap: usize,
7935) -> GraphDbNeighborhoodRankingGate {
7936    GraphDbNeighborhoodRankingGate {
7937        status: "held_default_order_unchanged".to_string(),
7938        ranked_output_default: false,
7939        default_order: "stable_node_id".to_string(),
7940        default_change_gate: "community_search_quality_metrics".to_string(),
7941        required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
7942            .iter()
7943            .map(|workload| (*workload).to_string())
7944            .collect(),
7945        required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
7946            .iter()
7947            .map(|metric| (*metric).to_string())
7948            .collect(),
7949        max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
7950        min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
7951        min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
7952        min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
7953        diagnostics: vec![
7954            "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
7955            format!(
7956                "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
7957            ),
7958            "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
7959        ],
7960    }
7961}
7962
7963fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
7964    match limit {
7965        Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
7966        Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
7967    }
7968}
7969
7970fn graph_db_ranked_neighbors(
7971    center_id: &str,
7972    nodes: &[SubstrateGraphNode],
7973    edges: &[SubstrateGraphEdge],
7974    cap: usize,
7975) -> Vec<GraphDbRankedNeighbor> {
7976    resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
7977}
7978
7979fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
7980    center_id: &str,
7981    depth: usize,
7982    edge_kind: Option<&str>,
7983    limit: Option<usize>,
7984    unranked_nodes: &[SubstrateGraphNode],
7985    unranked_edges: &[SubstrateGraphEdge],
7986    store: &S,
7987) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
7988    use std::time::Instant;
7989    let max_nodes = match limit {
7990        Some(0) | None => 200,
7991        Some(n) => n.clamp(10, 500),
7992    };
7993    let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
7994        .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
7995    if let Some(kind) = edge_kind {
7996        options = options.with_edge_kind(kind);
7997    }
7998    let start = Instant::now();
7999    let result = store.ranked_neighborhood(center_id, &options)?;
8000    let latency = start.elapsed().as_micros();
8001    let Some(ranked) = result else {
8002        return Ok(None);
8003    };
8004    let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
8005    let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
8006    let overlap_count = ranked_ids.intersection(&unranked_ids).count();
8007    let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
8008        0.0
8009    } else {
8010        (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8011    };
8012    let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8013        let mut name_count = BTreeMap::<&str, usize>::new();
8014        for n in nodes {
8015            *name_count.entry(&n.label).or_default() += 1;
8016        }
8017        name_count.values().filter(|&&c| c > 1).count()
8018    };
8019    let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8020        if nodes.is_empty() {
8021            return 100.0;
8022        }
8023        let with_handle = nodes
8024            .iter()
8025            .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8026            .count();
8027        (with_handle as f64 / nodes.len() as f64) * 100.0
8028    };
8029    let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8030        if nodes.is_empty() {
8031            return 0.0;
8032        }
8033        let semantic_kinds = [
8034            "semantic_concept",
8035            "semantic_entity",
8036            "symbol",
8037            "file",
8038            "source_handle",
8039        ];
8040        let useful = nodes
8041            .iter()
8042            .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8043            .count();
8044        let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8045        let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8046        (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8047            / nodes.len() as f64
8048    };
8049    let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8050        let edge_pairs: Vec<(String, String)> = ranked
8051            .edges
8052            .iter()
8053            .map(|e| (e.from_id.clone(), e.to_id.clone()))
8054            .collect();
8055        let cr = tsift_graph::detect_communities(&edge_pairs);
8056        let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8057        let mut fully_kept = 0usize;
8058        let mut partially_pruned = 0usize;
8059        let mut fully_pruned = 0usize;
8060        let mut pruned_kinds = BTreeSet::new();
8061        let mut pruned_labels = Vec::new();
8062        for comm in &cr.communities {
8063            let kept_in_comm: Vec<&str> = comm
8064                .members
8065                .iter()
8066                .filter(|m| kept_labels.contains(m.name.as_str()))
8067                .map(|m| m.name.as_str())
8068                .collect();
8069            if kept_in_comm.len() == comm.members.len() {
8070                fully_kept += 1;
8071            } else if kept_in_comm.is_empty() {
8072                fully_pruned += 1;
8073                for m in &comm.members {
8074                    if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8075                        pruned_kinds.insert(n.kind.clone());
8076                    }
8077                    pruned_labels.push(m.name.clone());
8078                }
8079            } else {
8080                partially_pruned += 1;
8081            }
8082        }
8083        pruned_labels.truncate(5);
8084        Some(CommunityTruncationSummary {
8085            total_communities: cr.communities.len(),
8086            fully_kept,
8087            partially_pruned,
8088            fully_pruned,
8089            pruned_community_kinds: pruned_kinds.into_iter().collect(),
8090            pruned_community_top_labels: pruned_labels,
8091        })
8092    } else {
8093        None
8094    };
8095    Ok(Some(GraphDbRankedNeighborhoodComparison {
8096        traversal_nodes: ranked.nodes.len(),
8097        traversal_edges: ranked.edges.len(),
8098        pruned_count: ranked.pruned_count,
8099        total_discovered: ranked.total_discovered,
8100        latency_micros: latency,
8101        overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8102        useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8103            / 1000.0,
8104        useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8105            .round()
8106            / 1000.0,
8107        duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8108        duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8109        handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8110        handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8111            / 100.0,
8112        community_truncation_summary,
8113        diagnostics: vec![
8114            format!(
8115                "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8116                ranked.nodes.len(),
8117                ranked.edges.len(),
8118                ranked.pruned_count,
8119                ranked.total_discovered,
8120                latency
8121            ),
8122            format!(
8123                "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8124                overlap_pct,
8125                overlap_count,
8126                unranked_ids.len(),
8127                ranked_ids.len()
8128            ),
8129            "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8130        ],
8131    }))
8132}
8133
8134struct GraphDbBudgetedSubgraph {
8135    nodes: Vec<SubstrateGraphNode>,
8136    edges: Vec<SubstrateGraphEdge>,
8137    report: GraphDbOutputBudgetReport,
8138    truncated: bool,
8139    next_cursor: Option<String>,
8140}
8141
8142const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8143const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8144const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8145
8146fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8147    match limit {
8148        Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8149        Some(limit) => limit
8150            .saturating_mul(320)
8151            .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8152    }
8153}
8154
8155fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8156    if matches!(limit, Some(0) | None) {
8157        return match kind {
8158            "source_handle" => 10,
8159            "worker_context" | "worker_result" => 8,
8160            "semantic_concept" | "semantic_entity" => 10,
8161            "file" | "symbol" | "route" => 12,
8162            _ => 8,
8163        };
8164    }
8165    let base = limit.unwrap_or(0).max(1);
8166    match kind {
8167        "source_handle" => base.saturating_add(4),
8168        "worker_context" | "worker_result" => base.saturating_add(2),
8169        "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8170        "file" | "symbol" | "route" => base.saturating_add(4),
8171        _ => base.saturating_add(1),
8172    }
8173}
8174
8175fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8176    if matches!(limit, Some(0) | None) {
8177        return match kind {
8178            "mentions" | "mentions_concept" | "mentions_entity" => 24,
8179            "semantic_relation" | "calls" | "defines" => 20,
8180            _ => 16,
8181        };
8182    }
8183    let base = limit.unwrap_or(0).max(1);
8184    match kind {
8185        "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8186        "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8187        _ => base.saturating_add(2),
8188    }
8189}
8190
8191fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8192    serde_json::to_vec(value)
8193        .map(|bytes| bytes.len().div_ceil(4).max(1))
8194        .unwrap_or(1)
8195}
8196
8197fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8198    let mut parts = vec![node.kind.clone(), node.label.clone()];
8199    for key in [
8200        "detail",
8201        "description",
8202        "source_ref",
8203        "path",
8204        "source_file",
8205        "source_symbol",
8206        "text_preview",
8207    ] {
8208        if let Some(value) = node.properties.get(key) {
8209            parts.push(value.clone());
8210        }
8211    }
8212    parts.join(" ")
8213}
8214
8215fn graph_db_semantic_scores_for_query(
8216    query: Option<&str>,
8217    nodes: &[SubstrateGraphNode],
8218) -> BTreeMap<String, f64> {
8219    let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8220        return BTreeMap::new();
8221    };
8222    let query_embedding = semantic_embedding(query);
8223    nodes
8224        .iter()
8225        .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8226        .filter_map(|node| {
8227            let embedding = node
8228                .properties
8229                .get("embedding")
8230                .and_then(|value| parse_semantic_embedding_property(value))?;
8231            Some((
8232                node.id.clone(),
8233                semantic_cosine(&query_embedding, &embedding),
8234            ))
8235        })
8236        .collect()
8237}
8238
8239fn graph_db_depth_by_id(
8240    origin_ids: &[String],
8241    edges: &[SubstrateGraphEdge],
8242) -> BTreeMap<String, usize> {
8243    let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8244    for edge in edges {
8245        adjacency
8246            .entry(edge.from_id.clone())
8247            .or_default()
8248            .push(edge.to_id.clone());
8249        adjacency
8250            .entry(edge.to_id.clone())
8251            .or_default()
8252            .push(edge.from_id.clone());
8253    }
8254
8255    let mut depth_by_id = BTreeMap::<String, usize>::new();
8256    let mut queue = VecDeque::<String>::new();
8257    for origin in origin_ids {
8258        if depth_by_id.insert(origin.clone(), 0).is_none() {
8259            queue.push_back(origin.clone());
8260        }
8261    }
8262    while let Some(current) = queue.pop_front() {
8263        let depth = depth_by_id.get(&current).copied().unwrap_or(0);
8264        for next in adjacency.get(&current).into_iter().flatten() {
8265            if depth_by_id.contains_key(next) {
8266                continue;
8267            }
8268            depth_by_id.insert(next.clone(), depth.saturating_add(1));
8269            queue.push_back(next.clone());
8270        }
8271    }
8272    depth_by_id
8273}
8274
8275fn graph_db_source_covered_ids(
8276    nodes: &[SubstrateGraphNode],
8277    edges: &[SubstrateGraphEdge],
8278) -> BTreeSet<String> {
8279    let source_ids = nodes
8280        .iter()
8281        .filter(|node| node.kind == "source_handle")
8282        .map(|node| node.id.as_str())
8283        .collect::<BTreeSet<_>>();
8284    let mut covered = source_ids
8285        .iter()
8286        .map(|id| (*id).to_string())
8287        .collect::<BTreeSet<_>>();
8288    for edge in edges {
8289        if source_ids.contains(edge.from_id.as_str()) {
8290            covered.insert(edge.to_id.clone());
8291        }
8292        if source_ids.contains(edge.to_id.as_str()) {
8293            covered.insert(edge.from_id.clone());
8294        }
8295    }
8296    covered
8297}
8298
8299fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8300    for key in [
8301        "observed_at_unix",
8302        "completed_at_unix",
8303        "created_at_unix",
8304        "started_at_unix",
8305    ] {
8306        if let Some(value) = node.properties.get(key)
8307            && let Ok(epoch) = value.parse::<i64>()
8308        {
8309            return epoch.div_euclid(86_400).clamp(0, 40_000);
8310        }
8311    }
8312    0
8313}
8314
8315fn graph_db_node_kind_score(kind: &str) -> i64 {
8316    match kind {
8317        "source_handle" => 180,
8318        "worker_context" => 170,
8319        "worker_result" => 160,
8320        "semantic_concept" | "semantic_entity" => 150,
8321        "backlog" | "job_packet" => 130,
8322        "symbol" => 120,
8323        "file" => 110,
8324        "route" => 105,
8325        "session" => 90,
8326        _ => 40,
8327    }
8328}
8329
8330fn graph_db_edge_kind_score(kind: &str) -> i64 {
8331    match kind {
8332        "mentions_concept" | "mentions_entity" => 180,
8333        "semantic_relation" => 170,
8334        "mentions" => 165,
8335        "requests_context" | "scopes_context" | "scopes_source" => 155,
8336        "explains_result" => 150,
8337        "calls" => 145,
8338        "defines" | "handled_by" | "defines_route" => 130,
8339        "contains" | "targets" => 120,
8340        "records_memory_source" | "has_vector_handle" => 115,
8341        _ => 40,
8342    }
8343}
8344
8345fn graph_db_node_usefulness_score(
8346    node: &SubstrateGraphNode,
8347    depth_by_id: &BTreeMap<String, usize>,
8348    semantic_scores: &BTreeMap<String, f64>,
8349    source_covered_ids: &BTreeSet<String>,
8350    origin_ids: &[String],
8351) -> i64 {
8352    if origin_ids.iter().any(|origin| origin == &node.id) {
8353        return 1_000_000;
8354    }
8355    let semantic = semantic_scores
8356        .get(&node.id)
8357        .map(|score| (score.max(0.0) * 1_000.0) as i64)
8358        .unwrap_or(0);
8359    let depth_penalty = depth_by_id
8360        .get(&node.id)
8361        .map(|depth| (*depth as i64).saturating_mul(55))
8362        .unwrap_or(180);
8363    let source_coverage = if source_covered_ids.contains(&node.id)
8364        || node.properties.contains_key("source_ref")
8365        || node.properties.contains_key("path")
8366    {
8367        120
8368    } else {
8369        0
8370    };
8371    graph_db_node_kind_score(&node.kind)
8372        + semantic
8373        + source_coverage
8374        + graph_db_recency_score(node).min(80)
8375        - depth_penalty
8376}
8377
8378fn graph_db_edge_usefulness_score(
8379    edge: &SubstrateGraphEdge,
8380    node_score_by_id: &BTreeMap<String, i64>,
8381    depth_by_id: &BTreeMap<String, usize>,
8382) -> i64 {
8383    let endpoint_score = node_score_by_id
8384        .get(&edge.from_id)
8385        .copied()
8386        .unwrap_or_default()
8387        .max(
8388            node_score_by_id
8389                .get(&edge.to_id)
8390                .copied()
8391                .unwrap_or_default(),
8392        );
8393    let depth_penalty = depth_by_id
8394        .get(&edge.from_id)
8395        .into_iter()
8396        .chain(depth_by_id.get(&edge.to_id))
8397        .min()
8398        .map(|depth| (*depth as i64).saturating_mul(35))
8399        .unwrap_or(140);
8400    graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
8401}
8402
8403fn graph_db_push_drop(
8404    drops: &mut BTreeMap<(String, String, String), usize>,
8405    item: &str,
8406    kind: &str,
8407    reason: &str,
8408) {
8409    *drops
8410        .entry((item.to_string(), kind.to_string(), reason.to_string()))
8411        .or_default() += 1;
8412}
8413
8414fn graph_db_budget_drop_report(
8415    drops: BTreeMap<(String, String, String), usize>,
8416) -> Vec<GraphDbDroppedByBudget> {
8417    drops
8418        .into_iter()
8419        .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
8420            item,
8421            kind,
8422            reason,
8423            dropped,
8424        })
8425        .collect()
8426}
8427
8428fn graph_db_apply_output_budget(
8429    origin_ids: &[String],
8430    semantic_scores: &BTreeMap<String, f64>,
8431    nodes: Vec<SubstrateGraphNode>,
8432    edges: Vec<SubstrateGraphEdge>,
8433    limit: Option<usize>,
8434) -> GraphDbBudgetedSubgraph {
8435    graph_db_apply_output_budget_with_depths_and_cursor(
8436        origin_ids,
8437        semantic_scores,
8438        nodes,
8439        edges,
8440        limit,
8441        None,
8442        None,
8443    )
8444}
8445
8446fn graph_db_apply_output_budget_with_depths_and_cursor(
8447    origin_ids: &[String],
8448    semantic_scores: &BTreeMap<String, f64>,
8449    nodes: Vec<SubstrateGraphNode>,
8450    edges: Vec<SubstrateGraphEdge>,
8451    limit: Option<usize>,
8452    depth_overrides: Option<&BTreeMap<String, usize>>,
8453    cursor: Option<&str>,
8454) -> GraphDbBudgetedSubgraph {
8455    let max_tokens = graph_db_output_token_cap(limit);
8456    let candidate_nodes = nodes.len();
8457    let candidate_edges = edges.len();
8458    let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
8459    if let Some(depth_overrides) = depth_overrides {
8460        for (id, depth) in depth_overrides {
8461            depth_by_id
8462                .entry(id.clone())
8463                .and_modify(|current| *current = (*current).min(*depth))
8464                .or_insert(*depth);
8465        }
8466    }
8467    let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
8468    let node_score_by_id = nodes
8469        .iter()
8470        .map(|node| {
8471            (
8472                node.id.clone(),
8473                graph_db_node_usefulness_score(
8474                    node,
8475                    &depth_by_id,
8476                    semantic_scores,
8477                    &source_covered_ids,
8478                    origin_ids,
8479                ),
8480            )
8481        })
8482        .collect::<BTreeMap<_, _>>();
8483
8484    let mut node_candidates = nodes.iter().collect::<Vec<_>>();
8485    node_candidates.sort_by(|left, right| {
8486        node_score_by_id
8487            .get(&right.id)
8488            .cmp(&node_score_by_id.get(&left.id))
8489            .then_with(|| left.kind.cmp(&right.kind))
8490            .then_with(|| left.label.cmp(&right.label))
8491            .then_with(|| left.id.cmp(&right.id))
8492    });
8493
8494    let cursor_skip = if let Some(cursor) = cursor {
8495        node_candidates
8496            .iter()
8497            .position(|node| node.id == cursor)
8498            .map(|pos| pos.saturating_add(1))
8499            .unwrap_or(0)
8500    } else {
8501        0
8502    };
8503    if cursor_skip > 0 {
8504        node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
8505    }
8506
8507    let mut selected_node_ids = BTreeSet::new();
8508    let mut selected_node_counts = BTreeMap::<String, usize>::new();
8509    let mut estimated_tokens = 0usize;
8510    let mut drops = BTreeMap::<(String, String, String), usize>::new();
8511    for node in &node_candidates {
8512        let kind_count = selected_node_counts
8513            .get(&node.kind)
8514            .copied()
8515            .unwrap_or_default();
8516        if !origin_ids.iter().any(|origin| origin == &node.id)
8517            && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
8518        {
8519            graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
8520            continue;
8521        }
8522        let tokens = graph_db_estimated_tokens(node);
8523        if !origin_ids.iter().any(|origin| origin == &node.id)
8524            && estimated_tokens.saturating_add(tokens) > max_tokens
8525        {
8526            graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
8527            continue;
8528        }
8529        selected_node_ids.insert(node.id.clone());
8530        *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
8531        estimated_tokens = estimated_tokens.saturating_add(tokens);
8532    }
8533
8534    let has_remaining_candidates = node_candidates
8535        .iter()
8536        .any(|node| !selected_node_ids.contains(&node.id));
8537
8538    let mut selected_nodes = nodes
8539        .into_iter()
8540        .filter(|node| selected_node_ids.contains(&node.id))
8541        .collect::<Vec<_>>();
8542
8543    let mut edge_candidates = edges
8544        .iter()
8545        .filter(|edge| {
8546            selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
8547        })
8548        .collect::<Vec<_>>();
8549    let edge_score_by_key = edge_candidates
8550        .iter()
8551        .map(|edge| {
8552            (
8553                graph_db_edge_key(edge),
8554                graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
8555            )
8556        })
8557        .collect::<BTreeMap<_, _>>();
8558    edge_candidates.sort_by(|left, right| {
8559        edge_score_by_key
8560            .get(&graph_db_edge_key(right))
8561            .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
8562            .then_with(|| left.kind.cmp(&right.kind))
8563            .then_with(|| left.from_id.cmp(&right.from_id))
8564            .then_with(|| left.to_id.cmp(&right.to_id))
8565    });
8566
8567    let endpoint_dropped_edges = edges
8568        .iter()
8569        .filter(|edge| {
8570            !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
8571        })
8572        .count();
8573    if endpoint_dropped_edges > 0 {
8574        drops.insert(
8575            (
8576                "edge".to_string(),
8577                "*".to_string(),
8578                "endpoint_node_dropped".to_string(),
8579            ),
8580            endpoint_dropped_edges,
8581        );
8582    }
8583
8584    let mut selected_edge_ids = BTreeSet::new();
8585    let mut selected_edge_counts = BTreeMap::<String, usize>::new();
8586    for edge in edge_candidates {
8587        let kind_count = selected_edge_counts
8588            .get(&edge.kind)
8589            .copied()
8590            .unwrap_or_default();
8591        if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
8592            graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
8593            continue;
8594        }
8595        let tokens = graph_db_estimated_tokens(edge);
8596        if estimated_tokens.saturating_add(tokens) > max_tokens {
8597            graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
8598            continue;
8599        }
8600        selected_edge_ids.insert(graph_db_edge_key(edge));
8601        *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
8602        estimated_tokens = estimated_tokens.saturating_add(tokens);
8603    }
8604
8605    let selected_edges = edges
8606        .into_iter()
8607        .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
8608        .collect::<Vec<_>>();
8609    let dropped_by_budget = graph_db_budget_drop_report(drops);
8610    let truncated = has_remaining_candidates;
8611    let next_cursor = if truncated {
8612        selected_nodes.last().map(|node| node.id.clone())
8613    } else {
8614        None
8615    };
8616    let mut diagnostics = vec![
8617        "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
8618            .to_string(),
8619        format!(
8620            "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
8621            selected_nodes.len(),
8622            candidate_nodes,
8623            selected_edges.len(),
8624            candidate_edges,
8625            max_tokens
8626        ),
8627    ];
8628    if cursor.is_some() {
8629        diagnostics.push(format!(
8630            "cursor skipped {} previously returned candidate(s)",
8631            cursor_skip
8632        ));
8633    }
8634    if next_cursor.is_some() {
8635        diagnostics.push(
8636            "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
8637        );
8638    }
8639    selected_nodes.shrink_to_fit();
8640
8641    GraphDbBudgetedSubgraph {
8642        nodes: selected_nodes,
8643        edges: selected_edges,
8644        report: GraphDbOutputBudgetReport {
8645            max_tokens,
8646            estimated_tokens,
8647            selected_nodes: selected_node_ids.len(),
8648            selected_edges: selected_edge_ids.len(),
8649            candidate_nodes,
8650            candidate_edges,
8651            dropped_by_budget,
8652            diagnostics,
8653        },
8654        truncated,
8655        next_cursor,
8656    }
8657}
8658
8659fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
8660    if edge.id.is_empty() {
8661        substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
8662    } else {
8663        edge.id.clone()
8664    }
8665}
8666
8667fn graph_db_schema() -> GraphDbSchema {
8668    GraphDbSchema {
8669        contract_versions: vec![
8670            GraphDbSchemaContract {
8671                name: "graph_db_evidence",
8672                version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
8673                description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
8674            },
8675            GraphDbSchemaContract {
8676                name: "worker_prompt_packet",
8677                version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
8678                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",
8679            },
8680            GraphDbSchemaContract {
8681                name: "conflict_matrix",
8682                version: CONFLICT_MATRIX_CONTRACT_VERSION,
8683                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",
8684            },
8685            GraphDbSchemaContract {
8686                name: "context_pack_graph_orchestration",
8687                version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
8688                description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
8689            },
8690            GraphDbSchemaContract {
8691                name: "session_review_follow_up",
8692                version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
8693                description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
8694            },
8695            GraphDbSchemaContract {
8696                name: "dispatch_trace",
8697                version: DISPATCH_TRACE_CONTRACT_VERSION,
8698                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",
8699            },
8700            GraphDbSchemaContract {
8701                name: "dependency_dag",
8702                version: DEPENDENCY_DAG_CONTRACT_VERSION,
8703                description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
8704            },
8705        ],
8706        node_fields: vec![
8707            GraphDbSchemaField {
8708                name: "id",
8709                value_type: "string",
8710                description: "Stable provider-neutral node id",
8711            },
8712            GraphDbSchemaField {
8713                name: "kind",
8714                value_type: "string",
8715                description: "Application-defined node family such as file, symbol, or backlog",
8716            },
8717            GraphDbSchemaField {
8718                name: "label",
8719                value_type: "string",
8720                description: "Human-readable label",
8721            },
8722            GraphDbSchemaField {
8723                name: "properties",
8724                value_type: "object<string,string>",
8725                description: "Adapter-specific string properties",
8726            },
8727            GraphDbSchemaField {
8728                name: "provenance",
8729                value_type: "array",
8730                description: "Source system and source reference metadata",
8731            },
8732            GraphDbSchemaField {
8733                name: "freshness",
8734                value_type: "object|null",
8735                description: "Optional content hash and observed timestamp",
8736            },
8737        ],
8738        edge_fields: vec![
8739            GraphDbSchemaField {
8740                name: "id",
8741                value_type: "string",
8742                description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
8743            },
8744            GraphDbSchemaField {
8745                name: "from_id",
8746                value_type: "string",
8747                description: "Source node id",
8748            },
8749            GraphDbSchemaField {
8750                name: "to_id",
8751                value_type: "string",
8752                description: "Target node id",
8753            },
8754            GraphDbSchemaField {
8755                name: "kind",
8756                value_type: "string",
8757                description: "Application-defined edge relation",
8758            },
8759            GraphDbSchemaField {
8760                name: "properties",
8761                value_type: "object<string,string>",
8762                description: "Adapter-specific string properties",
8763            },
8764            GraphDbSchemaField {
8765                name: "provenance",
8766                value_type: "array",
8767                description: "Source system and source reference metadata",
8768            },
8769            GraphDbSchemaField {
8770                name: "freshness",
8771                value_type: "object|null",
8772                description: "Optional content hash and observed timestamp",
8773            },
8774        ],
8775        operations: vec![
8776            GraphDbSchemaOperation {
8777                command: "refresh",
8778                description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
8779            },
8780            GraphDbSchemaOperation {
8781                command: "status",
8782                description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
8783            },
8784            GraphDbSchemaOperation {
8785                command: "doctor",
8786                description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
8787            },
8788            GraphDbSchemaOperation {
8789                command: "drift",
8790                description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
8791            },
8792            GraphDbSchemaOperation {
8793                command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
8794                description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
8795            },
8796            GraphDbSchemaOperation {
8797                command: "snapshot-export <output.db.gz> [--force]",
8798                description: "Export the current SQLite graph.db as a gzip-compressed shareable artifact only after freshness, doctor, WAL, and sidecar checks pass",
8799            },
8800            GraphDbSchemaOperation {
8801                command: "snapshot-import <artifact.db.gz> [--replace]",
8802                description: "Stage and validate a compressed SQLite graph.db artifact through doctor and freshness checks before replacing the local graph.db",
8803            },
8804            GraphDbSchemaOperation {
8805                command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
8806                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",
8807            },
8808            GraphDbSchemaOperation {
8809                command: "evidence <target> [--depth N] [--limit N]",
8810                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",
8811            },
8812            GraphDbSchemaOperation {
8813                command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
8814                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",
8815            },
8816            GraphDbSchemaOperation {
8817                command: "dispatch-trace [target...] --path <session> [--format json|html]",
8818                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",
8819            },
8820            GraphDbSchemaOperation {
8821                command: "dependency-dag [target...] --path <session>",
8822                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",
8823            },
8824            GraphDbSchemaOperation {
8825                command: "schema",
8826                description: "Return record and operation schemas",
8827            },
8828            GraphDbSchemaOperation {
8829                command: "node <id>",
8830                description: "Return one node by stable id",
8831            },
8832            GraphDbSchemaOperation {
8833                command: "edge <id>",
8834                description: "Return one edge by stable edge id",
8835            },
8836            GraphDbSchemaOperation {
8837                command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8838                description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
8839            },
8840            GraphDbSchemaOperation {
8841                command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8842                description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
8843            },
8844            GraphDbSchemaOperation {
8845                command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
8846                description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
8847            },
8848            GraphDbSchemaOperation {
8849                command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
8850                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",
8851            },
8852            GraphDbSchemaOperation {
8853                command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
8854                description: "Return the shortest directed path by node id, optionally bounded by hop count",
8855            },
8856        ],
8857    }
8858}
8859
8860pub(crate) fn sqlite_graph_freshness(
8861    store: &SqliteGraphStore,
8862    scope: &str,
8863) -> Result<GraphDbFreshnessReport> {
8864    let version = store.projection_version(scope)?;
8865    let Some(version) = version else {
8866        return Ok(GraphDbFreshnessReport {
8867            status: "missing".to_string(),
8868            fail_closed: true,
8869            projection_version: None,
8870            content_hash: None,
8871            source_watermark: None,
8872            diagnostics: vec![
8873                "graph projection metadata is missing; rebuild the graph before trusting reads"
8874                    .to_string(),
8875            ],
8876        });
8877    };
8878    let mut diagnostics = Vec::new();
8879    let fail_closed =
8880        version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
8881    if version.projection_version != GRAPH_PROJECTION_VERSION {
8882        diagnostics.push(format!(
8883            "projection version mismatch: expected {} got {}",
8884            GRAPH_PROJECTION_VERSION, version.projection_version
8885        ));
8886    }
8887    if version.content_hash.is_none() {
8888        diagnostics.push("projection content hash is missing".to_string());
8889    }
8890    Ok(GraphDbFreshnessReport {
8891        status: if fail_closed { "stale" } else { "current" }.to_string(),
8892        fail_closed,
8893        projection_version: Some(version.projection_version),
8894        content_hash: version.content_hash,
8895        source_watermark: version.source_watermark,
8896        diagnostics,
8897    })
8898}
8899
8900pub(crate) fn convex_graph_freshness(
8901    local: &ConvexProjectionRows,
8902    snapshot: &ConvexProjectionRows,
8903    scope: Option<&str>,
8904) -> GraphDbFreshnessReport {
8905    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
8906    GraphDbFreshnessReport {
8907        status: freshness.status,
8908        fail_closed: freshness.fail_closed,
8909        projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
8910        content_hash: freshness.snapshot_hash,
8911        source_watermark: None,
8912        diagnostics: freshness.diagnostics,
8913    }
8914}
8915
8916pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
8917    let (nodes, edges) = store.graph_counts()?;
8918    let files = store.file_count()?;
8919    Ok(GraphDbFreshnessReport {
8920        status: "current".to_string(),
8921        fail_closed: false,
8922        projection_version: Some("tokensave-readonly".to_string()),
8923        content_hash: None,
8924        source_watermark: Some(store.db_path().to_string_lossy().to_string()),
8925        diagnostics: vec![format!(
8926            "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
8927            nodes, edges, files
8928        )],
8929    })
8930}
8931
8932pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
8933    match TokensaveDb::discover(root) {
8934        Ok(Some(store)) => {
8935            report.push_check(GraphDbDoctorCheck {
8936                name: "tokensave_db_open".to_string(),
8937                status: "ok".to_string(),
8938                fail_closed: false,
8939                diagnostics: vec![format!(
8940                    "opened tokensave database at {}",
8941                    store.db_path().display()
8942                )],
8943                repair_commands: Vec::new(),
8944            });
8945            match (store.node_count(), store.edge_count(), store.file_count()) {
8946                (Ok(nodes), Ok(edges), Ok(files)) => {
8947                    report.push_check(GraphDbDoctorCheck {
8948                        name: "tokensave_counts".to_string(),
8949                        status: "ok".to_string(),
8950                        fail_closed: false,
8951                        diagnostics: vec![format!(
8952                            "tokensave contains {} node(s), {} edge(s), {} file(s)",
8953                            nodes, edges, files
8954                        )],
8955                        repair_commands: Vec::new(),
8956                    });
8957                }
8958                (nodes, edges, files) => {
8959                    report.push_check(graph_db_doctor_check(
8960                        "tokensave_counts",
8961                        vec![format!(
8962                            "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
8963                            nodes.err(),
8964                            edges.err(),
8965                            files.err()
8966                        )],
8967                        Vec::new(),
8968                    ));
8969                }
8970            }
8971        }
8972        Ok(None) => report.push_check(graph_db_doctor_check(
8973            "tokensave_db_exists",
8974            vec![format!(
8975                "tokensave database is missing at {}",
8976                root.join(".tokensave").join("tokensave.db").display()
8977            )],
8978            Vec::new(),
8979        )),
8980        Err(err) => report.push_check(graph_db_doctor_check(
8981            "tokensave_db_open",
8982            vec![err.to_string()],
8983            Vec::new(),
8984        )),
8985    }
8986}
8987
8988const GRAPH_DB_EVIDENCE_TARGET_KINDS: &[&str] = &[
8989    "backlog",
8990    "job_packet",
8991    "worker_result",
8992    "worker_context",
8993    "source_handle",
8994];
8995
8996pub(crate) fn graph_db_evidence_preferred_path(root: &Path, path_hint: &Path) -> Option<String> {
8997    hinted_markdown_file(root, path_hint).map(|path| {
8998        relativize_pathbuf(&path, root)
8999            .to_string_lossy()
9000            .replace('\\', "/")
9001    })
9002}
9003
9004fn graph_db_ambiguous_target_message(
9005    target: &str,
9006    kind: &str,
9007    candidates: &[SubstrateGraphNode],
9008) -> String {
9009    let mut by_path = BTreeMap::<String, String>::new();
9010    for candidate in candidates.iter().filter(|node| node.kind == kind) {
9011        let path = candidate
9012            .properties
9013            .get("path")
9014            .cloned()
9015            .unwrap_or_else(|| "<no path>".to_string());
9016        by_path.entry(path).or_insert_with(|| candidate.id.clone());
9017    }
9018    let examples = by_path
9019        .iter()
9020        .take(5)
9021        .map(|(path, node_id)| format!("{node_id} path={path}"))
9022        .collect::<Vec<_>>()
9023        .join(", ");
9024    format!(
9025        "graph-db evidence target {target} is ambiguous across {} {kind} node paths: {examples}; rerun with --path <agent-doc.md> or use an exact graph node id",
9026        by_path.len()
9027    )
9028}
9029
9030pub(crate) fn graph_db_resolve_evidence_target_with_path(
9031    store: &impl GraphStore,
9032    target: &str,
9033    preferred_path: Option<&str>,
9034) -> Result<Option<SubstrateGraphNode>> {
9035    if let Some(node) = store.node(target)? {
9036        return Ok(Some(node));
9037    }
9038    let candidates =
9039        store.evidence_target_candidates(target, GRAPH_DB_EVIDENCE_TARGET_KINDS, preferred_path)?;
9040    if candidates.is_empty() {
9041        return Ok(None);
9042    }
9043    if preferred_path.is_none() {
9044        let first_kind = candidates[0].kind.as_str();
9045        let distinct_paths = candidates
9046            .iter()
9047            .filter(|node| node.kind == first_kind)
9048            .map(|node| {
9049                node.properties
9050                    .get("path")
9051                    .map(String::as_str)
9052                    .unwrap_or("")
9053            })
9054            .collect::<BTreeSet<_>>();
9055        if distinct_paths.len() > 1 {
9056            bail!(
9057                "{}",
9058                graph_db_ambiguous_target_message(target, first_kind, &candidates)
9059            );
9060        }
9061    }
9062    Ok(candidates.into_iter().next())
9063}
9064
9065pub(crate) fn graph_db_resolve_evidence_target(
9066    store: &impl GraphStore,
9067    target: &str,
9068) -> Result<Option<SubstrateGraphNode>> {
9069    graph_db_resolve_evidence_target_with_path(store, target, None)
9070}
9071
9072fn graph_db_reachable_nodes_by_kind(
9073    store: &impl GraphStore,
9074    from_id: &str,
9075    kind: &str,
9076    depth: usize,
9077    limit: usize,
9078) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
9079    store.reachable_nodes_by_kind(from_id, kind, depth, limit)
9080}
9081
9082fn graph_db_evidence_completed_queue_drift_warnings(
9083    store: &impl GraphStore,
9084    target: &SubstrateGraphNode,
9085    worker_results: &[SubstrateGraphNode],
9086) -> Result<Vec<String>> {
9087    let ref_id = target.properties.get("ref_id").map(String::as_str);
9088    let has_completed_result = worker_results.iter().any(|node| {
9089        node.properties.get("status").map(String::as_str) == Some("completed")
9090            && node.properties.get("ref_id").map(String::as_str) == ref_id
9091    });
9092    if !has_completed_result {
9093        return Ok(Vec::new());
9094    }
9095    let active_jobs = store
9096        .nodes_by_kind("job_packet")?
9097        .into_iter()
9098        .filter(|node| {
9099            node.properties.get("ref_id").map(String::as_str) == ref_id
9100                && node.label.starts_with("do #")
9101        })
9102        .collect::<Vec<_>>();
9103    if active_jobs.is_empty() {
9104        return Ok(Vec::new());
9105    }
9106    let repair = match (target.properties.get("path"), ref_id) {
9107        (Some(path), Some(id)) => format!(
9108            "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9109            shell_quote(path),
9110            shell_quote(id),
9111            shell_quote(id)
9112        ),
9113        _ => {
9114            "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9115        }
9116    };
9117    Ok(vec![format!(
9118        "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",
9119        target.label,
9120        active_jobs.len()
9121    )])
9122}
9123
9124fn graph_db_evidence_next_commands(
9125    root: &Path,
9126    scope: Option<&str>,
9127    target: &SubstrateGraphNode,
9128    worker_context: &[SubstrateGraphNode],
9129    source_handles: &[SubstrateGraphNode],
9130    worker_results: &[SubstrateGraphNode],
9131    semantic_related: &[SubstrateGraphNode],
9132) -> Vec<String> {
9133    let mut commands = BTreeSet::new();
9134    if let Some(expand) = target.properties.get("expand") {
9135        commands.insert(expand.clone());
9136    }
9137    for worker in worker_context {
9138        if let Some(expand) = worker.properties.get("expand") {
9139            commands.insert(expand.clone());
9140        }
9141    }
9142    for source in source_handles {
9143        if let Some(expand) = source.properties.get("expand") {
9144            commands.insert(expand.clone());
9145        }
9146    }
9147    for result in worker_results {
9148        if let Some(expand) = result.properties.get("expand") {
9149            commands.insert(expand.clone());
9150        }
9151    }
9152    for semantic in semantic_related {
9153        if let Some(expand) = semantic.properties.get("expand") {
9154            commands.insert(expand.clone());
9155        }
9156    }
9157    commands.insert(format!(
9158        "tsift graph-db --path {}{} status --json",
9159        shell_quote(root.to_string_lossy().as_ref()),
9160        graph_db_scope_arg(scope)
9161    ));
9162    commands.insert(format!(
9163        "tsift graph-db --path {}{} doctor --json",
9164        shell_quote(root.to_string_lossy().as_ref()),
9165        graph_db_scope_arg(scope)
9166    ));
9167    commands.into_iter().collect()
9168}
9169
9170fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9171    vec![
9172        format!(
9173            "tsift graph-db --path {}{} refresh --json",
9174            shell_quote(root.to_string_lossy().as_ref()),
9175            graph_db_scope_arg(scope)
9176        ),
9177        format!(
9178            "tsift graph-db --path {}{} doctor --json",
9179            shell_quote(root.to_string_lossy().as_ref()),
9180            graph_db_scope_arg(scope)
9181        ),
9182    ]
9183}
9184
9185fn graph_db_evidence_replay_commands(
9186    root: &Path,
9187    scope: Option<&str>,
9188    target: &str,
9189    depth: usize,
9190    limit: usize,
9191) -> Vec<String> {
9192    vec![
9193        format!(
9194            "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9195            shell_quote(root.to_string_lossy().as_ref()),
9196            graph_db_scope_arg(scope),
9197            shell_quote(target),
9198            depth,
9199            limit
9200        ),
9201        format!(
9202            "tsift conflict-matrix --path {} {} --json",
9203            shell_quote(root.to_string_lossy().as_ref()),
9204            shell_quote(target)
9205        ),
9206    ]
9207}
9208
9209fn graph_db_evidence_packet_id(
9210    target: &str,
9211    target_node: &SubstrateGraphNode,
9212    freshness: &GraphDbFreshnessReport,
9213) -> String {
9214    stable_handle(
9215        "gevd",
9216        &format!(
9217            "{}:{}:{}:{}",
9218            GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9219            target,
9220            target_node.id,
9221            freshness.content_hash.as_deref().unwrap_or("no-hash")
9222        ),
9223    )
9224}
9225
9226pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9227    input: GraphDbEvidenceInput<'_, S>,
9228) -> Result<GraphDbEvidenceReport> {
9229    let GraphDbEvidenceInput {
9230        root,
9231        scope,
9232        backend,
9233        target,
9234        preferred_path,
9235        depth,
9236        limit,
9237        cursor,
9238        store,
9239        freshness,
9240        mut warnings,
9241    } = input;
9242    let repair_commands = graph_db_repair_commands(root, scope);
9243    if freshness.fail_closed {
9244        bail!(
9245            "graph database evidence failed closed for {} backend: {}; repair: {}",
9246            backend,
9247            freshness.diagnostics.join("; "),
9248            repair_commands.join("; ")
9249        );
9250    }
9251    let semantic_readiness =
9252        graph_db_semantic_readiness(root, scope, graph_store_semantic_node_count(store).ok());
9253    if semantic_readiness.fail_closed {
9254        warnings.push(format!(
9255            "graph evidence semantic readiness blocked: {} — {}",
9256            semantic_readiness.reason,
9257            semantic_readiness.diagnostics.join("; ")
9258        ));
9259        warnings.push(format!(
9260            "repair: {}",
9261            semantic_readiness.next_commands.join("; then ")
9262        ));
9263    }
9264    let target_node = graph_db_resolve_evidence_target_with_path(store, target, preferred_path)?
9265        .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9266    let max_rows = if limit == 0 { usize::MAX } else { limit };
9267    let mut reachable = store.reachable_nodes_by_kinds(
9268        &target_node.id,
9269        &[
9270            "worker_context",
9271            "source_handle",
9272            "worker_result",
9273            "semantic_concept",
9274            "semantic_entity",
9275        ],
9276        depth,
9277        max_rows,
9278    )?;
9279    let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9280    let source_paths = reachable.remove("source_handle").unwrap_or_default();
9281    let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9282    let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9283    semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9284    semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9285        left_path
9286            .hops
9287            .cmp(&right_path.hops)
9288            .then(left_node.kind.cmp(&right_node.kind))
9289            .then(left_node.label.cmp(&right_node.label))
9290            .then(left_node.id.cmp(&right_node.id))
9291    });
9292    if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9293        semantic_paths.truncate(max_rows);
9294    }
9295
9296    let evidence_nodes = worker_paths
9297        .iter()
9298        .chain(source_paths.iter())
9299        .chain(worker_result_paths.iter())
9300        .chain(semantic_paths.iter())
9301        .map(|(node, _)| node.clone())
9302        .collect::<Vec<_>>();
9303    let evidence_depth_by_id = worker_paths
9304        .iter()
9305        .chain(source_paths.iter())
9306        .chain(worker_result_paths.iter())
9307        .chain(semantic_paths.iter())
9308        .map(|(node, path)| (node.id.clone(), path.hops))
9309        .collect::<BTreeMap<_, _>>();
9310    let target_query = graph_db_node_search_text(&target_node);
9311    let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9312    let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9313        std::slice::from_ref(&target_node.id),
9314        &semantic_scores,
9315        evidence_nodes,
9316        Vec::new(),
9317        Some(limit),
9318        Some(&evidence_depth_by_id),
9319        cursor,
9320    );
9321    let output_budget = budgeted.report;
9322    let truncated = budgeted.truncated;
9323    let next_cursor = budgeted.next_cursor;
9324    let retained_evidence_ids = budgeted
9325        .nodes
9326        .iter()
9327        .map(|node| node.id.as_str())
9328        .collect::<BTreeSet<_>>();
9329    let worker_context = worker_paths
9330        .iter()
9331        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9332        .map(|(node, _)| node.clone())
9333        .collect::<Vec<_>>();
9334    let source_handles = source_paths
9335        .iter()
9336        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9337        .map(|(node, _)| node.clone())
9338        .collect::<Vec<_>>();
9339    let worker_results = worker_result_paths
9340        .iter()
9341        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9342        .map(|(node, _)| node.clone())
9343        .collect::<Vec<_>>();
9344    let semantic_related = semantic_paths
9345        .iter()
9346        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9347        .map(|(node, _)| node.clone())
9348        .collect::<Vec<_>>();
9349    warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
9350        store,
9351        &target_node,
9352        &worker_results,
9353    )?);
9354    if worker_context.is_empty()
9355        && source_handles.is_empty()
9356        && worker_results.is_empty()
9357        && semantic_related.is_empty()
9358    {
9359        warnings.push(format!(
9360            "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",
9361            target, target_node.kind
9362        ));
9363    }
9364    let shortest_paths = worker_paths
9365        .iter()
9366        .chain(source_paths.iter())
9367        .chain(worker_result_paths.iter())
9368        .chain(semantic_paths.iter())
9369        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9370        .map(|(node, path)| GraphDbEvidencePath {
9371            to: node.id.clone(),
9372            kind: node.kind.clone(),
9373            label: node.label.clone(),
9374            path: Some(path.clone()),
9375            expand: node.properties.get("expand").cloned(),
9376        })
9377        .collect::<Vec<_>>();
9378    let next_commands = graph_db_evidence_next_commands(
9379        root,
9380        scope,
9381        &target_node,
9382        &worker_context,
9383        &source_handles,
9384        &worker_results,
9385        &semantic_related,
9386    );
9387    let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
9388    let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
9389    let projection_hash = freshness.content_hash.clone();
9390
9391    Ok(GraphDbEvidenceReport {
9392        root: root.to_string_lossy().to_string(),
9393        scope: scope.map(str::to_string),
9394        backend: backend.to_string(),
9395        contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
9396        target: target.to_string(),
9397        packet_id,
9398        projection_hash,
9399        freshness,
9400        target_node: target_node.into(),
9401        worker_context: worker_context.into_iter().map(Into::into).collect(),
9402        source_handles: source_handles.into_iter().map(Into::into).collect(),
9403        worker_results: worker_results.into_iter().map(Into::into).collect(),
9404        semantic_related: semantic_related.into_iter().map(Into::into).collect(),
9405        shortest_paths,
9406        output_budget: Some(output_budget),
9407        truncated,
9408        next_cursor,
9409        next_commands,
9410        replay_commands,
9411        repair_commands,
9412        fixture_coverage: GraphDbFixtureCoverage {
9413            test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
9414                .to_string(),
9415            fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
9416            assertions: vec![
9417                "backlog id and job packet handle resolve to graph nodes".to_string(),
9418                "worker_context rows are reachable from queued work".to_string(),
9419                "source_handle rows are reachable through bounded shortest paths".to_string(),
9420                "worker_result rows are reachable from completed or blocked work".to_string(),
9421            ],
9422        },
9423        warnings,
9424    })
9425}
9426
9427fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
9428    println!(
9429        "graph-db evidence backend: {} target: {} [{}] packet:{}",
9430        report.backend, report.target_node.id, report.target_node.kind, report.packet_id
9431    );
9432    let page_info = if report.truncated {
9433        let cursor = report.next_cursor.as_deref().unwrap_or("?");
9434        format!(" (truncated, next_cursor: {cursor})")
9435    } else {
9436        String::new()
9437    };
9438    println!(
9439        "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
9440        report.worker_context.len(),
9441        report.source_handles.len(),
9442        report.worker_results.len(),
9443        report.semantic_related.len(),
9444        report.shortest_paths.len()
9445    );
9446    for path in &report.shortest_paths {
9447        if let Some(graph_path) = &path.path {
9448            println!(
9449                "path: {} hop(s) {}",
9450                graph_path.hops,
9451                graph_path.nodes.join(" -> ")
9452            );
9453        }
9454    }
9455    for command in &report.next_commands {
9456        println!("next: {command}");
9457    }
9458    for warning in &report.warnings {
9459        println!("warning: {warning}");
9460    }
9461}
9462
9463pub(crate) fn print_graph_db_evidence_report(
9464    report: &GraphDbEvidenceReport,
9465    format: OutputFormat,
9466) -> Result<()> {
9467    if format.json_output {
9468        let page_info = if report.truncated {
9469            let cursor = report.next_cursor.as_deref().unwrap_or("?");
9470            format!(" (truncated, next_cursor: {cursor})")
9471        } else {
9472            String::new()
9473        };
9474        print_json_or_envelope(
9475            report,
9476            &format,
9477            "graph-db",
9478            "evidence",
9479            ToolEnvelopeSummary {
9480                text: format!(
9481                    "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}",
9482                    report.target,
9483                    report.worker_context.len(),
9484                    report.source_handles.len(),
9485                    report.worker_results.len(),
9486                    report.semantic_related.len(),
9487                    report.shortest_paths.len()
9488                ),
9489                metrics: vec![
9490                    envelope_metric("backend", &report.backend),
9491                    envelope_metric("worker_context", report.worker_context.len()),
9492                    envelope_metric("source_handles", report.source_handles.len()),
9493                    envelope_metric("worker_results", report.worker_results.len()),
9494                    envelope_metric("semantic_related", report.semantic_related.len()),
9495                    envelope_metric("paths", report.shortest_paths.len()),
9496                ],
9497            },
9498            report.truncated,
9499            report.next_commands.clone(),
9500        )
9501    } else {
9502        print_graph_db_evidence_human(report);
9503        Ok(())
9504    }
9505}
9506
9507pub(crate) fn graph_db_report_from_store(
9508    root: &Path,
9509    scope: Option<&str>,
9510    backend: &str,
9511    query: GraphDbQuery,
9512    store: &impl GraphStore,
9513    freshness: GraphDbFreshnessReport,
9514    warnings: Vec<String>,
9515) -> Result<GraphDbReport> {
9516    if freshness.fail_closed {
9517        bail!(
9518            "graph database read failed closed for {} backend: {}",
9519            backend,
9520            freshness.diagnostics.join("; ")
9521        );
9522    }
9523    let mut report = GraphDbReport {
9524        root: root.to_string_lossy().to_string(),
9525        scope: scope.map(str::to_string),
9526        backend: backend.to_string(),
9527        query: format!("{query:?}"),
9528        freshness,
9529        readiness: None,
9530        schema: None,
9531        node: None,
9532        edge: None,
9533        nodes: Vec::new(),
9534        edges: Vec::new(),
9535        ranked_neighbors: Vec::new(),
9536        semantic_related: Vec::new(),
9537        neighborhood_ranking_gate: None,
9538        ranked_neighborhood_comparison: None,
9539        knowledge_retrieval: None,
9540        output_budget: None,
9541        path: None,
9542        page: None,
9543        warnings,
9544    };
9545
9546    match query {
9547        GraphDbQuery::Refresh => {
9548            bail!("graph-db refresh must be handled by the refresh command path");
9549        }
9550        GraphDbQuery::Status => {
9551            bail!("graph-db status must be handled by the status command path");
9552        }
9553        GraphDbQuery::Doctor => {
9554            bail!("graph-db doctor must be handled by the doctor command path");
9555        }
9556        GraphDbQuery::Drift => {
9557            bail!("graph-db drift must be handled by the drift command path");
9558        }
9559        GraphDbQuery::Compact { .. } => {
9560            bail!("graph-db compact must be handled by the compact command path");
9561        }
9562        GraphDbQuery::SnapshotExport { .. } => {
9563            bail!("graph-db snapshot-export must be handled by the snapshot command path");
9564        }
9565        GraphDbQuery::SnapshotImport { .. } => {
9566            bail!("graph-db snapshot-import must be handled by the snapshot command path");
9567        }
9568        GraphDbQuery::BackendEval { .. } => {
9569            bail!("graph-db backend-eval must be handled by the benchmark command path");
9570        }
9571        GraphDbQuery::Evidence { .. } => {
9572            bail!("graph-db evidence must be handled by the evidence command path");
9573        }
9574        GraphDbQuery::Related {
9575            query,
9576            kind,
9577            depth,
9578            seed_limit,
9579            limit,
9580        } => {
9581            let semantic =
9582                semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
9583            let SemanticRelatedReport {
9584                items,
9585                warnings: semantic_warnings,
9586                ..
9587            } = semantic;
9588            let readiness = graph_db_semantic_readiness(
9589                root,
9590                scope,
9591                (!items.is_empty()).then_some(items.len()),
9592            );
9593            report.warnings.extend(semantic_warnings);
9594            let seed_ids = items
9595                .iter()
9596                .map(|item| item.handle.clone())
9597                .collect::<Vec<_>>();
9598            let semantic_scores = items
9599                .iter()
9600                .map(|item| (item.handle.clone(), item.score))
9601                .collect::<BTreeMap<_, _>>();
9602            let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
9603            let seed_count = seed_ids.len();
9604            let mut diagnostics = subgraph.diagnostics;
9605            let budgeted = graph_db_apply_output_budget(
9606                &seed_ids,
9607                &semantic_scores,
9608                subgraph.nodes,
9609                subgraph.edges,
9610                Some(limit),
9611            );
9612            let budget_report = budgeted.report;
9613            let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
9614            diagnostics.extend(budget_report.diagnostics.clone());
9615            diagnostics.extend(readiness.diagnostics.clone());
9616
9617            report.readiness = Some(readiness);
9618            report.semantic_related = items;
9619            if let Some(seed_id) = seed_ids.first() {
9620                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
9621                report.ranked_neighbors = graph_db_ranked_neighbors(
9622                    seed_id,
9623                    &budgeted.nodes,
9624                    &budgeted.edges,
9625                    ranked_neighbor_cap,
9626                );
9627                report.neighborhood_ranking_gate =
9628                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9629            }
9630            report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9631            report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9632            report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
9633                mode: "semantic_seeded_neighborhood".to_string(),
9634                query,
9635                seed_kind: semantic_related_kind_name(kind).to_string(),
9636                seed_limit,
9637                seed_count,
9638                depth,
9639                limit,
9640                node_count: report.nodes.len(),
9641                edge_count: report.edges.len(),
9642                truncated: subgraph.truncated || dropped_by_budget,
9643                traversal: "incident_plus_outgoing_edges".to_string(),
9644                freshness_boundary:
9645                    "semantic rows must come from refreshed summary or tsift-memory graph records"
9646                        .to_string(),
9647                privacy_boundary:
9648                    "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
9649                        .to_string(),
9650                diagnostics,
9651            });
9652            report.output_budget = Some(budget_report);
9653        }
9654        GraphDbQuery::Schema => {
9655            report.schema = Some(graph_db_schema());
9656        }
9657        GraphDbQuery::Node { id } => {
9658            report.node = store.node(&id)?.map(Into::into);
9659        }
9660        GraphDbQuery::Edge { id } => {
9661            report.edge = store.edge(&id)?.map(Into::into);
9662        }
9663        GraphDbQuery::Edges {
9664            edge_kind,
9665            cursor,
9666            limit,
9667            property_filters,
9668        } => {
9669            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9670            let paged = store.paged_edges(
9671                edge_kind.as_deref(),
9672                graph_db_query_options_for_store(&options),
9673            )?;
9674            report.edges = paged.edges.into_iter().map(Into::into).collect();
9675            report.page = Some(graph_db_page_report_from_store(
9676                paged.page,
9677                options.property_filters,
9678            ));
9679        }
9680        GraphDbQuery::Incident {
9681            id,
9682            edge_kind,
9683            cursor,
9684            limit,
9685            property_filters,
9686        } => {
9687            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9688            let paged = store.paged_incident_edges(
9689                &id,
9690                edge_kind.as_deref(),
9691                graph_db_query_options_for_store(&options),
9692            )?;
9693            report.edges = paged.edges.into_iter().map(Into::into).collect();
9694            report.page = Some(graph_db_page_report_from_store(
9695                paged.page,
9696                options.property_filters,
9697            ));
9698        }
9699        GraphDbQuery::Kind {
9700            kind,
9701            cursor,
9702            limit,
9703            property_filters,
9704        } => {
9705            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9706            let paged =
9707                store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
9708            report.nodes = paged.nodes.into_iter().map(Into::into).collect();
9709            report.edges = paged.edges.into_iter().map(Into::into).collect();
9710            report.page = Some(graph_db_page_report_from_store(
9711                paged.page,
9712                options.property_filters,
9713            ));
9714        }
9715        GraphDbQuery::Neighborhood {
9716            id,
9717            depth,
9718            edge_kind,
9719            cursor,
9720            limit,
9721            property_filters,
9722        } => {
9723            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9724            if let Some(paged) = store.paged_neighborhood(
9725                &id,
9726                depth,
9727                edge_kind.as_deref(),
9728                graph_db_query_options_for_store(&options),
9729            )? {
9730                let budgeted = graph_db_apply_output_budget(
9731                    std::slice::from_ref(&id),
9732                    &BTreeMap::new(),
9733                    paged.nodes,
9734                    paged.edges,
9735                    options.limit,
9736                );
9737                let budget_report = budgeted.report;
9738                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
9739                let ranked_neighbors = graph_db_ranked_neighbors(
9740                    &id,
9741                    &budgeted.nodes,
9742                    &budgeted.edges,
9743                    ranked_neighbor_cap,
9744                );
9745                let comparison = graph_db_ranked_neighborhood_comparison(
9746                    &id,
9747                    depth,
9748                    edge_kind.as_deref(),
9749                    options.limit,
9750                    &budgeted.nodes,
9751                    &budgeted.edges,
9752                    store,
9753                )?;
9754                report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9755                report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9756                report.ranked_neighbors = ranked_neighbors;
9757                report.neighborhood_ranking_gate =
9758                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9759                let mut page =
9760                    graph_db_page_report_from_store(paged.page, options.property_filters);
9761                page.returned_nodes = report.nodes.len();
9762                page.returned_edges = report.edges.len();
9763                page.truncated |= !budget_report.dropped_by_budget.is_empty();
9764                page.diagnostics.extend(budget_report.diagnostics.clone());
9765                report.page = Some(page);
9766                report.output_budget = Some(budget_report);
9767                if let Some(comparison) = comparison {
9768                    report.ranked_neighborhood_comparison = Some(comparison);
9769                }
9770            }
9771        }
9772        GraphDbQuery::Path {
9773            from,
9774            to,
9775            edge_kind,
9776            max_hops,
9777        } => {
9778            report.path =
9779                store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
9780            if let Some(max_hops) = max_hops
9781                && report.path.is_none()
9782            {
9783                report.warnings.push(format!(
9784                    "no directed path found within --max-hops {}",
9785                    max_hops
9786                ));
9787            }
9788        }
9789        GraphDbQuery::Map { .. } => {
9790            bail!("graph-db map must be handled by the map command path");
9791        }
9792    }
9793    Ok(report)
9794}
9795
9796pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
9797    if compact {
9798        println!(
9799            "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
9800            report.backend,
9801            report.query,
9802            report.nodes.len() + usize::from(report.node.is_some()),
9803            report.edges.len() + usize::from(report.edge.is_some()),
9804            report.freshness.status
9805        );
9806        return;
9807    }
9808    println!("graph-db backend: {}", report.backend);
9809    println!("freshness: {}", report.freshness.status);
9810    if let Some(readiness) = &report.readiness {
9811        println!(
9812            "readiness: {} reason: {} fail_closed: {}",
9813            readiness.status, readiness.reason, readiness.fail_closed
9814        );
9815        for diagnostic in &readiness.diagnostics {
9816            println!("readiness diagnostic: {diagnostic}");
9817        }
9818        for command in &readiness.next_commands {
9819            println!("readiness next: {command}");
9820        }
9821    }
9822    if let Some(schema) = &report.schema {
9823        println!(
9824            "schema: {} node fields, {} edge fields, {} operations",
9825            schema.node_fields.len(),
9826            schema.edge_fields.len(),
9827            schema.operations.len()
9828        );
9829    }
9830    if let Some(node) = &report.node {
9831        println!("node: {} [{}] {}", node.id, node.kind, node.label);
9832    }
9833    if let Some(edge) = &report.edge {
9834        let edge_full: SubstrateGraphEdge = edge.into();
9835        println!(
9836            "edge: {} {} -{}-> {}",
9837            graph_db_edge_key(&edge_full),
9838            edge.from_id,
9839            edge.kind,
9840            edge.to_id
9841        );
9842    }
9843    if let Some(knowledge) = &report.knowledge_retrieval {
9844        println!(
9845            "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
9846            knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
9847        );
9848    }
9849    for item in &report.semantic_related {
9850        println!(
9851            "semantic_seed: {:.3} [{}] {} ({})",
9852            item.score, item.kind, item.label, item.handle
9853        );
9854    }
9855    for node in &report.nodes {
9856        println!("node: {} [{}] {}", node.id, node.kind, node.label);
9857    }
9858    for edge in &report.edges {
9859        let edge_full: SubstrateGraphEdge = edge.into();
9860        println!(
9861            "edge: {} {} -{}-> {}",
9862            graph_db_edge_key(&edge_full),
9863            edge.from_id,
9864            edge.kind,
9865            edge.to_id
9866        );
9867    }
9868    for neighbor in &report.ranked_neighbors {
9869        println!(
9870            "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
9871            neighbor.rank,
9872            neighbor.score,
9873            neighbor
9874                .depth
9875                .map(|depth| depth.to_string())
9876                .unwrap_or_else(|| "unknown".to_string()),
9877            neighbor.node_id,
9878            neighbor.kind,
9879            neighbor.label
9880        );
9881    }
9882    if let Some(gate) = &report.neighborhood_ranking_gate {
9883        println!(
9884            "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
9885            gate.status, gate.default_order, gate.ranked_output_default
9886        );
9887    }
9888    if let Some(path) = &report.path {
9889        println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
9890    }
9891    if let Some(page) = &report.page {
9892        if let Some(next_cursor) = &page.next_cursor {
9893            println!("next_cursor: {next_cursor}");
9894        }
9895        for diagnostic in &page.diagnostics {
9896            println!("page: {diagnostic}");
9897        }
9898    }
9899    for warning in &report.warnings {
9900        println!("warning: {warning}");
9901    }
9902}
9903
9904pub(crate) fn graph_db_backend_eval_phase_timing(
9905    name: &str,
9906    duration_micros: u128,
9907    detail: &str,
9908) -> GraphDbBackendEvalPhaseTiming {
9909    GraphDbBackendEvalPhaseTiming {
9910        name: name.to_string(),
9911        duration_micros,
9912        detail: detail.to_string(),
9913    }
9914}
9915
9916pub(crate) fn graph_db_backend_eval_timed_phase<T>(
9917    phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
9918    name: &str,
9919    detail: &str,
9920    run: impl FnOnce() -> Result<T>,
9921) -> Result<T> {
9922    let started = Instant::now();
9923    let result = run();
9924    phases.push(graph_db_backend_eval_phase_timing(
9925        name,
9926        started.elapsed().as_micros(),
9927        detail,
9928    ));
9929    result
9930}
9931
9932pub(crate) fn graph_db_backend_eval_refresh_total_micros(
9933    phases: &[GraphDbBackendEvalPhaseTiming],
9934) -> u128 {
9935    phases
9936        .iter()
9937        .filter(|phase| phase.name != "conflict_matrix_preparation")
9938        .map(|phase| phase.duration_micros)
9939        .sum()
9940}
9941
9942pub(crate) fn graph_db_backend_eval_cached_refresh(
9943    root: &Path,
9944    scope: Option<&str>,
9945    source_watermark: Option<&str>,
9946) -> Result<
9947    Option<(
9948        TraversalGraphBuild,
9949        SqliteProjectionRefresh,
9950        Vec<GraphDbBackendEvalPhaseTiming>,
9951    )>,
9952> {
9953    let Some(source_watermark) = source_watermark else {
9954        return Ok(None);
9955    };
9956    let graph_db = graph_substrate_db_path(root, scope);
9957    if !graph_db.exists() {
9958        return Ok(None);
9959    }
9960
9961    let started = Instant::now();
9962    let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
9963        Ok(store) => store,
9964        Err(_) => return Ok(None),
9965    };
9966    if store.has_user_triggers().unwrap_or(true) {
9967        return Ok(None);
9968    }
9969    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
9970    if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
9971        return Ok(None);
9972    }
9973
9974    let phases = vec![
9975        graph_db_backend_eval_phase_timing(
9976            "source_graph_build",
9977            started.elapsed().as_micros(),
9978            "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
9979        ),
9980        graph_db_backend_eval_phase_timing(
9981            "projection_rows",
9982            0,
9983            "reused cached provider-neutral projection rows from graph.db",
9984        ),
9985        graph_db_backend_eval_phase_timing(
9986            "sqlite_open",
9987            0,
9988            "reused existing graph.db projection without opening a write transaction",
9989        ),
9990    ];
9991    let refresh = SqliteProjectionRefresh {
9992        scope: scope.unwrap_or("root").to_string(),
9993        projection_version: freshness
9994            .projection_version
9995            .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
9996        source_watermark: Some(source_watermark.to_string()),
9997        tombstoned_nodes: Vec::new(),
9998        tombstoned_edges: Vec::new(),
9999        upserted_nodes: 0,
10000        upserted_edges: 0,
10001        unchanged_nodes: 0,
10002        unchanged_edges: 0,
10003        upserted_properties: 0,
10004        unchanged_properties: 0,
10005        deleted_properties: 0,
10006        deleted_nodes: 0,
10007        deleted_edges: 0,
10008        pruned_tombstones: 0,
10009        file_size_bytes_before: None,
10010        file_size_bytes_after: None,
10011        phase_timings: Vec::new(),
10012    };
10013    Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
10014}
10015
10016pub(crate) fn graph_db_backend_eval_reused_cached_projection(
10017    phases: &[GraphDbBackendEvalPhaseTiming],
10018) -> bool {
10019    phases.iter().any(|phase| {
10020        phase.name == "source_graph_build"
10021            && phase.detail.contains("reused current graph.db projection")
10022    })
10023}
10024
10025pub(crate) fn graph_db_backend_eval_update_source_watermark(
10026    root: &Path,
10027    path_hint: &Path,
10028    scope: Option<&str>,
10029) -> Result<()> {
10030    let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
10031        return Ok(());
10032    };
10033    let graph_db = graph_substrate_db_path(root, scope);
10034    let mut store = SqliteGraphStore::open(&graph_db)?;
10035    store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
10036    Ok(())
10037}
10038
10039pub(crate) fn graph_db_backend_eval_refresh_with_profile(
10040    root: &Path,
10041    path_hint: &Path,
10042    scope: Option<&str>,
10043) -> Result<(
10044    TraversalGraphBuild,
10045    SqliteProjectionRefresh,
10046    Vec<GraphDbBackendEvalPhaseTiming>,
10047)> {
10048    let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
10049    if let Some(cached) =
10050        graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
10051    {
10052        return Ok(cached);
10053    }
10054
10055    let mut phases = Vec::new();
10056    let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
10057        "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"
10058    } else {
10059        "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
10060    };
10061    let source_graph = graph_db_backend_eval_timed_phase(
10062        &mut phases,
10063        "source_graph_build",
10064        source_graph_detail,
10065        || build_traversal_graph_source_with_options(root, path_hint, scope, false),
10066    )?;
10067    let projection = graph_db_backend_eval_timed_phase(
10068        &mut phases,
10069        "projection_rows",
10070        "provider-neutral GraphStore node/edge row construction before SQLite persistence",
10071        || traversal_projection_from_graph(root, scope, &source_graph),
10072    )?;
10073    let graph_db = graph_substrate_db_path(root, scope);
10074    let mut store = graph_db_backend_eval_timed_phase(
10075        &mut phases,
10076        "sqlite_open",
10077        "open the local SQLite graph.db with WAL and busy-timeout settings",
10078        || SqliteGraphStore::open(&graph_db),
10079    )?;
10080    let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
10081        .ok()
10082        .flatten();
10083    let refresh = store.replace_projection_with_version(
10084        scope.unwrap_or("root"),
10085        &projection,
10086        Some(GRAPH_PROJECTION_VERSION),
10087        refreshed_source_watermark
10088            .or(source_watermark)
10089            .or_else(|| graph_projection_content_hash(&projection)),
10090    )?;
10091    phases.extend(
10092        refresh
10093            .phase_timings
10094            .iter()
10095            .map(|phase| GraphDbBackendEvalPhaseTiming {
10096                name: phase.name.clone(),
10097                duration_micros: phase.duration_micros,
10098                detail: phase.detail.clone(),
10099            }),
10100    );
10101    Ok((source_graph, refresh, phases))
10102}
10103
10104fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10105    root.join(".tsift/backend-eval-cache")
10106}
10107
10108fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10109    graph_db_backend_eval_disk_cache_dir(root)
10110        .join(kind)
10111        .join(format!("{key}.json.gz"))
10112}
10113
10114fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10115    graph_db_backend_eval_disk_cache_dir(root)
10116        .join(kind)
10117        .join(format!("{key}.json"))
10118}
10119
10120#[derive(Default, Clone)]
10121struct GraphDbBackendEvalDiskCacheReadProfile {
10122    file_read_micros: u128,
10123    gzip_decode_micros: u128,
10124    serde_decode_micros: u128,
10125    legacy: bool,
10126}
10127
10128fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10129    root: &Path,
10130    kind: &str,
10131    key: &str,
10132) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10133    let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10134    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10135    let read_started = Instant::now();
10136    let read_result = fs::read(&path);
10137    profile.file_read_micros = read_started.elapsed().as_micros();
10138    if let Ok(bytes) = read_result {
10139        let decode_started = Instant::now();
10140        let mut decoder = GzDecoder::new(bytes.as_slice());
10141        let mut decoded = Vec::new();
10142        let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10143        profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10144        if decode_ok {
10145            let serde_started = Instant::now();
10146            let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10147            profile.serde_decode_micros = serde_started.elapsed().as_micros();
10148            if let Some(value) = parsed {
10149                return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10150            }
10151        }
10152    }
10153
10154    let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10155    let legacy_started = Instant::now();
10156    let bytes = fs::read(legacy_path).ok()?;
10157    profile.file_read_micros = profile
10158        .file_read_micros
10159        .saturating_add(legacy_started.elapsed().as_micros());
10160    let serde_started = Instant::now();
10161    let value = serde_json::from_slice(&bytes).ok()?;
10162    profile.serde_decode_micros = profile
10163        .serde_decode_micros
10164        .saturating_add(serde_started.elapsed().as_micros());
10165    profile.legacy = true;
10166    Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10167}
10168
10169#[derive(Default, Clone)]
10170struct GraphDbBackendEvalDiskCacheWriteProfile {
10171    serde_encode_micros: u128,
10172    gzip_encode_micros: u128,
10173    file_write_micros: u128,
10174}
10175
10176fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10177    root: &Path,
10178    kind: &str,
10179    key: &str,
10180    value: &T,
10181) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10182    let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10183    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10184    let parent = path.parent()?;
10185    if fs::create_dir_all(parent).is_err() {
10186        return None;
10187    }
10188    let serde_started = Instant::now();
10189    let bytes = serde_json::to_vec(value).ok()?;
10190    profile.serde_encode_micros = serde_started.elapsed().as_micros();
10191    let gzip_started = Instant::now();
10192    let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10193    if encoder.write_all(&bytes).is_err() {
10194        return None;
10195    }
10196    let encoded = encoder.finish().ok()?;
10197    profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10198    let write_started = Instant::now();
10199    if fs::write(&path, &encoded).is_err() {
10200        return None;
10201    }
10202    profile.file_write_micros = write_started.elapsed().as_micros();
10203    Some((encoded.len() as u64, bytes.len() as u64, profile))
10204}
10205
10206fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10207    let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10208    let Ok(entries) = fs::read_dir(dir) else {
10209        return (0, 0);
10210    };
10211    let keep_name = format!("{keep_key}.json.gz");
10212    let mut pruned_files = 0usize;
10213    let mut pruned_bytes = 0u64;
10214    for entry in entries.flatten() {
10215        let path = entry.path();
10216        if !path.is_file() {
10217            continue;
10218        }
10219        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10220            continue;
10221        };
10222        if name == keep_name {
10223            continue;
10224        }
10225        let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10226        if !is_backend_eval_cache {
10227            continue;
10228        }
10229        let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10230        if fs::remove_file(&path).is_ok() {
10231            pruned_files += 1;
10232            pruned_bytes += bytes;
10233        }
10234    }
10235    (pruned_files, pruned_bytes)
10236}
10237
10238fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10239    root: &Path,
10240    source_root: &Path,
10241) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10242    let mut rows = Vec::new();
10243    let mut entries = walk::walk_files(source_root)?;
10244    entries.sort_by(|left, right| left.path.cmp(&right.path));
10245    for entry in entries {
10246        if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10247            continue;
10248        }
10249        if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10250            continue;
10251        }
10252        let bytes = fs::read(&entry.path)
10253            .with_context(|| format!("reading source input {}", entry.path.display()))?;
10254        rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10255            path: traversal_watermark_path(root, &entry.path),
10256            bytes: bytes.len() as u64,
10257            content_hash: content_hash(&bytes)?,
10258        });
10259    }
10260    Ok(rows)
10261}
10262
10263fn graph_db_backend_eval_full_projection_source_watermark(
10264    root: &Path,
10265    scope: Option<&str>,
10266) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10267    let path_hint = root;
10268    let mut detail_parts = Vec::new();
10269    let mut parts = vec![
10270        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10271        format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10272        "watermark_kind:stable_full_projection_inputs".to_string(),
10273        format!("scope:{}", scope.unwrap_or("root")),
10274        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10275    ];
10276
10277    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10278    match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10279        Some(db_path) => {
10280            let db = index::IndexDb::open_read_only_resilient(db_path)?;
10281            parts.push("index_mode:indexed".to_string());
10282            detail_parts.push("mode=indexed".to_string());
10283            parts.push(format!(
10284                "index_source_root:{}",
10285                traversal_watermark_path(root, &gate.source_root)
10286            ));
10287
10288            let symbols = db
10289                .all_symbols()?
10290                .into_iter()
10291                .filter(|symbol| {
10292                    !traversal_path_is_generated_artifact(
10293                        root,
10294                        &gate.source_root,
10295                        Path::new(&symbol.file),
10296                    ) && !traversal_path_is_session_markdown(
10297                        root,
10298                        &gate.source_root,
10299                        Path::new(&symbol.file),
10300                    )
10301                })
10302                .collect::<Vec<_>>();
10303            let symbols_hash = content_hash(&symbols)?;
10304            detail_parts.push(format!("symbols={symbols_hash}"));
10305            parts.push(format!("index_symbols:{symbols_hash}"));
10306
10307            let edges = db
10308                .all_stored_edges()?
10309                .into_iter()
10310                .filter(|edge| {
10311                    !traversal_path_is_generated_artifact(
10312                        root,
10313                        &gate.source_root,
10314                        Path::new(&edge.caller_file),
10315                    ) && !traversal_path_is_session_markdown(
10316                        root,
10317                        &gate.source_root,
10318                        Path::new(&edge.caller_file),
10319                    )
10320                })
10321                .collect::<Vec<_>>();
10322            let edges_hash = content_hash(&edges)?;
10323            detail_parts.push(format!("call_edges={edges_hash}"));
10324            parts.push(format!("index_call_edges:{edges_hash}"));
10325
10326            let routes = db
10327                .all_routes()?
10328                .into_iter()
10329                .filter(|route| {
10330                    !traversal_path_is_generated_artifact(
10331                        root,
10332                        &gate.source_root,
10333                        Path::new(&route.file),
10334                    ) && !traversal_path_is_session_markdown(
10335                        root,
10336                        &gate.source_root,
10337                        Path::new(&route.file),
10338                    )
10339                })
10340                .collect::<Vec<_>>();
10341            let routes_hash = content_hash(&routes)?;
10342            detail_parts.push(format!("routes={routes_hash}"));
10343            parts.push(format!("index_routes:{routes_hash}"));
10344        }
10345        None => {
10346            parts.push("index_mode:raw_fallback".to_string());
10347            detail_parts.push("mode=raw_fallback".to_string());
10348            parts.push(format!(
10349                "raw_source_root:{}",
10350                traversal_watermark_path(root, &gate.source_root)
10351            ));
10352            let raw_rows =
10353                graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
10354            let raw_hash = content_hash(&raw_rows)?;
10355            detail_parts.push(format!("raw_source_files={raw_hash}"));
10356            parts.push(format!("raw_source_files:{raw_hash}"));
10357        }
10358    }
10359
10360    parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
10361    detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
10362    let summaries_start = parts.len();
10363    push_traversal_summaries_watermark_part(root, &mut parts)?;
10364    let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
10365    detail_parts.push(format!("summaries={summaries_hash}"));
10366    let value = content_hash(&parts)?;
10367    detail_parts.push(format!("watermark={value}"));
10368    Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
10369        value,
10370        detail: detail_parts.join(" "),
10371    })
10372}
10373
10374fn graph_db_backend_eval_full_projection_cache_key(
10375    root: &Path,
10376    scope: Option<&str>,
10377) -> Result<(String, String, String)> {
10378    let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
10379    let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10380        root,
10381        scope,
10382        &source_watermark.value,
10383    )?;
10384    Ok((source_watermark.value, key, source_watermark.detail))
10385}
10386
10387fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
10388    root: &Path,
10389    scope: Option<&str>,
10390    source_watermark: &str,
10391) -> Result<String> {
10392    content_hash(&serde_json::json!({
10393    "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
10394    "root": root.display().to_string(),
10395    "scope": scope.unwrap_or("root"),
10396    "source_watermark": source_watermark,
10397    }))
10398}
10399
10400pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
10401    root: &Path,
10402    scope: Option<&str>,
10403) -> Result<(
10404    GraphProjection,
10405    Vec<String>,
10406    Vec<GraphDbBackendEvalPhaseTiming>,
10407    GraphDbBackendEvalFullProjectionCacheStats,
10408)> {
10409    let (source_watermark, key, source_watermark_detail) =
10410        graph_db_backend_eval_full_projection_cache_key(root, scope)?;
10411    let lookup_started = Instant::now();
10412    if let Some((cached, disk_bytes, json_bytes, read_profile)) =
10413        graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
10414            root,
10415            "full_projection",
10416            &key,
10417        )
10418        && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
10419        && cached.key == key
10420        && cached.source_watermark == source_watermark
10421    {
10422        let lookup_overhead_micros = lookup_started
10423            .elapsed()
10424            .as_micros()
10425            .saturating_sub(read_profile.file_read_micros)
10426            .saturating_sub(read_profile.gzip_decode_micros)
10427            .saturating_sub(read_profile.serde_decode_micros);
10428        let prune_started = Instant::now();
10429        let (pruned_files, pruned_bytes) =
10430            graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
10431        let prune_micros = prune_started.elapsed().as_micros();
10432        let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
10433            hit: true,
10434            disk_bytes,
10435            json_bytes,
10436            pruned_files,
10437            pruned_bytes,
10438        };
10439        let read_detail_suffix = if read_profile.legacy {
10440            " (legacy uncompressed cache path)"
10441        } else {
10442            ""
10443        };
10444        return Ok((
10445            cached.projection,
10446            cached.warnings,
10447            vec![
10448                graph_db_backend_eval_phase_timing(
10449                    "full_projection.cache_lookup",
10450                    lookup_overhead_micros,
10451                    &format!(
10452                        "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
10453                    ),
10454                ),
10455                graph_db_backend_eval_phase_timing(
10456                    "full_projection.cache.file_read",
10457                    read_profile.file_read_micros,
10458                    &format!(
10459                        "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
10460                    ),
10461                ),
10462                graph_db_backend_eval_phase_timing(
10463                    "full_projection.cache.gzip_decode",
10464                    read_profile.gzip_decode_micros,
10465                    "gunzip the compressed projection cache bytes",
10466                ),
10467                graph_db_backend_eval_phase_timing(
10468                    "full_projection.cache.serde_decode",
10469                    read_profile.serde_decode_micros,
10470                    "serde_json deserialize the decoded projection cache payload",
10471                ),
10472                graph_db_backend_eval_phase_timing(
10473                    "full_projection.cache.prune",
10474                    prune_micros,
10475                    "prune sibling cache files older than the current key",
10476                ),
10477                graph_db_backend_eval_phase_timing(
10478                    "full_projection.source_graph_build",
10479                    0,
10480                    "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10481                ),
10482                graph_db_backend_eval_phase_timing(
10483                    "full_projection.projection_rows",
10484                    0,
10485                    "reused cached provider-neutral full-project projection rows",
10486                ),
10487            ],
10488            cache_stats,
10489        ));
10490    }
10491
10492    let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
10493    let mut phases = vec![graph_db_backend_eval_phase_timing(
10494        "full_projection.cache_lookup",
10495        lookup_started.elapsed().as_micros(),
10496        &format!(
10497            "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
10498        ),
10499    )];
10500    let full_source = graph_db_backend_eval_timed_phase(
10501        &mut phases,
10502        "full_projection.source_graph_build",
10503        "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
10504        || build_traversal_graph_source_with_options(root, root, scope, false),
10505    )?;
10506    let projection = graph_db_backend_eval_timed_phase(
10507        &mut phases,
10508        "full_projection.projection_rows",
10509        "provider-neutral row construction for the opt-in full-project projection dataset",
10510        || traversal_projection_from_graph(root, scope, &full_source),
10511    )?;
10512    let warnings = full_source.warnings;
10513    let refreshed_source_watermark =
10514        graph_db_backend_eval_full_projection_source_watermark(root, scope)
10515            .map(|watermark| watermark.value)
10516            .unwrap_or_else(|_| source_watermark.clone());
10517    let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10518        root,
10519        scope,
10520        &refreshed_source_watermark,
10521    )?;
10522    let cache = GraphDbBackendEvalFullProjectionCache {
10523        version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
10524        key: write_key.clone(),
10525        source_watermark: refreshed_source_watermark,
10526        projection: projection.clone(),
10527        warnings: warnings.clone(),
10528    };
10529    if let Some((disk_bytes, json_bytes, write_profile)) =
10530        graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
10531    {
10532        cache_stats.disk_bytes = disk_bytes;
10533        cache_stats.json_bytes = json_bytes;
10534        phases.push(graph_db_backend_eval_phase_timing(
10535            "full_projection.cache.serde_encode",
10536            write_profile.serde_encode_micros,
10537            "serde_json serialize the projection cache payload before compression",
10538        ));
10539        phases.push(graph_db_backend_eval_phase_timing(
10540            "full_projection.cache.gzip_encode",
10541            write_profile.gzip_encode_micros,
10542            "gzip-compress the serialized projection cache payload",
10543        ));
10544        phases.push(graph_db_backend_eval_phase_timing(
10545            "full_projection.cache.file_write",
10546            write_profile.file_write_micros,
10547            "write the compressed projection cache bytes to .tsift/backend-eval-cache",
10548        ));
10549    }
10550    let prune_started = Instant::now();
10551    let (pruned_files, pruned_bytes) =
10552        graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
10553    phases.push(graph_db_backend_eval_phase_timing(
10554        "full_projection.cache.prune",
10555        prune_started.elapsed().as_micros(),
10556        "prune sibling cache files older than the current key",
10557    ));
10558    cache_stats.pruned_files = pruned_files;
10559    cache_stats.pruned_bytes = pruned_bytes;
10560    Ok((projection, warnings, phases, cache_stats))
10561}
10562
10563fn graph_db_backend_eval_timed(
10564    name: &str,
10565    run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
10566) -> (
10567    GraphDbBackendEvalOperation,
10568    Option<GraphDbBackendEvalSignature>,
10569) {
10570    let started = Instant::now();
10571    match run() {
10572        Ok((rows, value)) => (
10573            GraphDbBackendEvalOperation {
10574                name: name.to_string(),
10575                supported: true,
10576                status: "ok".to_string(),
10577                duration_micros: started.elapsed().as_micros(),
10578                rows,
10579                error: None,
10580            },
10581            Some(GraphDbBackendEvalSignature {
10582                operation: name.to_string(),
10583                value,
10584            }),
10585        ),
10586        Err(err) => (
10587            GraphDbBackendEvalOperation {
10588                name: name.to_string(),
10589                supported: false,
10590                status: "error".to_string(),
10591                duration_micros: started.elapsed().as_micros(),
10592                rows: None,
10593                error: Some(format!("{err:#}")),
10594            },
10595            None,
10596        ),
10597    }
10598}
10599
10600fn graph_db_backend_eval_parity(
10601    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10602    candidate_signatures: &[GraphDbBackendEvalSignature],
10603) -> GraphDbBackendEvalParity {
10604    let Some(sqlite_signatures) = sqlite_signatures else {
10605        return GraphDbBackendEvalParity {
10606            matches_sqlite: true,
10607            diagnostics: Vec::new(),
10608        };
10609    };
10610    let sqlite = sqlite_signatures
10611        .iter()
10612        .map(|signature| (signature.operation.as_str(), &signature.value))
10613        .collect::<BTreeMap<_, _>>();
10614    let candidate = candidate_signatures
10615        .iter()
10616        .map(|signature| (signature.operation.as_str(), &signature.value))
10617        .collect::<BTreeMap<_, _>>();
10618    let mut diagnostics = Vec::new();
10619    for (operation, sqlite_value) in sqlite {
10620        match candidate.get(operation) {
10621            Some(candidate_value) if *candidate_value == sqlite_value => {}
10622            Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
10623            None => diagnostics.push(format!(
10624                "{operation} did not complete for candidate backend"
10625            )),
10626        }
10627    }
10628    GraphDbBackendEvalParity {
10629        matches_sqlite: diagnostics.is_empty(),
10630        diagnostics,
10631    }
10632}
10633
10634pub(crate) fn graph_db_backend_eval_targets(
10635    store: &impl GraphStore,
10636    requested: &[String],
10637) -> Result<Vec<String>> {
10638    let requested = requested
10639        .iter()
10640        .filter_map(|target| normalize_conflict_target(target))
10641        .collect::<Vec<_>>();
10642    if !requested.is_empty() {
10643        return Ok(requested);
10644    }
10645
10646    for kind in ["backlog", "job_packet"] {
10647        let nodes = store.nodes_by_kind(kind)?;
10648        if let Some(node) = nodes.first() {
10649            if let Some(ref_id) = node.properties.get("ref_id") {
10650                return Ok(vec![ref_id.clone()]);
10651            }
10652            return Ok(vec![node.id.clone()]);
10653        }
10654    }
10655    Ok(Vec::new())
10656}
10657
10658fn graph_db_backend_eval_path_targets(
10659    store: &impl GraphStore,
10660    max_hops: usize,
10661) -> Result<Option<(String, String, usize)>> {
10662    let synthetic_from = "gsym-synthetic-0000";
10663    let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
10664    if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
10665        let outgoing = store.outgoing_edges(synthetic_from, None)?;
10666        if outgoing.len() > 1
10667            && let Some(edge) = outgoing.first()
10668        {
10669            return Ok(Some((
10670                edge.from_id.clone(),
10671                edge.to_id.clone(),
10672                GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10673            )));
10674        }
10675        return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
10676    }
10677
10678    Ok(store.sample_edge(None)?.map(|edge| {
10679        (
10680            edge.from_id,
10681            edge.to_id,
10682            GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10683        )
10684    }))
10685}
10686
10687fn graph_db_backend_eval_path_operation<S: GraphStore>(
10688    store: &S,
10689    configured_max_hops: usize,
10690) -> (
10691    GraphDbBackendEvalOperation,
10692    Option<GraphDbBackendEvalSignature>,
10693) {
10694    let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10695        "path_max_hops".to_string()
10696    } else {
10697        format!("path_max_hops_{configured_max_hops}")
10698    };
10699    graph_db_backend_eval_timed(&operation_name, || {
10700        let (from, to, effective_max_hops) =
10701            graph_db_backend_eval_path_targets(store, configured_max_hops)?
10702                .context("backend-eval path probe requires at least one traversable edge")?;
10703        let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
10704        let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10705            Some(format!(
10706                "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
10707                GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
10708            ))
10709        } else if path.is_none() && effective_max_hops == configured_max_hops {
10710            Some(format!(
10711                "path probe truncated at {configured_max_hops} hops before a route was found"
10712            ))
10713        } else {
10714            None
10715        };
10716        Ok((
10717            path.as_ref().map(|path| path.nodes.len()),
10718            serde_json::json!({
10719                "from": from,
10720                "to": to,
10721                "configured_max_hops": configured_max_hops,
10722                "effective_max_hops": effective_max_hops,
10723                "hops": path.as_ref().map(|path| path.hops),
10724                "nodes": path.as_ref().map(|path| &path.nodes),
10725                "found": path.is_some(),
10726                "warning": warning,
10727            }),
10728        ))
10729    })
10730}
10731
10732fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
10733    store: &S,
10734    depth: usize,
10735    limit: usize,
10736) -> (
10737    GraphDbBackendEvalOperation,
10738    Option<GraphDbBackendEvalSignature>,
10739) {
10740    graph_db_backend_eval_timed("neighborhood", || {
10741        let edge = match store.sample_edge(Some("calls"))? {
10742            Some(edge) => edge,
10743            None => store.sample_edge(None)?.context(
10744                "backend-eval neighborhood probe requires at least one traversable edge",
10745            )?,
10746        };
10747        let page = store
10748            .paged_neighborhood(
10749                &edge.from_id,
10750                depth,
10751                Some(&edge.kind),
10752                GraphQueryOptions {
10753                    limit: Some(limit.max(1)),
10754                    ..GraphQueryOptions::default()
10755                },
10756            )?
10757            .with_context(|| {
10758                format!(
10759                    "backend-eval neighborhood target not found: {}",
10760                    edge.from_id
10761                )
10762            })?;
10763        Ok((
10764            Some(page.nodes.len() + page.edges.len()),
10765            serde_json::json!({
10766                "center": edge.from_id,
10767                "kind": edge.kind,
10768                "depth": depth,
10769                "limit": limit.max(1),
10770                "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10771                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10772                "truncated": page.page.truncated,
10773            }),
10774        ))
10775    })
10776}
10777
10778fn graph_db_backend_eval_related_operation<S: GraphStore>(
10779    root: &Path,
10780    scope: Option<&str>,
10781    store: &S,
10782    depth: usize,
10783    limit: usize,
10784) -> (
10785    GraphDbBackendEvalOperation,
10786    Option<GraphDbBackendEvalSignature>,
10787) {
10788    graph_db_backend_eval_timed("related", || {
10789        let query = "backend evaluation";
10790        let semantic = semantic_related_report_from_store(
10791            root,
10792            scope,
10793            query,
10794            3,
10795            SemanticRelatedKind::All,
10796            store,
10797        )?;
10798        let seed_ids = semantic
10799            .items
10800            .iter()
10801            .map(|item| item.handle.clone())
10802            .collect::<Vec<_>>();
10803        let subgraph =
10804            graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
10805        Ok((
10806            Some(subgraph.nodes.len() + subgraph.edges.len()),
10807            serde_json::json!({
10808                "query": query,
10809                "seed_ids": seed_ids,
10810                "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10811                "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10812                "truncated": subgraph.truncated,
10813                "warnings": semantic.warnings,
10814                "diagnostics": subgraph.diagnostics,
10815            }),
10816        ))
10817    })
10818}
10819
10820fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
10821    serde_json::json!({
10822        "target": report.target,
10823        "target_node_id": report.target_node.id,
10824        "target_kind": report.target_node.kind,
10825        "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
10826        "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
10827        "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
10828        "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
10829        "path_count": report.shortest_paths.len(),
10830    })
10831}
10832
10833fn graph_db_backend_eval_target_resolution_signature(
10834    resolved: &[(String, SubstrateGraphNode)],
10835) -> serde_json::Value {
10836    serde_json::json!({
10837        "targets": resolved.iter().map(|(target, node)| {
10838            serde_json::json!({
10839                "target": target,
10840                "target_node_id": node.id,
10841                "target_kind": node.kind,
10842                "target_label": node.label,
10843            })
10844        }).collect::<Vec<_>>(),
10845    })
10846}
10847
10848fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
10849    serde_json::json!({
10850        "targets": report.targets,
10851        "can_parallel": report.can_parallel,
10852        "fail_closed": report.fail_closed,
10853        "cross_target_parallel_safe": report.cross_target_parallel_safe,
10854        "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
10855        "candidates": report.candidates.iter().map(|candidate| {
10856            serde_json::json!({
10857                "target": candidate.target,
10858                "risk": conflict_risk_label(candidate.risk),
10859                "owned_files": candidate.owned_files,
10860                "owned_symbols": candidate.owned_symbols,
10861                "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
10862                "previously_completed": candidate.previously_completed,
10863                "parallel_safe": candidate.parallel_safe,
10864            })
10865        }).collect::<Vec<_>>(),
10866        "conflicts": report.conflicts.iter().map(|pair| {
10867            serde_json::json!({
10868                "left": pair.left,
10869                "right": pair.right,
10870                "risk": conflict_risk_label(pair.risk),
10871            })
10872        }).collect::<Vec<_>>(),
10873    })
10874}
10875
10876fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
10877    serde_json::json!({
10878        "targets": report.targets,
10879        "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10880        "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
10881        "evidence_packet_ids": report.evidence_packet_ids,
10882        "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
10883        "truncated": report.truncated,
10884    })
10885}
10886
10887fn graph_db_backend_eval_edge_scan_probe(
10888    store: &impl GraphStore,
10889) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
10890    if let Some((edge, filter)) = store.sample_edge_with_property()? {
10891        return Ok((edge, vec![filter]));
10892    }
10893    let edge = store
10894        .sample_edge(None)?
10895        .context("backend-eval edge scan requires at least one edge")?;
10896    Ok((edge, Vec::new()))
10897}
10898
10899#[allow(clippy::too_many_arguments)]
10900fn graph_db_backend_eval_report_for_store<S: GraphStore>(
10901    backend: &str,
10902    adapter: &str,
10903    read_only: bool,
10904    root: &Path,
10905    path: &Path,
10906    scope: Option<&str>,
10907    targets: &[String],
10908    depth: usize,
10909    limit: usize,
10910    impact_limit: usize,
10911    store: &S,
10912    freshness: GraphDbFreshnessReport,
10913    refresh_operation: GraphDbBackendEvalOperation,
10914    refresh_signature: Option<GraphDbBackendEvalSignature>,
10915    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10916    extra_warnings: Vec<String>,
10917    prepared: &ConflictMatrixPreparedInputs,
10918    projection_load: &str,
10919    lock_behavior: &str,
10920    install_portability: &str,
10921) -> (
10922    GraphDbBackendEvalBackendReport,
10923    Vec<GraphDbBackendEvalSignature>,
10924) {
10925    let mut operations = vec![refresh_operation];
10926    let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
10927
10928    let (operation, signature) = graph_db_backend_eval_timed("status", || {
10929        let (nodes, edges) = store.graph_counts()?;
10930        Ok((
10931            Some(nodes + edges),
10932            serde_json::json!({
10933                "freshness": freshness.status,
10934                "nodes": nodes,
10935                "edges": edges,
10936            }),
10937        ))
10938    });
10939    operations.push(operation);
10940    signatures.extend(signature);
10941
10942    let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
10943        let edge = store
10944            .sample_edge(None)?
10945            .context("backend-eval edge lookup requires at least one edge")?;
10946        let edge_id = graph_db_edge_key(&edge);
10947        let found = store
10948            .edge(&edge_id)?
10949            .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
10950        Ok((
10951            Some(1),
10952            serde_json::json!({
10953                "edge_id": edge_id,
10954                "from_id": found.from_id,
10955                "to_id": found.to_id,
10956                "kind": found.kind,
10957            }),
10958        ))
10959    });
10960    operations.push(operation);
10961    signatures.extend(signature);
10962
10963    let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
10964        let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
10965        let page = store.paged_edges(
10966            Some(&edge.kind),
10967            GraphQueryOptions {
10968                limit: Some(limit.max(1)),
10969                property_filters: filters.clone(),
10970                ..GraphQueryOptions::default()
10971            },
10972        )?;
10973        Ok((
10974            Some(page.edges.len()),
10975            serde_json::json!({
10976                "kind": edge.kind,
10977                "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
10978                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10979                "truncated": page.page.truncated,
10980            }),
10981        ))
10982    });
10983    operations.push(operation);
10984    signatures.extend(signature);
10985
10986    let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
10987        let edge = store
10988            .sample_edge(None)?
10989            .context("backend-eval incident edge scan requires at least one edge")?;
10990        let page = store.paged_incident_edges(
10991            &edge.from_id,
10992            Some(&edge.kind),
10993            GraphQueryOptions {
10994                limit: Some(limit.max(1)),
10995                ..GraphQueryOptions::default()
10996            },
10997        )?;
10998        Ok((
10999            Some(page.edges.len()),
11000            serde_json::json!({
11001                "node_id": edge.from_id,
11002                "kind": edge.kind,
11003                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11004                "truncated": page.page.truncated,
11005            }),
11006        ))
11007    });
11008    operations.push(operation);
11009    signatures.extend(signature);
11010
11011    let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
11012    operations.push(operation);
11013    signatures.extend(signature);
11014
11015    let (operation, signature) =
11016        graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
11017    operations.push(operation);
11018    signatures.extend(signature);
11019
11020    for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
11021        .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
11022    {
11023        let (operation, signature) =
11024            graph_db_backend_eval_path_operation(store, configured_max_hops);
11025        operations.push(operation);
11026        signatures.extend(signature);
11027    }
11028
11029    let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
11030        let resolved = targets
11031            .iter()
11032            .map(|target| {
11033                let node = graph_db_resolve_evidence_target(store, target)?
11034                    .with_context(|| format!("backend-eval target not found: {target}"))?;
11035                Ok((target.clone(), node))
11036            })
11037            .collect::<Result<Vec<_>>>()?;
11038        let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
11039        Ok((Some(resolved.len()), signature))
11040    });
11041    operations.push(operation);
11042    signatures.extend(signature);
11043
11044    let mut evidence_for_report = None;
11045    let mut graph_snapshot_for_trace = None;
11046    let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
11047        let resolved_targets =
11048            resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
11049        let evidence = collect_conflict_matrix_evidence_packets(
11050            root,
11051            scope,
11052            backend,
11053            &resolved_targets,
11054            depth,
11055            limit,
11056            store,
11057            freshness.clone(),
11058        )?;
11059        let report = &evidence
11060            .first()
11061            .context("backend-eval evidence requires at least one target")?
11062            .report;
11063        let rows = evidence
11064            .iter()
11065            .map(|entry| {
11066                entry.report.worker_context.len()
11067                    + entry.report.source_handles.len()
11068                    + entry.report.worker_results.len()
11069                    + entry.report.semantic_related.len()
11070            })
11071            .sum();
11072        let signature = graph_db_backend_eval_evidence_signature(report);
11073        evidence_for_report = Some((resolved_targets, evidence));
11074        Ok((Some(rows), signature))
11075    });
11076    operations.push(operation);
11077    signatures.extend(signature);
11078
11079    let mut conflict_for_trace = None;
11080    let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
11081        let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
11082            let graph =
11083                conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
11084            let shared_preparation =
11085                conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
11086            ConflictMatrixGraphPreparedInputs {
11087                targets,
11088                graph,
11089                evidence,
11090                shared_preparation,
11091            }
11092        } else {
11093            prepare_conflict_matrix_graph_orchestration(
11094                root,
11095                scope,
11096                backend,
11097                targets,
11098                prepared,
11099                depth,
11100                limit,
11101                store,
11102                freshness.clone(),
11103            )?
11104        };
11105        let report = build_conflict_matrix_report_from_prepared_graph(
11106            root,
11107            path,
11108            scope,
11109            depth,
11110            limit,
11111            impact_limit,
11112            freshness.clone(),
11113            extra_warnings.clone(),
11114            prepared,
11115            &graph_prepared,
11116        )?;
11117        let signature = graph_db_backend_eval_conflict_signature(&report);
11118        let rows = report.candidates.len() + report.conflicts.len();
11119        conflict_for_trace = Some(report);
11120        graph_snapshot_for_trace = Some(graph_prepared.graph);
11121        Ok((Some(rows), signature))
11122    });
11123    operations.push(operation);
11124    signatures.extend(signature);
11125
11126    let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11127        let conflict = conflict_for_trace
11128            .take()
11129            .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11130        let graph = graph_snapshot_for_trace
11131            .take()
11132            .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11133        let report = build_dispatch_trace_report_from_conflict_snapshot(
11134            root,
11135            scope,
11136            conflict,
11137            graph.nodes,
11138            graph.edges,
11139            depth,
11140            limit,
11141            Vec::new(),
11142        )?;
11143        Ok((
11144            Some(report.nodes.len() + report.edges.len()),
11145            graph_db_backend_eval_dispatch_signature(&report),
11146        ))
11147    });
11148    operations.push(operation);
11149    signatures.extend(signature);
11150
11151    let total_micros = operations
11152        .iter()
11153        .map(|operation| operation.duration_micros)
11154        .sum();
11155    let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11156    (
11157        GraphDbBackendEvalBackendReport {
11158            backend: backend.to_string(),
11159            adapter: adapter.to_string(),
11160            read_only,
11161            projection_load: projection_load.to_string(),
11162            operations,
11163            total_micros,
11164            parity,
11165            lock_behavior: lock_behavior.to_string(),
11166            install_portability: install_portability.to_string(),
11167        },
11168        signatures,
11169    )
11170}
11171
11172pub(crate) fn graph_db_backend_eval_refresh_operation(
11173    duration_micros: u128,
11174    rows: usize,
11175    value: serde_json::Value,
11176) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11177    (
11178        GraphDbBackendEvalOperation {
11179            name: "refresh".to_string(),
11180            supported: true,
11181            status: "ok".to_string(),
11182            duration_micros,
11183            rows: Some(rows),
11184            error: None,
11185        },
11186        GraphDbBackendEvalSignature {
11187            operation: "refresh".to_string(),
11188            value,
11189        },
11190    )
11191}
11192
11193pub(crate) fn graph_db_backend_eval_synthetic_projection(
11194    nodes: usize,
11195    fanout: usize,
11196) -> GraphProjection {
11197    let nodes = nodes.max(12);
11198    let symbol_count = nodes.saturating_sub(9).max(1);
11199    let source = GraphProvenance::new("backend-eval", "synthetic");
11200    let mut projection_nodes = vec![
11201        SubstrateGraphNode::new(
11202            "projection:tsift-traversal:synthetic",
11203            GRAPH_PROJECTION_META_KIND,
11204            "synthetic projection",
11205        )
11206        .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11207        .with_property(
11208            "content_hash",
11209            format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11210        )
11211        .with_provenance(source.clone()),
11212        SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11213            .with_property("ref_id", "synthetic-session"),
11214        SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11215            .with_property("ref_id", "synthetic")
11216            .with_property("path", "tasks/software/synthetic.md")
11217            .with_property("line", "1")
11218            .with_property(
11219                "expand",
11220                "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11221            ),
11222        SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11223            .with_property("ref_id", "synthetic"),
11224        SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11225            .with_property("target", "synthetic")
11226            .with_property("summary", "Synthetic worker owns synthetic.rs")
11227            .with_property(
11228                "expand",
11229                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11230            ),
11231        SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11232            .with_property("file", "synthetic.rs")
11233            .with_property("start", "1")
11234            .with_property("end", "80")
11235            .with_property(
11236                "expand",
11237                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11238            ),
11239        SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11240            .with_property("path", "synthetic.rs"),
11241        SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11242            .with_property("handle", "gsem-synthetic")
11243            .with_property("label", "backend evaluation")
11244            .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11245            .with_property(
11246                "embedding",
11247                semantic_embedding_property("backend evaluation"),
11248            ),
11249        SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11250            .with_property("ref_id", "synthetic")
11251            .with_property("status", "completed")
11252            .with_property("touched_files", "synthetic.rs")
11253            .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11254    ];
11255    for idx in 0..symbol_count {
11256        projection_nodes.push(
11257            SubstrateGraphNode::new(
11258                format!("gsym-synthetic-{idx:04}"),
11259                "symbol",
11260                format!("synthetic_symbol_{idx:04}"),
11261            )
11262            .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11263            .with_property("path", "synthetic.rs")
11264            .with_property("line", (idx + 1).to_string()),
11265        );
11266    }
11267
11268    let mut projection_edges = vec![
11269        SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11270        SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11271        SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11272        SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11273        SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11274        SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11275        SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11276        SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11277        SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11278    ];
11279    for idx in 0..symbol_count {
11280        let from = format!("gsym-synthetic-{idx:04}");
11281        for offset in 1..=fanout.max(1).min(symbol_count) {
11282            let to_idx = (idx + offset) % symbol_count;
11283            if to_idx != idx {
11284                projection_edges.push(SubstrateGraphEdge::new(
11285                    from.clone(),
11286                    format!("gsym-synthetic-{to_idx:04}"),
11287                    "calls",
11288                ));
11289            }
11290        }
11291    }
11292
11293    GraphProjection {
11294        nodes: projection_nodes,
11295        edges: projection_edges
11296            .into_iter()
11297            .map(|edge| {
11298                edge.with_property("dataset", "synthetic")
11299                    .with_provenance(source.clone())
11300            })
11301            .collect(),
11302    }
11303}
11304
11305pub(crate) fn graph_db_backend_eval_promotion(
11306    datasets: &[GraphDbBackendEvalDataset],
11307    candidates: &[GraphDbExperimentalBackend],
11308) -> Vec<GraphDbBackendPromotionDecision> {
11309    let mut decisions = Vec::new();
11310    for candidate in candidates {
11311        let mut reasons = Vec::new();
11312        let mut faster_everywhere = true;
11313        let mut parity_everywhere = true;
11314        for dataset in datasets {
11315            let Some(sqlite_report) = dataset
11316                .backends
11317                .iter()
11318                .find(|backend| backend.backend == "sqlite")
11319            else {
11320                parity_everywhere = false;
11321                faster_everywhere = false;
11322                reasons.push(format!(
11323                    "{} dataset is missing SQLite baseline",
11324                    dataset.name
11325                ));
11326                continue;
11327            };
11328            let sqlite_total = sqlite_report.total_micros;
11329            let Some(candidate_report) = dataset
11330                .backends
11331                .iter()
11332                .find(|backend| backend.backend == candidate.name())
11333            else {
11334                parity_everywhere = false;
11335                reasons.push(format!("{} dataset did not run", dataset.name));
11336                continue;
11337            };
11338            if !candidate_report.parity.matches_sqlite {
11339                parity_everywhere = false;
11340                reasons.push(format!("{} parity differed from SQLite", dataset.name));
11341            }
11342            if candidate_report.total_micros >= sqlite_total {
11343                faster_everywhere = false;
11344                reasons.push(format!(
11345                    "{} total {}us did not beat SQLite {}us",
11346                    dataset.name, candidate_report.total_micros, sqlite_total
11347                ));
11348            }
11349            let sqlite_operations = sqlite_report
11350                .operations
11351                .iter()
11352                .map(|operation| (operation.name.as_str(), operation.duration_micros))
11353                .collect::<BTreeMap<_, _>>();
11354            for operation in &candidate_report.operations {
11355                if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
11356                    && operation.duration_micros >= *sqlite_duration
11357                {
11358                    faster_everywhere = false;
11359                    reasons.push(format!(
11360                        "{} {} operation {}us did not beat SQLite {}us",
11361                        dataset.name, operation.name, operation.duration_micros, sqlite_duration
11362                    ));
11363                }
11364            }
11365            if candidate_report
11366                .operations
11367                .iter()
11368                .any(|operation| operation.status != "ok")
11369            {
11370                parity_everywhere = false;
11371                reasons.push(format!("{} has failed benchmark operations", dataset.name));
11372            }
11373        }
11374        let decision = if let Some(reason) = candidate.prototype_hold_reason() {
11375            reasons.push(reason.to_string());
11376            reasons.push(
11377                "current bounded prototype timings are benchmark evidence, not a backend switch approval"
11378                    .to_string(),
11379            );
11380            "hold"
11381        } else if parity_everywhere && faster_everywhere {
11382            reasons.push(
11383                "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
11384                    .to_string(),
11385            );
11386            "eligible"
11387        } else {
11388            reasons.push(
11389                "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
11390                    .to_string(),
11391            );
11392            "hold"
11393        };
11394        decisions.push(GraphDbBackendPromotionDecision {
11395            backend: candidate.name().to_string(),
11396            decision: decision.to_string(),
11397            reasons: dedupe_preserve_order(reasons),
11398            gate: candidate.promotion_gate(),
11399        });
11400    }
11401    decisions
11402}
11403
11404pub(crate) fn graph_db_backend_eval_metrics(
11405    datasets: &[GraphDbBackendEvalDataset],
11406) -> BTreeMap<String, f64> {
11407    let mut metrics = BTreeMap::new();
11408    for dataset in datasets {
11409        let graph_rows = graph_db_backend_eval_graph_rows(dataset);
11410        metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
11411        metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
11412        metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
11413        for backend in &dataset.backends {
11414            let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
11415            metrics.insert(
11416                format!("{prefix}.total_duration_micros"),
11417                backend.total_micros as f64,
11418            );
11419            append_graph_db_backend_eval_normalized_duration_metric(
11420                &mut metrics,
11421                &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
11422                backend.total_micros,
11423                graph_rows,
11424            );
11425            for operation in &backend.operations {
11426                metrics.insert(
11427                    format!("{prefix}.{}.duration_micros", operation.name),
11428                    operation.duration_micros as f64,
11429                );
11430                append_graph_db_backend_eval_normalized_duration_metric(
11431                    &mut metrics,
11432                    &format!(
11433                        "{prefix}.{}.duration_micros_per_1k_graph_rows",
11434                        operation.name
11435                    ),
11436                    operation.duration_micros,
11437                    graph_rows,
11438                );
11439                if let Some(rows) = operation.rows {
11440                    metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
11441                }
11442            }
11443        }
11444    }
11445    metrics
11446}
11447
11448pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
11449    dataset.nodes + dataset.edges
11450}
11451
11452pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
11453    metrics: &mut BTreeMap<String, f64>,
11454    key: &str,
11455    duration_micros: u128,
11456    graph_rows: usize,
11457) {
11458    if graph_rows == 0 {
11459        return;
11460    }
11461    metrics.insert(
11462        key.to_string(),
11463        duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
11464    );
11465}
11466
11467pub(crate) fn append_graph_db_backend_eval_phase_metrics(
11468    metrics: &mut BTreeMap<String, f64>,
11469    dataset: &str,
11470    graph_rows: usize,
11471    phases: &[GraphDbBackendEvalPhaseTiming],
11472) {
11473    for phase in phases {
11474        metrics.insert(
11475            format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
11476            phase.duration_micros as f64,
11477        );
11478        append_graph_db_backend_eval_normalized_duration_metric(
11479            metrics,
11480            &format!(
11481                "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
11482                phase.name
11483            ),
11484            phase.duration_micros,
11485            graph_rows,
11486        );
11487    }
11488}
11489
11490fn graph_db_backend_eval_base_command(
11491    root: &Path,
11492    scope: Option<&str>,
11493    full_projection: bool,
11494) -> String {
11495    let full_projection_arg = if full_projection {
11496        " --full-projection"
11497    } else {
11498        ""
11499    };
11500    format!(
11501        "tsift graph-db --path {}{} --json backend-eval{}",
11502        shell_quote(root.to_string_lossy().as_ref()),
11503        graph_db_scope_arg(scope),
11504        full_projection_arg
11505    )
11506}
11507
11508pub(crate) fn graph_db_backend_eval_metric_digest_command(
11509    root: &Path,
11510    scope: Option<&str>,
11511    full_projection: bool,
11512) -> String {
11513    format!(
11514        "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11515        graph_db_backend_eval_base_command(root, scope, full_projection)
11516    )
11517}
11518
11519fn graph_db_backend_eval_repeated_sample_command(
11520    root: &Path,
11521    scope: Option<&str>,
11522    full_projection: bool,
11523) -> String {
11524    format!(
11525        "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11526        graph_db_backend_eval_base_command(root, scope, full_projection)
11527    )
11528}
11529
11530fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
11531    let mut required_metrics = Vec::new();
11532    for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
11533        required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
11534        required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
11535        for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
11536            required_metrics.push(format!(
11537                "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
11538            ));
11539            required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
11540        }
11541    }
11542    GraphDbHopCapPromotionGate {
11543        status: "hold_64_default_until_gate_passes".to_string(),
11544        current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
11545        candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
11546        required_backend: perf_gate::BASELINE_BACKEND.to_string(),
11547        required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
11548            .iter()
11549            .map(|workload| (*workload).to_string())
11550            .collect(),
11551        required_metrics,
11552        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11553        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11554        decision_rule:
11555            "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"
11556                .to_string(),
11557    }
11558}
11559
11560fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
11561    let candidate_backends = [
11562        GraphDbExperimentalBackend::Falkordb,
11563        GraphDbExperimentalBackend::Kuzu,
11564        GraphDbExperimentalBackend::Surrealdb,
11565    ]
11566    .into_iter()
11567    .map(|backend| GraphDbBackendAdapterSpikeCandidate {
11568        backend: backend.name().to_string(),
11569        adapter_label: backend.adapter_label().to_string(),
11570        projection_load: backend.projection_load().to_string(),
11571        lock_behavior: backend.lock_behavior().to_string(),
11572        install_portability: backend.install_portability().to_string(),
11573    })
11574    .collect();
11575
11576    GraphDbBackendAdapterSpikeGate {
11577        status: "hold_real_optional_adapter_required".to_string(),
11578        candidate_backends,
11579        required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
11580            .iter()
11581            .map(|workload| (*workload).to_string())
11582            .collect(),
11583        required_checks: vec![
11584            "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
11585            "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
11586            "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
11587            "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
11588            "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
11589                .to_string(),
11590            "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
11591            "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
11592        ],
11593        decision_rule:
11594            "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"
11595                .to_string(),
11596        evidence_plan: "plans/gback-evidence.md".to_string(),
11597    }
11598}
11599
11600pub(crate) fn graph_db_backend_eval_performance_gate(
11601    root: &Path,
11602    scope: Option<&str>,
11603    full_projection: bool,
11604) -> GraphDbBackendEvalPerformanceGate {
11605    let mut required_metrics = vec![
11606        "real.sqlite.refresh.duration_micros".to_string(),
11607        "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
11608        "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
11609        "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
11610        "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11611        "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11612        "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
11613        "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
11614        "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11615        "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
11616        "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
11617        "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
11618        "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
11619        "real.sqlite.conflict_matrix.duration_micros".to_string(),
11620        "real.sqlite.dispatch_trace.duration_micros".to_string(),
11621        "real.sqlite.path_max_hops.duration_micros".to_string(),
11622        "real.sqlite.path_max_hops_128.duration_micros".to_string(),
11623        "real.sqlite.path_max_hops_256.duration_micros".to_string(),
11624        "real.sqlite.path_max_hops_512.duration_micros".to_string(),
11625        "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
11626        "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
11627        "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
11628        "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
11629        "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11630        "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11631        "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
11632            .to_string(),
11633        "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11634            .to_string(),
11635        "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11636        "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11637        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
11638        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
11639        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
11640        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
11641        "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11642            .to_string(),
11643        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
11644        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
11645            .to_string(),
11646        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
11647            .to_string(),
11648        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
11649            .to_string(),
11650    ];
11651    if full_projection {
11652        required_metrics.extend([
11653            "full_projection.cache.hit".to_string(),
11654            "full_projection.cache.disk_bytes".to_string(),
11655            "full_projection.cache.compression_ratio".to_string(),
11656            "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
11657            "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11658            "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
11659                .to_string(),
11660            "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
11661                .to_string(),
11662            "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
11663            "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
11664            "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
11665            "full_projection.sqlite.neighborhood.duration_micros".to_string(),
11666            "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
11667            "full_projection.sqlite.evidence.duration_micros".to_string(),
11668            "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
11669            "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
11670            "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
11671            "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
11672            "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
11673            "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
11674        ]);
11675    }
11676    GraphDbBackendEvalPerformanceGate {
11677        baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
11678        ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
11679            .to_string(),
11680        opt_in_real_profile:
11681            "pass --full-projection to add the full-project dataset when checking for large projection regressions"
11682                .to_string(),
11683        full_projection_cache_hit_gate: if full_projection {
11684            "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"
11685                .to_string()
11686        } else {
11687            "not evaluated until --full-projection is enabled".to_string()
11688        },
11689        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11690        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11691        normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
11692        required_metrics,
11693        digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
11694        repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
11695            root,
11696            scope,
11697            full_projection,
11698        ),
11699        hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
11700        backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
11701    }
11702}
11703
11704#[cfg(feature = "backend-surrealdb")]
11705fn graph_db_backend_eval_path_segment(value: &str) -> String {
11706    value
11707        .chars()
11708        .map(|ch| {
11709            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
11710                ch
11711            } else {
11712                '_'
11713            }
11714        })
11715        .collect()
11716}
11717
11718#[cfg(feature = "backend-surrealdb")]
11719fn graph_db_backend_eval_surrealdb_store_path(
11720    root: &Path,
11721    scope: Option<&str>,
11722    dataset: &str,
11723) -> PathBuf {
11724    root.join(".tsift/backend-eval-cache/surrealdb")
11725        .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
11726        .join(graph_db_backend_eval_path_segment(dataset))
11727        .join("surrealkv")
11728}
11729
11730pub(crate) struct GraphDbBackendEvalOptions<'a> {
11731    path: &'a Path,
11732    scope: Option<&'a str>,
11733    candidates: &'a [String],
11734    targets: &'a [String],
11735    full_projection: bool,
11736}
11737
11738#[allow(clippy::too_many_arguments)]
11739pub(crate) fn graph_db_backend_eval_dataset(
11740    name: &str,
11741    root: &Path,
11742    path: &Path,
11743    scope: Option<&str>,
11744    targets: &[String],
11745    depth: usize,
11746    limit: usize,
11747    impact_limit: usize,
11748    candidates: &[GraphDbExperimentalBackend],
11749    sqlite_store: &SqliteGraphStore,
11750    sqlite_freshness: GraphDbFreshnessReport,
11751    sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
11752    sqlite_rows: ConvexProjectionRows,
11753    extra_warnings: Vec<String>,
11754    prepared: &ConflictMatrixPreparedInputs,
11755) -> Result<GraphDbBackendEvalDataset> {
11756    let (nodes, edges) = sqlite_store.graph_counts()?;
11757    let (sqlite_operation, sqlite_signature) = sqlite_refresh;
11758    let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
11759        "sqlite",
11760        "SQLite GraphStore correctness baseline",
11761        false,
11762        root,
11763        path,
11764        scope,
11765        targets,
11766        depth,
11767        limit,
11768        impact_limit,
11769        sqlite_store,
11770        sqlite_freshness,
11771        sqlite_operation,
11772        Some(sqlite_signature),
11773        None,
11774        extra_warnings.clone(),
11775        prepared,
11776        "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
11777        "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
11778        "bundled rusqlite baseline; no external service or runtime required",
11779    );
11780
11781    let mut backends = vec![sqlite_report];
11782    for candidate in candidates {
11783        #[cfg(feature = "backend-surrealdb")]
11784        if *candidate == GraphDbExperimentalBackend::Surrealdb {
11785            let started = Instant::now();
11786            let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
11787            let (store, warm_start) =
11788                SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
11789            let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11790            let rows = candidate_nodes + candidate_edges;
11791            let mut refresh_meta = serde_json::json!({
11792                "nodes": candidate_nodes,
11793                "edges": candidate_edges,
11794            });
11795            if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
11796                refresh_meta["warm_start"] = serde_json::json!("cache_hit");
11797            }
11798            let refresh = graph_db_backend_eval_refresh_operation(
11799                started.elapsed().as_micros(),
11800                rows,
11801                refresh_meta,
11802            );
11803            let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11804            let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11805                candidate.name(),
11806                "SurrealDB SurrealKV optional adapter spike",
11807                false,
11808                root,
11809                path,
11810                scope,
11811                targets,
11812                depth,
11813                limit,
11814                impact_limit,
11815                &store,
11816                freshness,
11817                refresh.0,
11818                Some(refresh.1),
11819                Some(&sqlite_signatures),
11820                extra_warnings.clone(),
11821                prepared,
11822                "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",
11823                "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
11824                "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
11825            );
11826            backends.push(candidate_report);
11827            continue;
11828        }
11829        let started = Instant::now();
11830        let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
11831        let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11832        let rows = candidate_nodes + candidate_edges;
11833        let refresh = graph_db_backend_eval_refresh_operation(
11834            started.elapsed().as_micros(),
11835            rows,
11836            serde_json::json!({
11837                "nodes": candidate_nodes,
11838                "edges": candidate_edges,
11839            }),
11840        );
11841        let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11842        let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11843            candidate.name(),
11844            candidate.adapter_label(),
11845            true,
11846            root,
11847            path,
11848            scope,
11849            targets,
11850            depth,
11851            limit,
11852            impact_limit,
11853            &store,
11854            freshness,
11855            refresh.0,
11856            Some(refresh.1),
11857            Some(&sqlite_signatures),
11858            extra_warnings.clone(),
11859            prepared,
11860            candidate.projection_load(),
11861            candidate.lock_behavior(),
11862            candidate.install_portability(),
11863        );
11864        backends.push(candidate_report);
11865    }
11866
11867    Ok(GraphDbBackendEvalDataset {
11868        name: name.to_string(),
11869        target_count: targets.len(),
11870        nodes,
11871        edges,
11872        backends,
11873    })
11874}
11875
11876pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
11877    println!(
11878        "graph-db backend-eval baseline:{} candidates:{}",
11879        report.baseline_backend,
11880        report.candidates.join(", ")
11881    );
11882    for phase in &report.phase_timings {
11883        println!(
11884            "phase:{} {}us {}",
11885            phase.name, phase.duration_micros, phase.detail
11886        );
11887    }
11888    for dataset in &report.datasets {
11889        println!(
11890            "dataset:{} targets:{} rows:{}",
11891            dataset.name,
11892            dataset.target_count,
11893            dataset.nodes + dataset.edges
11894        );
11895        for backend in &dataset.backends {
11896            println!(
11897                "  backend:{} total:{}us parity:{}",
11898                backend.backend, backend.total_micros, backend.parity.matches_sqlite
11899            );
11900            println!("    projection-load: {}", backend.projection_load);
11901            println!("    lock-behavior: {}", backend.lock_behavior);
11902            println!("    install-portability: {}", backend.install_portability);
11903            for operation in &backend.operations {
11904                println!(
11905                    "    {} {} {}us",
11906                    operation.name, operation.status, operation.duration_micros
11907                );
11908            }
11909            for diagnostic in &backend.parity.diagnostics {
11910                println!("    parity: {diagnostic}");
11911            }
11912        }
11913    }
11914    for decision in &report.promotion {
11915        println!("promotion {}: {}", decision.backend, decision.decision);
11916        println!("  gate: {}", decision.gate.status);
11917        for reason in &decision.reasons {
11918            println!("  reason: {reason}");
11919        }
11920        for check in &decision.gate.required_checks {
11921            println!("  check: {check}");
11922        }
11923    }
11924    println!("metric-digest: {}", report.metric_digest_command);
11925    println!(
11926        "repeat-samples: {}",
11927        report.performance_gate.repeated_sample_command
11928    );
11929}
11930
11931fn traversal_expand_command(root: &Path, handle: &str) -> String {
11932    format!(
11933        "tsift traverse {} --path {} --depth 1 --limit 50",
11934        shell_quote(handle),
11935        shell_quote(root.to_string_lossy().as_ref())
11936    )
11937}
11938
11939fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
11940    let display = relativize(file, root);
11941    let handle = stable_handle("gfil", &format!("file:{display}"));
11942    TraversalNode {
11943        handle: handle.clone(),
11944        kind: "file".to_string(),
11945        label: display.clone(),
11946        ref_id: Some(display.clone()),
11947        path: Some(display),
11948        line: None,
11949        detail: None,
11950        properties: BTreeMap::new(),
11951        expand: traversal_expand_command(root, &handle),
11952    }
11953}
11954
11955fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
11956    let mut node = traversal_file_node(root, file);
11957    if let Some(path) = node.path.clone() {
11958        node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
11959        node.expand = source_read_command(root, &path, 1, 80);
11960    }
11961    node
11962}
11963
11964fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
11965    let file = relativize(&symbol.file, root);
11966    let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
11967    let handle = stable_handle("gsym", &key);
11968    TraversalNode {
11969        handle: handle.clone(),
11970        kind: "symbol".to_string(),
11971        label: symbol.name.clone(),
11972        ref_id: Some(symbol.name.clone()),
11973        path: Some(file),
11974        line: Some(symbol.line),
11975        detail: Some(format!("{} {}", symbol.language, symbol.kind)),
11976        properties: BTreeMap::new(),
11977        expand: traversal_expand_command(root, &handle),
11978    }
11979}
11980
11981fn traversal_ast_span_expand_command(
11982    root: &Path,
11983    file: &str,
11984    symbol: &index::StoredSymbol,
11985    span: &AstSpanPreview,
11986) -> String {
11987    if symbol.language == "markdown" {
11988        markdown_ast_command(root, file, Some(&span.handle))
11989    } else {
11990        let line_count = span
11991            .end_line
11992            .saturating_sub(span.start_line)
11993            .saturating_add(1)
11994            .max(1);
11995        source_read_command(root, file, span.start_line, line_count)
11996    }
11997}
11998
11999fn traversal_ast_span_node(
12000    root: &Path,
12001    symbol: &index::StoredSymbol,
12002    source: &[u8],
12003    symbols: &[index::StoredSymbol],
12004) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
12005    let span = stored_symbol_ast_span(symbol, source, symbols, usize::MAX)?;
12006    let file = relativize(&symbol.file, root);
12007    let mut properties = BTreeMap::new();
12008    properties.insert("layer".to_string(), "ast_navigation".to_string());
12009    properties.insert("language".to_string(), symbol.language.clone());
12010    properties.insert("symbol_kind".to_string(), symbol.kind.clone());
12011    properties.insert("node_kind".to_string(), span.node_kind.clone());
12012    properties.insert("start_byte".to_string(), span.start_byte.to_string());
12013    properties.insert("end_byte".to_string(), span.end_byte.to_string());
12014    properties.insert("end_line".to_string(), span.end_line.to_string());
12015    if let Some(body_start_byte) = span.body_start_byte {
12016        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12017    }
12018    if let Some(body_end_byte) = span.body_end_byte {
12019        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12020    }
12021    if let Some(body_start_line) = span.body_start_line {
12022        properties.insert("body_start_line".to_string(), body_start_line.to_string());
12023    }
12024    if let Some(body_end_line) = span.body_end_line {
12025        properties.insert("body_end_line".to_string(), body_end_line.to_string());
12026    }
12027    if let Some(parent_handle) = &span.parent_handle {
12028        properties.insert("parent_handle".to_string(), parent_handle.clone());
12029    }
12030    if !span.child_handles.is_empty() {
12031        properties.insert("child_handles".to_string(), span.child_handles.join(","));
12032    }
12033    if let Some(parent_module) = &symbol.parent_module {
12034        properties.insert("parent_module".to_string(), parent_module.clone());
12035    }
12036    if let Some(markdown) = &span.markdown {
12037        properties.insert(
12038            "markdown_block_kind".to_string(),
12039            markdown_ast_block_kind(&symbol.kind),
12040        );
12041        if let Some(heading_level) = markdown.heading_level {
12042            properties.insert("heading_level".to_string(), heading_level.to_string());
12043        }
12044        if !markdown.section_path.is_empty() {
12045            properties.insert(
12046                "section_path".to_string(),
12047                markdown.section_path.join(" > "),
12048            );
12049        }
12050        if let Some(section_handle) = &markdown.section_handle {
12051            properties.insert("section_handle".to_string(), section_handle.clone());
12052        }
12053        if let Some(list_depth) = markdown.list_depth {
12054            properties.insert("list_depth".to_string(), list_depth.to_string());
12055        }
12056        if let Some(fence_language) = &markdown.fence_language {
12057            properties.insert("fence_language".to_string(), fence_language.clone());
12058        }
12059    }
12060
12061    let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
12062    let node = TraversalNode {
12063        handle: span.handle.clone(),
12064        kind: "ast_span".to_string(),
12065        label: symbol.name.clone(),
12066        ref_id: Some(symbol.name.clone()),
12067        path: Some(file.clone()),
12068        line: Some(line),
12069        detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
12070        properties,
12071        expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
12072    };
12073    let entry = TraversalAstSpanIndexEntry {
12074        handle: span.handle,
12075        symbol_handle: String::new(),
12076        file_handle: None,
12077        file,
12078        name: symbol.name.clone(),
12079        kind: symbol.kind.clone(),
12080        language: symbol.language.clone(),
12081        node_kind: span.node_kind,
12082        start_byte: span.start_byte,
12083        end_byte: span.end_byte,
12084        parent_module: symbol.parent_module.clone(),
12085        markdown: span.markdown,
12086    };
12087    Some((node, entry))
12088}
12089
12090fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
12091    let handle = stable_handle("gsym", &format!("symbol:{name}"));
12092    TraversalNode {
12093        handle: handle.clone(),
12094        kind: "symbol".to_string(),
12095        label: name.to_string(),
12096        ref_id: Some(name.to_string()),
12097        path: None,
12098        line: None,
12099        detail: Some("unresolved call target".to_string()),
12100        properties: BTreeMap::new(),
12101        expand: traversal_expand_command(root, &handle),
12102    }
12103}
12104
12105fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12106    let file = relativize(&route.file, root);
12107    let method = route.method.as_deref().unwrap_or("any");
12108    let key = format!(
12109        "route:{file}:{}:{}:{}",
12110        route.line, method, route.route_path
12111    );
12112    let handle = stable_handle("grte", &key);
12113    TraversalNode {
12114        handle: handle.clone(),
12115        kind: "route".to_string(),
12116        label: format!("{} {}", method.to_uppercase(), route.route_path),
12117        ref_id: Some(route.route_path.clone()),
12118        path: Some(file),
12119        line: Some(route.line),
12120        detail: Some(format!(
12121            "{} route handled by {}",
12122            route.framework, route.handler_name
12123        )),
12124        properties: BTreeMap::new(),
12125        expand: traversal_expand_command(root, &handle),
12126    }
12127}
12128
12129fn traversal_cargo_workspace_node(
12130    root: &Path,
12131    workspace: &multiplicity::CargoWorkspaceInfo,
12132) -> TraversalNode {
12133    let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12134        .to_string_lossy()
12135        .replace('\\', "/");
12136    let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12137        .to_string_lossy()
12138        .replace('\\', "/");
12139    let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12140    let mut properties = BTreeMap::new();
12141    properties.insert("layer".to_string(), "cargo_workspace".to_string());
12142    properties.insert("workspace_root".to_string(), workspace_root.clone());
12143    properties.insert("members".to_string(), workspace.members.join(","));
12144    properties.insert(
12145        "default_members".to_string(),
12146        workspace.default_members.join(","),
12147    );
12148    TraversalNode {
12149        handle: handle.clone(),
12150        kind: "cargo_workspace".to_string(),
12151        label: if workspace_root.is_empty() {
12152            "root cargo workspace".to_string()
12153        } else {
12154            workspace_root
12155        },
12156        ref_id: Some(workspace.id.clone()),
12157        path: Some(manifest),
12158        line: None,
12159        detail: Some("Cargo workspace manifest".to_string()),
12160        properties,
12161        expand: traversal_expand_command(root, &handle),
12162    }
12163}
12164
12165fn traversal_cargo_package_node(
12166    root: &Path,
12167    package: &multiplicity::CargoPackageInfo,
12168) -> TraversalNode {
12169    let manifest = relativize_pathbuf(&package.manifest_path, root)
12170        .to_string_lossy()
12171        .replace('\\', "/");
12172    let package_root = relativize_pathbuf(&package.package_root, root)
12173        .to_string_lossy()
12174        .replace('\\', "/");
12175    let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12176        .to_string_lossy()
12177        .replace('\\', "/");
12178    let handle = stable_handle(
12179        "gcpk",
12180        &format!("cargo-package:{manifest}:{}", package.name),
12181    );
12182    let mut properties = BTreeMap::new();
12183    properties.insert("layer".to_string(), "cargo_package".to_string());
12184    properties.insert("package_name".to_string(), package.name.clone());
12185    properties.insert(
12186        "normalized_name".to_string(),
12187        package.normalized_name.clone(),
12188    );
12189    properties.insert("package_root".to_string(), package_root.clone());
12190    properties.insert("workspace_root".to_string(), workspace_root);
12191    properties.insert("features".to_string(), package.features.join(","));
12192    properties.insert("targets".to_string(), package.targets.join(","));
12193    properties.insert(
12194        "dependencies".to_string(),
12195        package
12196            .dependencies
12197            .iter()
12198            .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12199            .collect::<Vec<_>>()
12200            .join(","),
12201    );
12202    TraversalNode {
12203        handle: handle.clone(),
12204        kind: "cargo_package".to_string(),
12205        label: package.name.clone(),
12206        ref_id: Some(package.scope_id.clone()),
12207        path: Some(manifest),
12208        line: None,
12209        detail: Some(format!(
12210            "Cargo package in {}",
12211            if package_root.is_empty() {
12212                "."
12213            } else {
12214                package_root.as_str()
12215            }
12216        )),
12217        properties,
12218        expand: traversal_expand_command(root, &handle),
12219    }
12220}
12221
12222fn traversal_session_node(
12223    root: &Path,
12224    markdown_path: &Path,
12225    session_id: Option<&str>,
12226) -> TraversalNode {
12227    let display = relativize_pathbuf(markdown_path, root)
12228        .to_string_lossy()
12229        .replace('\\', "/");
12230    let handle = stable_handle("gses", &format!("session:{display}"));
12231    TraversalNode {
12232        handle: handle.clone(),
12233        kind: "session".to_string(),
12234        label: session_id.unwrap_or(&display).to_string(),
12235        ref_id: session_id.map(str::to_string),
12236        path: Some(display),
12237        line: None,
12238        detail: Some("agent-doc session artifact".to_string()),
12239        properties: BTreeMap::new(),
12240        expand: traversal_expand_command(root, &handle),
12241    }
12242}
12243
12244fn traversal_backlog_node(
12245    root: &Path,
12246    markdown_path: &Path,
12247    id: &str,
12248    text: &str,
12249    line: i64,
12250) -> TraversalNode {
12251    let display = relativize_pathbuf(markdown_path, root)
12252        .to_string_lossy()
12253        .replace('\\', "/");
12254    let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12255    TraversalNode {
12256        handle: handle.clone(),
12257        kind: "backlog".to_string(),
12258        label: format!("#{id}"),
12259        ref_id: Some(id.to_string()),
12260        path: Some(display),
12261        line: Some(line),
12262        detail: Some(text.to_string()),
12263        properties: BTreeMap::new(),
12264        expand: traversal_expand_command(root, &handle),
12265    }
12266}
12267
12268fn traversal_job_packet_node(
12269    root: &Path,
12270    markdown_path: &Path,
12271    label: &str,
12272    ref_id: Option<&str>,
12273    detail: &str,
12274    line: i64,
12275) -> TraversalNode {
12276    let display = relativize_pathbuf(markdown_path, root)
12277        .to_string_lossy()
12278        .replace('\\', "/");
12279    let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12280    TraversalNode {
12281        handle: handle.clone(),
12282        kind: "job_packet".to_string(),
12283        label: label.to_string(),
12284        ref_id: ref_id.map(str::to_string),
12285        path: Some(display),
12286        line: Some(line),
12287        detail: Some(detail.to_string()),
12288        properties: BTreeMap::new(),
12289        expand: traversal_expand_command(root, &handle),
12290    }
12291}
12292
12293#[derive(Clone, Debug)]
12294struct ParsedWorkerResult {
12295    id: String,
12296    status: String,
12297    touched_files: Vec<String>,
12298    tests: Vec<String>,
12299    follow_up_ids: Vec<String>,
12300}
12301
12302fn traversal_worker_result_node(
12303    root: &Path,
12304    markdown_path: &Path,
12305    parsed: &ParsedWorkerResult,
12306    line_text: &str,
12307    line: i64,
12308) -> TraversalNode {
12309    let display = relativize_pathbuf(markdown_path, root)
12310        .to_string_lossy()
12311        .replace('\\', "/");
12312    let handle = stable_handle(
12313        "wres",
12314        &format!(
12315            "worker-result:{display}:{}:{}:{}",
12316            parsed.id, parsed.status, line
12317        ),
12318    );
12319    let mut properties = BTreeMap::new();
12320    properties.insert("status".to_string(), parsed.status.clone());
12321    if !parsed.touched_files.is_empty() {
12322        properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12323    }
12324    if !parsed.tests.is_empty() {
12325        properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12326    }
12327    if !parsed.follow_up_ids.is_empty() {
12328        properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12329    }
12330    TraversalNode {
12331        handle: handle.clone(),
12332        kind: "worker_result".to_string(),
12333        label: format!("{} #{}", parsed.status, parsed.id),
12334        ref_id: Some(parsed.id.clone()),
12335        path: Some(display),
12336        line: Some(line),
12337        detail: Some(line_text.trim().to_string()),
12338        properties,
12339        expand: traversal_expand_command(root, &handle),
12340    }
12341}
12342
12343fn traversal_tokens(input: &str) -> BTreeSet<String> {
12344    input
12345        .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
12346        .flat_map(|part| part.split(['_', '-']))
12347        .map(str::trim)
12348        .filter(|part| part.len() >= 3)
12349        .map(|part| part.to_ascii_lowercase())
12350        .collect()
12351}
12352
12353fn traversal_ast_span_contains(
12354    parent: &TraversalAstSpanIndexEntry,
12355    child: &TraversalAstSpanIndexEntry,
12356) -> bool {
12357    parent.handle != child.handle
12358        && parent.file == child.file
12359        && parent.start_byte <= child.start_byte
12360        && parent.end_byte >= child.end_byte
12361}
12362
12363fn traversal_ast_parent_handle<'a>(
12364    entry: &TraversalAstSpanIndexEntry,
12365    entries: &'a [TraversalAstSpanIndexEntry],
12366) -> Option<&'a str> {
12367    entries
12368        .iter()
12369        .filter(|candidate| traversal_ast_span_contains(candidate, entry))
12370        .min_by_key(|candidate| {
12371            (
12372                candidate.end_byte.saturating_sub(candidate.start_byte),
12373                candidate.start_byte,
12374                candidate.end_byte,
12375                candidate.kind.as_str(),
12376                candidate.name.as_str(),
12377                candidate.node_kind.as_str(),
12378            )
12379        })
12380        .map(|candidate| candidate.handle.as_str())
12381}
12382
12383fn traversal_ast_enclosing_module_handle<'a>(
12384    entry: &TraversalAstSpanIndexEntry,
12385    entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
12386    parent_by_handle: &BTreeMap<String, String>,
12387) -> Option<&'a str> {
12388    let mut current = parent_by_handle.get(&entry.handle);
12389    while let Some(handle) = current {
12390        let Some(parent) = entries_by_handle.get(handle) else {
12391            break;
12392        };
12393        if matches!(parent.kind.as_str(), "module" | "mod")
12394            || entry
12395                .parent_module
12396                .as_deref()
12397                .is_some_and(|module| module == parent.name)
12398        {
12399            return Some(parent.handle.as_str());
12400        }
12401        current = parent_by_handle.get(&parent.handle);
12402    }
12403    None
12404}
12405
12406fn link_ast_navigation_edges(
12407    graph: &mut TraversalGraphBuild,
12408    entries: &[TraversalAstSpanIndexEntry],
12409) {
12410    let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
12411    let entries_by_handle = entries
12412        .iter()
12413        .map(|entry| (entry.handle.clone(), entry.clone()))
12414        .collect::<BTreeMap<_, _>>();
12415    let mut parent_by_handle = BTreeMap::<String, String>::new();
12416    let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
12417
12418    for entry in entries {
12419        entries_by_file
12420            .entry(entry.file.clone())
12421            .or_default()
12422            .push(entry.clone());
12423    }
12424
12425    for file_entries in entries_by_file.values() {
12426        for entry in file_entries {
12427            let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
12428            if let Some(parent) = &parent {
12429                parent_by_handle.insert(entry.handle.clone(), parent.clone());
12430            }
12431            let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
12432            children_by_parent
12433                .entry(sibling_key)
12434                .or_default()
12435                .push(entry.clone());
12436        }
12437    }
12438
12439    for entry in entries {
12440        let parent = parent_by_handle.get(&entry.handle);
12441        if let Some(parent) = parent {
12442            graph.add_edge(
12443                parent,
12444                &entry.handle,
12445                "contains",
12446                Some("AST parent contains child span".to_string()),
12447                1,
12448            );
12449            graph.add_edge(
12450                parent,
12451                &entry.handle,
12452                "child",
12453                Some("AST child span".to_string()),
12454                1,
12455            );
12456            graph.add_edge(
12457                &entry.handle,
12458                parent,
12459                "parent",
12460                Some("AST parent span".to_string()),
12461                1,
12462            );
12463        } else if let Some(file_handle) = &entry.file_handle {
12464            graph.add_edge(
12465                file_handle,
12466                &entry.handle,
12467                "contains",
12468                Some("file contains top-level AST span".to_string()),
12469                1,
12470            );
12471        }
12472
12473        if let Some(module_handle) =
12474            traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
12475        {
12476            graph.add_edge(
12477                &entry.handle,
12478                module_handle,
12479                "enclosing_module",
12480                Some("nearest enclosing module AST span".to_string()),
12481                1,
12482            );
12483        }
12484
12485        if entry.language == "markdown"
12486            && let Some(markdown) = &entry.markdown
12487            && let Some(section_handle) = &markdown.section_handle
12488            && section_handle != &entry.handle
12489        {
12490            graph.add_edge(
12491                section_handle,
12492                &entry.handle,
12493                "contains_markdown_block",
12494                Some("Markdown section contains block".to_string()),
12495                1,
12496            );
12497            graph.add_edge(
12498                &entry.handle,
12499                section_handle,
12500                "enclosing_section",
12501                Some("Markdown enclosing section".to_string()),
12502                1,
12503            );
12504        }
12505    }
12506
12507    for siblings in children_by_parent.values_mut() {
12508        siblings.sort_by(|left, right| {
12509            left.start_byte
12510                .cmp(&right.start_byte)
12511                .then(left.end_byte.cmp(&right.end_byte))
12512                .then(left.kind.cmp(&right.kind))
12513                .then(left.name.cmp(&right.name))
12514                .then(left.node_kind.cmp(&right.node_kind))
12515                .then(left.handle.cmp(&right.handle))
12516        });
12517        for pair in siblings.windows(2) {
12518            let previous = &pair[0];
12519            let next = &pair[1];
12520            graph.add_edge(
12521                &previous.handle,
12522                &next.handle,
12523                "next_sibling",
12524                Some("next AST sibling span".to_string()),
12525                1,
12526            );
12527            graph.add_edge(
12528                &next.handle,
12529                &previous.handle,
12530                "previous_sibling",
12531                Some("previous AST sibling span".to_string()),
12532                1,
12533            );
12534        }
12535    }
12536}
12537
12538fn traversal_markdown_embedded_symbol_node(
12539    root: &Path,
12540    entry: &TraversalAstSpanIndexEntry,
12541    markdown: &MarkdownSpanMetadata,
12542    embedded: &MarkdownEmbeddedSymbol,
12543) -> TraversalNode {
12544    let mut properties = BTreeMap::new();
12545    properties.insert("layer".to_string(), "embedded_code".to_string());
12546    properties.insert("embedded".to_string(), "true".to_string());
12547    properties.insert("language".to_string(), embedded.language.clone());
12548    properties.insert("symbol_kind".to_string(), embedded.kind.clone());
12549    properties.insert("node_kind".to_string(), embedded.node_kind.clone());
12550    properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
12551    properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
12552    properties.insert("end_line".to_string(), embedded.end_line.to_string());
12553    properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
12554    properties.insert(
12555        "markdown_block_kind".to_string(),
12556        markdown_ast_block_kind(&entry.kind),
12557    );
12558    if let Some(body_start_byte) = embedded.body_start_byte {
12559        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12560    }
12561    if let Some(body_end_byte) = embedded.body_end_byte {
12562        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12563    }
12564    if let Some(body_start_line) = embedded.body_start_line {
12565        properties.insert("body_start_line".to_string(), body_start_line.to_string());
12566    }
12567    if let Some(body_end_line) = embedded.body_end_line {
12568        properties.insert("body_end_line".to_string(), body_end_line.to_string());
12569    }
12570    if let Some(fence_language) = &markdown.fence_language {
12571        properties.insert("fence_language".to_string(), fence_language.clone());
12572    }
12573    if !markdown.section_path.is_empty() {
12574        properties.insert(
12575            "section_path".to_string(),
12576            markdown.section_path.join(" > "),
12577        );
12578    }
12579    if let Some(section_handle) = &markdown.section_handle {
12580        properties.insert("section_handle".to_string(), section_handle.clone());
12581    }
12582    let line_count = embedded
12583        .end_line
12584        .saturating_sub(embedded.start_line)
12585        .saturating_add(1)
12586        .max(1);
12587    TraversalNode {
12588        handle: embedded.handle.clone(),
12589        kind: "ast_span".to_string(),
12590        label: embedded.name.clone(),
12591        ref_id: Some(embedded.name.clone()),
12592        path: Some(entry.file.clone()),
12593        line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
12594        detail: Some(format!(
12595            "{} {} embedded in Markdown fence",
12596            embedded.language, embedded.kind
12597        )),
12598        properties,
12599        expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
12600    }
12601}
12602
12603fn link_markdown_embedded_code_edges(
12604    graph: &mut TraversalGraphBuild,
12605    root: &Path,
12606    entries: &[TraversalAstSpanIndexEntry],
12607) {
12608    for entry in entries {
12609        let Some(markdown) = &entry.markdown else {
12610            continue;
12611        };
12612        for embedded in &markdown.embedded_symbols {
12613            let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
12614            graph.add_node(node);
12615            graph.add_edge(
12616                &entry.handle,
12617                &embedded.handle,
12618                "contains",
12619                Some("Markdown fence contains embedded AST symbol".to_string()),
12620                1,
12621            );
12622            graph.add_edge(
12623                &entry.handle,
12624                &embedded.handle,
12625                "child",
12626                Some("embedded code symbol".to_string()),
12627                1,
12628            );
12629            graph.add_edge(
12630                &entry.handle,
12631                &embedded.handle,
12632                "contains_embedded_symbol",
12633                Some("Markdown fence contains embedded code symbol".to_string()),
12634                1,
12635            );
12636            graph.add_edge(
12637                &embedded.handle,
12638                &entry.handle,
12639                "parent",
12640                Some("Markdown fence parent span".to_string()),
12641                1,
12642            );
12643            graph.add_edge(
12644                &embedded.handle,
12645                &entry.handle,
12646                "embedded_in_fence",
12647                Some("embedded code symbol belongs to Markdown fence".to_string()),
12648                1,
12649            );
12650            if let Some(section_handle) = &markdown.section_handle
12651                && section_handle != &entry.handle
12652            {
12653                graph.add_edge(
12654                    section_handle,
12655                    &embedded.handle,
12656                    "contains_embedded_code",
12657                    Some("Markdown section contains embedded code symbol".to_string()),
12658                    1,
12659                );
12660                graph.add_edge(
12661                    &embedded.handle,
12662                    section_handle,
12663                    "enclosing_section",
12664                    Some("Markdown enclosing section".to_string()),
12665                    1,
12666                );
12667            }
12668        }
12669    }
12670}
12671
12672fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
12673    let mut tokens = traversal_tokens(&node.label);
12674    if let Some(ref_id) = &node.ref_id {
12675        tokens.extend(traversal_tokens(ref_id));
12676    }
12677    if let Some(path) = &node.path {
12678        tokens.extend(traversal_tokens(path));
12679    }
12680    if let Some(detail) = &node.detail {
12681        tokens.extend(traversal_tokens(detail));
12682    }
12683    tokens
12684}
12685
12686fn markdown_code_spans(input: &str) -> Vec<String> {
12687    input
12688        .split('`')
12689        .enumerate()
12690        .filter(|(idx, _)| idx % 2 == 1)
12691        .map(|(_, part)| part.trim().to_string())
12692        .filter(|part| !part.is_empty())
12693        .collect()
12694}
12695
12696fn push_traversal_token_index(
12697    index: &mut HashMap<String, Vec<usize>>,
12698    tokens: &BTreeSet<String>,
12699    entry_index: usize,
12700) {
12701    for token in tokens {
12702        index.entry(token.clone()).or_default().push(entry_index);
12703    }
12704}
12705
12706impl<'a> TraversalCodeLookup<'a> {
12707    fn new(
12708        symbols: &'a [TraversalSymbolIndexEntry],
12709        files: &'a [TraversalFileIndexEntry],
12710        routes: &'a [TraversalRouteIndexEntry],
12711        multiplicities: &'a [TraversalMultiplicityIndexEntry],
12712    ) -> Self {
12713        let mut symbol_index = HashMap::new();
12714        for (idx, entry) in symbols.iter().enumerate() {
12715            push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
12716        }
12717        let mut file_index = HashMap::new();
12718        let mut file_path_index = HashMap::new();
12719        for (idx, entry) in files.iter().enumerate() {
12720            push_traversal_token_index(&mut file_index, &entry.tokens, idx);
12721            if let Some(path) = entry.node.path.as_ref() {
12722                file_path_index.insert(path.clone(), path.clone());
12723            }
12724        }
12725        let mut route_index = HashMap::new();
12726        for (idx, entry) in routes.iter().enumerate() {
12727            push_traversal_token_index(&mut route_index, &entry.tokens, idx);
12728        }
12729        let mut multiplicity_index = HashMap::new();
12730        for (idx, entry) in multiplicities.iter().enumerate() {
12731            push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
12732        }
12733        Self {
12734            symbols,
12735            files,
12736            routes,
12737            multiplicities,
12738            symbol_index,
12739            file_index,
12740            route_index,
12741            multiplicity_index,
12742            file_path_index,
12743        }
12744    }
12745
12746    fn touched_files_for_line(&self, line: &str) -> Vec<String> {
12747        let mut touched_files = BTreeSet::new();
12748        for candidate in markdown_code_spans(line)
12749            .into_iter()
12750            .chain(line.split_whitespace().map(str::to_string))
12751        {
12752            for path in traversal_path_candidates(&candidate) {
12753                if let Some(file) = self.file_path_index.get(&path) {
12754                    touched_files.insert(file.clone());
12755                }
12756            }
12757        }
12758        touched_files.into_iter().collect()
12759    }
12760}
12761
12762fn traversal_path_candidates(candidate: &str) -> Vec<String> {
12763    let trimmed = candidate.trim_matches(|ch: char| {
12764        matches!(
12765            ch,
12766            '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
12767        )
12768    });
12769    if trimmed.is_empty() {
12770        return Vec::new();
12771    }
12772    let mut candidates = vec![trimmed.to_string()];
12773    if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
12774        && !path.is_empty()
12775        && line_suffix.chars().all(|ch| ch.is_ascii_digit())
12776    {
12777        candidates.push(path.to_string());
12778    }
12779    candidates
12780}
12781
12782fn parse_worker_result_line(
12783    line: &str,
12784    lookup: &TraversalCodeLookup<'_>,
12785) -> Vec<ParsedWorkerResult> {
12786    if line.trim_start().starts_with("- [") {
12787        return Vec::new();
12788    }
12789    let lower = line.to_ascii_lowercase();
12790    let status =
12791        if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
12792        {
12793            "completed"
12794        } else if lower.contains("blocked") || lower.contains("externally blocked") {
12795            "blocked"
12796        } else {
12797            return Vec::new();
12798        };
12799    let result_prefix_end = ["follow-up", "follow up", "next:"]
12800        .iter()
12801        .filter_map(|marker| lower.find(marker))
12802        .min()
12803        .unwrap_or(line.len());
12804    let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
12805    if ids.is_empty() {
12806        return Vec::new();
12807    }
12808    let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
12809    let all_ids = extract_conflict_target_refs(line);
12810
12811    let touched_files = lookup.touched_files_for_line(line);
12812    let tests = markdown_code_spans(line)
12813        .into_iter()
12814        .filter(|span| span.to_ascii_lowercase().contains("test"))
12815        .collect::<Vec<_>>();
12816
12817    ids.iter()
12818        .map(|id| ParsedWorkerResult {
12819            id: id.clone(),
12820            status: status.to_string(),
12821            touched_files: touched_files.clone(),
12822            tests: tests.clone(),
12823            follow_up_ids: all_ids
12824                .iter()
12825                .filter(|other| *other != id && !result_ids.contains(*other))
12826                .cloned()
12827                .collect(),
12828        })
12829        .collect()
12830}
12831
12832fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
12833    let hinted_path = if path_hint.is_absolute() {
12834        path_hint.to_path_buf()
12835    } else {
12836        root.join(path_hint)
12837    };
12838    if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
12839        return Some(hinted_path);
12840    }
12841    None
12842}
12843
12844fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
12845    let candidate = if path.is_absolute() {
12846        path.to_path_buf()
12847    } else {
12848        source_root.join(path)
12849    };
12850    if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
12851        return false;
12852    }
12853    if !matches!(
12854        candidate.extension().and_then(|ext| ext.to_str()),
12855        Some("md" | "mdx")
12856    ) {
12857        return false;
12858    }
12859    fs::read_to_string(&candidate)
12860        .map(|content| session_markdown::markdown_content_looks_like_agent_doc_session(&content))
12861        .unwrap_or(false)
12862}
12863
12864fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
12865    if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
12866        return Ok(vec![hinted_path]);
12867    }
12868    let mut files = Vec::new();
12869    let walker = ignore::WalkBuilder::new(root)
12870        .hidden(true)
12871        .git_ignore(true)
12872        .git_global(true)
12873        .git_exclude(true)
12874        .build();
12875    for result in walker {
12876        let entry =
12877            result.with_context(|| format!("walking markdown files under {}", root.display()))?;
12878        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
12879            continue;
12880        }
12881        if traversal_path_is_generated_artifact(root, root, entry.path()) {
12882            continue;
12883        }
12884        if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
12885            files.push(entry.path().to_path_buf());
12886        }
12887    }
12888    files.sort();
12889    Ok(files)
12890}
12891
12892fn traversal_watermark_path(root: &Path, path: &Path) -> String {
12893    path.strip_prefix(root)
12894        .unwrap_or(path)
12895        .to_string_lossy()
12896        .replace('\\', "/")
12897}
12898
12899fn push_traversal_metadata_watermark_part(
12900    root: &Path,
12901    path: &Path,
12902    label: &str,
12903    parts: &mut Vec<String>,
12904) {
12905    let display = traversal_watermark_path(root, path);
12906    match fs::metadata(path) {
12907        Ok(metadata) => {
12908            let (secs, nanos) = metadata
12909                .modified()
12910                .ok()
12911                .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
12912                .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
12913                .unwrap_or((0, 0));
12914            parts.push(format!(
12915                "{label}:{display}:len={}:mtime={secs}.{nanos}",
12916                metadata.len()
12917            ));
12918        }
12919        Err(_) => parts.push(format!("{label}:{display}:missing")),
12920    }
12921}
12922
12923#[derive(Serialize)]
12924struct TraversalSummaryWatermarkRow<'a> {
12925    symbol_name: &'a str,
12926    file_path: &'a str,
12927    entities: &'a Option<Vec<summarize::Entity>>,
12928    relationships: &'a Option<Vec<summarize::Relationship>>,
12929    concept_labels: &'a Option<Vec<String>>,
12930}
12931
12932fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
12933    let summaries_db = root.join(".tsift/summaries.db");
12934    if !summaries_db.exists() {
12935        parts.push("summaries_db:absent".to_string());
12936        return Ok(());
12937    }
12938
12939    match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
12940        .and_then(|summary_db| summary_db.all())
12941    {
12942        Ok(summaries) => {
12943            let rows = summaries
12944                .iter()
12945                .map(|summary| TraversalSummaryWatermarkRow {
12946                    symbol_name: &summary.symbol_name,
12947                    file_path: &summary.file_path,
12948                    entities: &summary.entities,
12949                    relationships: &summary.relationships,
12950                    concept_labels: &summary.concept_labels,
12951                })
12952                .collect::<Vec<_>>();
12953            parts.push(format!(
12954                "summaries_db:rows={}:semantic_hash={}",
12955                rows.len(),
12956                content_hash(&rows)?
12957            ));
12958        }
12959        Err(_) => {
12960            push_traversal_metadata_watermark_part(
12961                root,
12962                &summaries_db,
12963                "summaries_db_unreadable",
12964                parts,
12965            );
12966        }
12967    }
12968    Ok(())
12969}
12970
12971#[cfg(test)]
12972fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
12973    resolution::relative_path_is_generated_artifact(relative)
12974}
12975
12976fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
12977    resolution::path_is_generated_artifact(root, source_root, path)
12978}
12979
12980fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
12981    resolution::index_snapshot_part_is_generated(root, source_root, part)
12982}
12983
12984pub(crate) fn traversal_source_watermark(
12985    root: &Path,
12986    path_hint: &Path,
12987    scope: Option<&str>,
12988    session_only: bool,
12989) -> Result<Option<String>> {
12990    let mut parts = vec![
12991        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
12992        format!("scope:{}", scope.unwrap_or("root")),
12993        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
12994        format!("session_only:{session_only}"),
12995    ];
12996
12997    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
12998        let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
12999            Ok(targets) => targets,
13000            Err(_) => return Ok(None),
13001        };
13002        let Some(target) = targets.into_iter().next() else {
13003            return Ok(None);
13004        };
13005        let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
13006            Ok(db) => db,
13007            Err(_) => return Ok(None),
13008        };
13009        parts.push(format!("index_label:{}", target.label));
13010        parts.push(format!(
13011            "index_scope:{}",
13012            target.scope_name.as_deref().unwrap_or("root")
13013        ));
13014        parts.push(format!(
13015            "index_source_root:{}",
13016            traversal_watermark_path(root, &target.source_root)
13017        ));
13018        let mut snapshot_rows = 0usize;
13019        for part in db.source_snapshot_parts()? {
13020            if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
13021                continue;
13022            }
13023            snapshot_rows += 1;
13024            parts.push(format!("index_snapshot:{part}"));
13025        }
13026        parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
13027    }
13028
13029    let markdown_files = markdown_files_for_traversal(root, path_hint)?;
13030    parts.push(format!("markdown_count:{}", markdown_files.len()));
13031    for markdown_path in markdown_files {
13032        push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
13033    }
13034
13035    push_traversal_summaries_watermark_part(root, &mut parts)?;
13036
13037    Ok(Some(content_hash(&parts)?))
13038}
13039
13040fn ranked_symbol_matches<'a>(
13041    query_tokens: &BTreeSet<String>,
13042    entries: &'a [TraversalSymbolIndexEntry],
13043    index: &HashMap<String, Vec<usize>>,
13044) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13045    let mut scores = BTreeMap::<usize, usize>::new();
13046    for token in query_tokens {
13047        if let Some(indices) = index.get(token) {
13048            for idx in indices {
13049                *scores.entry(*idx).or_default() += 1;
13050            }
13051        }
13052    }
13053    let mut matches = scores
13054        .into_iter()
13055        .map(|(idx, score)| (score, &entries[idx]))
13056        .collect::<Vec<_>>();
13057    matches.sort_by(|(left_score, left), (right_score, right)| {
13058        right_score
13059            .cmp(left_score)
13060            .then_with(|| left.node.label.cmp(&right.node.label))
13061            .then_with(|| left.handle.cmp(&right.handle))
13062    });
13063    matches
13064}
13065
13066fn ranked_file_matches<'a>(
13067    query_tokens: &BTreeSet<String>,
13068    entries: &'a [TraversalFileIndexEntry],
13069    index: &HashMap<String, Vec<usize>>,
13070) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13071    let mut scores = BTreeMap::<usize, usize>::new();
13072    for token in query_tokens {
13073        if let Some(indices) = index.get(token) {
13074            for idx in indices {
13075                *scores.entry(*idx).or_default() += 1;
13076            }
13077        }
13078    }
13079    let mut matches = scores
13080        .into_iter()
13081        .map(|(idx, score)| (score, &entries[idx]))
13082        .collect::<Vec<_>>();
13083    matches.sort_by(|(left_score, left), (right_score, right)| {
13084        right_score
13085            .cmp(left_score)
13086            .then_with(|| left.node.label.cmp(&right.node.label))
13087            .then_with(|| left.handle.cmp(&right.handle))
13088    });
13089    matches
13090}
13091
13092fn ranked_route_matches<'a>(
13093    query_tokens: &BTreeSet<String>,
13094    entries: &'a [TraversalRouteIndexEntry],
13095    index: &HashMap<String, Vec<usize>>,
13096) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13097    let mut scores = BTreeMap::<usize, usize>::new();
13098    for token in query_tokens {
13099        if let Some(indices) = index.get(token) {
13100            for idx in indices {
13101                *scores.entry(*idx).or_default() += 1;
13102            }
13103        }
13104    }
13105    let mut matches = scores
13106        .into_iter()
13107        .map(|(idx, score)| (score, &entries[idx]))
13108        .collect::<Vec<_>>();
13109    matches.sort_by(|(left_score, left), (right_score, right)| {
13110        right_score
13111            .cmp(left_score)
13112            .then_with(|| left.node.label.cmp(&right.node.label))
13113            .then_with(|| left.handle.cmp(&right.handle))
13114    });
13115    matches
13116}
13117
13118fn ranked_multiplicity_matches<'a>(
13119    query_tokens: &BTreeSet<String>,
13120    entries: &'a [TraversalMultiplicityIndexEntry],
13121    index: &HashMap<String, Vec<usize>>,
13122) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13123    let mut scores = BTreeMap::<usize, usize>::new();
13124    for token in query_tokens {
13125        if let Some(indices) = index.get(token) {
13126            for idx in indices {
13127                *scores.entry(*idx).or_default() += 1;
13128            }
13129        }
13130    }
13131    let mut matches = scores
13132        .into_iter()
13133        .map(|(idx, score)| (score, &entries[idx]))
13134        .collect::<Vec<_>>();
13135    matches.sort_by(|(left_score, left), (right_score, right)| {
13136        right_score
13137            .cmp(left_score)
13138            .then_with(|| left.node.kind.cmp(&right.node.kind))
13139            .then_with(|| left.node.label.cmp(&right.node.label))
13140            .then_with(|| left.handle.cmp(&right.handle))
13141    });
13142    matches
13143}
13144
13145fn link_backlog_to_code_nodes(
13146    graph: &mut TraversalGraphBuild,
13147    backlog: &TraversalNode,
13148    text: &str,
13149    lookup: &TraversalCodeLookup<'_>,
13150    limit: usize,
13151) {
13152    let mut query_tokens = traversal_tokens(text);
13153    if let Some(ref_id) = &backlog.ref_id {
13154        query_tokens.extend(traversal_tokens(ref_id));
13155    }
13156    if query_tokens.is_empty() {
13157        return;
13158    }
13159
13160    for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13161        .into_iter()
13162        .take(limit)
13163    {
13164        graph.add_edge(
13165            &backlog.handle,
13166            &entry.handle,
13167            "mentions",
13168            Some("backlog text matches symbol tokens".to_string()),
13169            score,
13170        );
13171    }
13172
13173    for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13174        .into_iter()
13175        .take(limit.min(5))
13176    {
13177        graph.add_edge(
13178            &backlog.handle,
13179            &entry.handle,
13180            "mentions",
13181            Some("backlog text matches file tokens".to_string()),
13182            score,
13183        );
13184    }
13185
13186    for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13187        .into_iter()
13188        .take(limit.min(5))
13189    {
13190        graph.add_edge(
13191            &backlog.handle,
13192            &entry.handle,
13193            "mentions",
13194            Some("backlog text matches route tokens".to_string()),
13195            score,
13196        );
13197    }
13198
13199    for (score, entry) in ranked_multiplicity_matches(
13200        &query_tokens,
13201        lookup.multiplicities,
13202        &lookup.multiplicity_index,
13203    )
13204    .into_iter()
13205    .take(limit.min(5))
13206    {
13207        graph.add_edge(
13208            &backlog.handle,
13209            &entry.handle,
13210            "mentions",
13211            Some("backlog text matches multiplicity tokens".to_string()),
13212            score,
13213        );
13214    }
13215}
13216
13217fn load_agent_doc_traversal_nodes(
13218    root: &Path,
13219    path_hint: &Path,
13220    graph: &mut TraversalGraphBuild,
13221    lookup: &TraversalCodeLookup<'_>,
13222) -> Result<()> {
13223    for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13224        let content = match fs::read_to_string(&markdown_path) {
13225            Ok(content) => content,
13226            Err(err) => {
13227                graph.warnings.push(format!(
13228                    "session artifact unavailable: {}: {err}",
13229                    markdown_path.display()
13230                ));
13231                continue;
13232            }
13233        };
13234        let Some(document) = AgentDocSessionDocument::parse_if_session(&content) else {
13235            continue;
13236        };
13237
13238        let session = traversal_session_node(root, &markdown_path, document.session_id.as_deref());
13239        graph.add_node(session.clone());
13240        let lines = content.lines().collect::<Vec<_>>();
13241        let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13242        for item in &document.backlog_items {
13243            let backlog = traversal_backlog_node(
13244                root,
13245                &markdown_path,
13246                &item.id,
13247                &item.text,
13248                item.line as i64,
13249            );
13250            graph.add_node(backlog.clone());
13251            backlog_by_id.insert(item.id.clone(), backlog.clone());
13252            graph.add_edge(
13253                &session.handle,
13254                &backlog.handle,
13255                "contains",
13256                Some("session backlog item".to_string()),
13257                1,
13258            );
13259            link_backlog_to_code_nodes(graph, &backlog, &item.text, lookup, 8);
13260        }
13261
13262        let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13263        for item in &document.queue_items {
13264            match item {
13265                AgentDocQueueItem::Dispatch { value, line }
13266                | AgentDocQueueItem::Preset { value, line } => {
13267                    let dispatch_ref = value.strip_prefix('#').unwrap_or(value.as_str());
13268                    let node = traversal_job_packet_node(
13269                        root,
13270                        &markdown_path,
13271                        &format!("dispatch {value}"),
13272                        Some(dispatch_ref),
13273                        "agent-doc dispatch preset",
13274                        *line as i64,
13275                    );
13276                    graph.add_node(node.clone());
13277                    graph.add_edge(
13278                        &session.handle,
13279                        &node.handle,
13280                        "contains",
13281                        Some("session queued dispatch".to_string()),
13282                        1,
13283                    );
13284                }
13285                AgentDocQueueItem::Do { id, line } => {
13286                    let detail = backlog_by_id
13287                        .get(id)
13288                        .and_then(|node| node.detail.clone())
13289                        .unwrap_or_else(|| "queued backlog item".to_string());
13290                    let node = traversal_job_packet_node(
13291                        root,
13292                        &markdown_path,
13293                        &format!("do #{id}"),
13294                        Some(id),
13295                        &detail,
13296                        *line as i64,
13297                    );
13298                    graph.add_node(node.clone());
13299                    graph.add_edge(
13300                        &session.handle,
13301                        &node.handle,
13302                        "contains",
13303                        Some("session queued job packet".to_string()),
13304                        1,
13305                    );
13306                    if let Some(backlog) = backlog_by_id.get(id) {
13307                        graph.add_edge(
13308                            &node.handle,
13309                            &backlog.handle,
13310                            "targets",
13311                            Some("queued backlog item".to_string()),
13312                            1,
13313                        );
13314                    }
13315                    job_by_id.insert(id.clone(), node);
13316                }
13317            }
13318        }
13319
13320        let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13321        for (idx, line) in lines.iter().enumerate() {
13322            for parsed in parse_worker_result_line(line, lookup) {
13323                let line_no = idx as i64 + 1;
13324                if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13325                    continue;
13326                }
13327                let result =
13328                    traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13329                graph.add_node(result.clone());
13330                graph.add_edge(
13331                    &session.handle,
13332                    &result.handle,
13333                    "contains",
13334                    Some("session worker result".to_string()),
13335                    1,
13336                );
13337                if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13338                    graph.add_edge(
13339                        &backlog.handle,
13340                        &result.handle,
13341                        "has_result",
13342                        Some(format!("worker result {}", parsed.status)),
13343                        1,
13344                    );
13345                }
13346                if let Some(job) = job_by_id.get(&parsed.id) {
13347                    graph.add_edge(
13348                        &job.handle,
13349                        &result.handle,
13350                        "has_result",
13351                        Some(format!("queued worker result {}", parsed.status)),
13352                        1,
13353                    );
13354                }
13355                let mut result_text = line.to_string();
13356                if !parsed.touched_files.is_empty() {
13357                    result_text.push(' ');
13358                    result_text.push_str(&parsed.touched_files.join(" "));
13359                }
13360                link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
13361            }
13362        }
13363    }
13364    Ok(())
13365}
13366
13367#[derive(Debug, Clone)]
13368struct AgentDocIndexGate {
13369    db_path: Option<PathBuf>,
13370    source_root: PathBuf,
13371    diagnostics: Vec<String>,
13372}
13373
13374#[derive(Clone, Hash, PartialEq, Eq)]
13375struct AgentDocIndexGateCacheKey {
13376    root: PathBuf,
13377    path_hint: PathBuf,
13378    scope: Option<String>,
13379    packet_label: String,
13380}
13381
13382fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
13383    std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
13384> {
13385    static CACHE: std::sync::OnceLock<
13386        std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
13387    > = std::sync::OnceLock::new();
13388    CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
13389}
13390
13391fn prepare_agent_doc_index_gate_cached(
13392    root: &Path,
13393    path_hint: &Path,
13394    scope: Option<&str>,
13395    packet_label: &str,
13396) -> (AgentDocIndexGate, String) {
13397    let key = AgentDocIndexGateCacheKey {
13398        root: root.to_path_buf(),
13399        path_hint: path_hint.to_path_buf(),
13400        scope: scope.map(str::to_string),
13401        packet_label: packet_label.to_string(),
13402    };
13403    if let Ok(cache) = agent_doc_index_gate_cache().lock()
13404        && let Some(cached) = cache.get(&key)
13405    {
13406        return (
13407            cached.clone(),
13408            "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
13409        );
13410    }
13411    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
13412    if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
13413        cache.insert(key, gate.clone());
13414    }
13415    (
13416        gate,
13417        "fresh inspection/refresh — cache miss on this preparation key".to_string(),
13418    )
13419}
13420
13421fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
13422    match state {
13423        SearchIndexState::Fresh => None,
13424        SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
13425        SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
13426    }
13427}
13428
13429fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
13430    rebuild_search_target_detail(&RebuildSearchTarget {
13431        label: target.label.clone(),
13432        reason,
13433        reindex_cmd: target.reindex_cmd.clone(),
13434    })
13435}
13436
13437fn index_refresh_diagnostic(
13438    target: &SearchIndexTarget,
13439    reason: RebuildSearchReason,
13440    summary: &index::IndexSummary,
13441    packet_label: &str,
13442) -> String {
13443    let changed = summary.new + summary.modified + summary.deleted;
13444    format!(
13445        "index refreshed: {}; updated {} changed file{} before {}",
13446        index_reason_detail(target, reason),
13447        changed,
13448        if changed == 1 { "" } else { "s" },
13449        packet_label
13450    )
13451}
13452
13453fn index_refresh_fallback_diagnostic(
13454    target: &SearchIndexTarget,
13455    reason: RebuildSearchReason,
13456    err: &anyhow::Error,
13457    packet_label: &str,
13458) -> String {
13459    format!(
13460        "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
13461        index_reason_detail(target, reason),
13462        packet_label
13463    )
13464}
13465
13466fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
13467    if let Some(scope_name) = scope
13468        && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
13469    {
13470        return scope.source_root;
13471    }
13472    if let Some(scope_name) = scope
13473        && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
13474    {
13475        return package.package_root;
13476    }
13477    if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
13478        return scope.source_root;
13479    }
13480    if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
13481        return package.package_root;
13482    }
13483    if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
13484        return scope.source_root;
13485    }
13486    root.to_path_buf()
13487}
13488
13489fn prepare_agent_doc_index_gate(
13490    root: &Path,
13491    path_hint: &Path,
13492    scope: Option<&str>,
13493    packet_label: &str,
13494) -> AgentDocIndexGate {
13495    let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
13496    let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13497        Ok(targets) => targets,
13498        Err(err) => {
13499            return AgentDocIndexGate {
13500                db_path: None,
13501                source_root: fallback_source_root,
13502                diagnostics: vec![format!(
13503                    "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13504                )],
13505            };
13506        }
13507    };
13508    let Some(target) = targets.into_iter().next() else {
13509        return AgentDocIndexGate {
13510            db_path: None,
13511            source_root: fallback_source_root,
13512            diagnostics: vec![format!(
13513                "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
13514            )],
13515        };
13516    };
13517
13518    let state = match inspect_search_index(&target) {
13519        Ok(state) => state,
13520        Err(err) => {
13521            return AgentDocIndexGate {
13522                db_path: None,
13523                source_root: target.source_root,
13524                diagnostics: vec![format!(
13525                    "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13526                )],
13527            };
13528        }
13529    };
13530
13531    let Some(reason) = index_reason_for_state(state) else {
13532        return AgentDocIndexGate {
13533            db_path: Some(target.db_path),
13534            source_root: target.source_root,
13535            diagnostics: Vec::new(),
13536        };
13537    };
13538
13539    match apply_search_index_update(root, &target) {
13540        Ok(summary) => {
13541            // #gdbgatecold: the index was just rewritten, so any cached
13542            // pre-refresh inspection result for this scope (held by the
13543            // active lazily-backed `InspectScopeGuard`) is stale. Invalidate
13544            // the scope epoch so the next `inspect_read_only` re-reads the
13545            // fresh index.
13546            index::inspect_scope_invalidate_all();
13547            let diagnostics = vec![index_refresh_diagnostic(
13548                &target,
13549                reason,
13550                &summary,
13551                packet_label,
13552            )];
13553            AgentDocIndexGate {
13554                db_path: Some(target.db_path),
13555                source_root: target.source_root,
13556                diagnostics,
13557            }
13558        }
13559        Err(err) => {
13560            let diagnostics = vec![index_refresh_fallback_diagnostic(
13561                &target,
13562                reason,
13563                &err,
13564                packet_label,
13565            )];
13566            AgentDocIndexGate {
13567                db_path: None,
13568                source_root: target.source_root,
13569                diagnostics,
13570            }
13571        }
13572    }
13573}
13574
13575fn add_raw_source_file_nodes(
13576    root: &Path,
13577    source_root: &Path,
13578    graph: &mut TraversalGraphBuild,
13579    file_entries: &mut Vec<TraversalFileIndexEntry>,
13580) -> Result<()> {
13581    let mut entries = walk::walk_files(source_root)?;
13582    entries.sort_by(|left, right| left.path.cmp(&right.path));
13583    for entry in entries {
13584        let file = entry.path.to_string_lossy();
13585        let node = traversal_raw_source_file_node(root, file.as_ref());
13586        let entry = TraversalFileIndexEntry {
13587            handle: node.handle.clone(),
13588            tokens: traversal_node_tokens(&node),
13589            node: node.clone(),
13590        };
13591        graph.add_node(node);
13592        file_entries.push(entry);
13593    }
13594    Ok(())
13595}
13596
13597fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
13598    if scope_root.is_empty() {
13599        return true;
13600    }
13601    path == scope_root || path.starts_with(&format!("{scope_root}/"))
13602}
13603
13604fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
13605    let path = Path::new(file);
13606    if path.is_absolute() {
13607        return path.to_path_buf();
13608    }
13609    let source_candidate = source_root.join(path);
13610    if source_candidate.exists() {
13611        source_candidate
13612    } else {
13613        root.join(path)
13614    }
13615}
13616
13617fn cargo_import_alias_from_line(line: &str) -> Option<String> {
13618    let trimmed = line.trim();
13619    let rest = trimmed
13620        .strip_prefix("pub use ")
13621        .or_else(|| trimmed.strip_prefix("use "))
13622        .or_else(|| trimmed.strip_prefix("extern crate "))?;
13623    let alias = rest
13624        .split([':', ';', ' ', '\t'])
13625        .next()
13626        .unwrap_or_default()
13627        .trim();
13628    (!alias.is_empty()).then(|| alias.to_string())
13629}
13630
13631fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
13632    let mut aliases = BTreeSet::new();
13633    for entry in walk::walk_files(&package.package_root)? {
13634        if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
13635            continue;
13636        }
13637        let content = fs::read_to_string(&entry.path)
13638            .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
13639        aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
13640    }
13641    Ok(aliases)
13642}
13643
13644fn load_multiplicity_traversal_nodes(
13645    root: &Path,
13646    source_root: &Path,
13647    graph: &mut TraversalGraphBuild,
13648    file_handle_by_path: &HashMap<String, String>,
13649    multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
13650) -> Result<()> {
13651    let inventory = multiplicity::discover_cargo_inventory(source_root)?;
13652    let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
13653    for workspace in &inventory.workspaces {
13654        let node = traversal_cargo_workspace_node(root, workspace);
13655        workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
13656        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13657            handle: node.handle.clone(),
13658            tokens: traversal_node_tokens(&node),
13659            node: node.clone(),
13660        });
13661        graph.add_node(node);
13662    }
13663
13664    let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
13665    let mut package_nodes = Vec::new();
13666    for package in &inventory.packages {
13667        let node = traversal_cargo_package_node(root, package);
13668        package_handle_by_name
13669            .entry(package.name.clone())
13670            .or_default()
13671            .push(node.handle.clone());
13672        package_handle_by_name
13673            .entry(package.normalized_name.clone())
13674            .or_default()
13675            .push(node.handle.clone());
13676        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13677            handle: node.handle.clone(),
13678            tokens: traversal_node_tokens(&node),
13679            node: node.clone(),
13680        });
13681        graph.add_node(node.clone());
13682        package_nodes.push((package, node));
13683    }
13684
13685    for (package, node) in &package_nodes {
13686        if let Some(workspace_handle) =
13687            workspace_handle_by_root.get(&package.relative_workspace_root)
13688        {
13689            graph.add_edge(
13690                workspace_handle,
13691                &node.handle,
13692                "contains_package",
13693                Some("Cargo workspace member package".to_string()),
13694                1,
13695            );
13696        }
13697        let package_root = relativize_pathbuf(&package.package_root, root)
13698            .to_string_lossy()
13699            .replace('\\', "/");
13700        for (file, handle) in file_handle_by_path {
13701            if relative_path_inside_scope(file, &package_root) {
13702                graph.add_edge(
13703                    &node.handle,
13704                    handle,
13705                    "owns_file",
13706                    Some("Cargo package owns source file".to_string()),
13707                    1,
13708                );
13709            }
13710        }
13711        for dependency in &package.dependencies {
13712            if let Some(handles) = package_handle_by_name.get(&dependency.name)
13713                && handles.len() == 1
13714            {
13715                graph.add_edge(
13716                    &node.handle,
13717                    &handles[0],
13718                    "declares_dependency",
13719                    Some(format!("{} Cargo dependency", dependency.kind)),
13720                    1,
13721                );
13722            }
13723        }
13724        for alias in cargo_import_aliases(package)? {
13725            if let Some(handles) = package_handle_by_name.get(&alias)
13726                && handles.len() == 1
13727                && handles[0] != node.handle
13728            {
13729                graph.add_edge(
13730                    &node.handle,
13731                    &handles[0],
13732                    "uses_crate",
13733                    Some("Rust use/extern crate reference".to_string()),
13734                    1,
13735                );
13736                graph.add_edge(
13737                    &node.handle,
13738                    &handles[0],
13739                    "imports",
13740                    Some("Rust use/extern crate import".to_string()),
13741                    1,
13742                );
13743            }
13744        }
13745    }
13746
13747    Ok(())
13748}
13749
13750fn build_traversal_graph_source_with_options(
13751    root: &Path,
13752    path_hint: &Path,
13753    scope: Option<&str>,
13754    session_only: bool,
13755) -> Result<TraversalGraphBuild> {
13756    let mut graph = TraversalGraphBuild::default();
13757    let mut symbol_entries = Vec::new();
13758    let mut file_entries = Vec::new();
13759    let mut route_entries = Vec::new();
13760    let mut multiplicity_entries = Vec::new();
13761    let mut file_handle_by_path = HashMap::<String, String>::new();
13762    let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
13763    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13764        let (gate, _cache_detail) =
13765            prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
13766        graph.warnings.extend(gate.diagnostics);
13767        let gate_source_root = gate.source_root.clone();
13768
13769        match gate.db_path {
13770            Some(db_path) if db_path.exists() => {
13771                let db = index::IndexDb::open_read_only_resilient(&db_path)?;
13772                let file_paths = db.file_paths()?;
13773                for file in file_paths {
13774                    if traversal_path_is_generated_artifact(
13775                        root,
13776                        &gate_source_root,
13777                        Path::new(&file),
13778                    ) {
13779                        continue;
13780                    }
13781                    let node = traversal_file_node(root, &file);
13782                    let entry = TraversalFileIndexEntry {
13783                        handle: node.handle.clone(),
13784                        tokens: traversal_node_tokens(&node),
13785                        node: node.clone(),
13786                    };
13787                    if let Some(path) = entry.node.path.as_ref() {
13788                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
13789                    }
13790                    graph.add_node(node);
13791                    file_entries.push(entry);
13792                }
13793
13794                let symbols = db.all_symbols()?;
13795                let mut symbol_by_file_name_line = HashMap::new();
13796                let mut span_by_file_name_line = HashMap::new();
13797                let mut first_symbol_by_name = BTreeMap::<String, String>::new();
13798                let mut first_span_by_name = BTreeMap::<String, String>::new();
13799                let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
13800                let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
13801                for symbol in symbols.iter().filter(|symbol| {
13802                    !traversal_path_is_generated_artifact(
13803                        root,
13804                        &gate_source_root,
13805                        Path::new(&symbol.file),
13806                    )
13807                }) {
13808                    let node = traversal_symbol_node(root, symbol);
13809                    let file = relativize(&symbol.file, root);
13810                    symbol_by_file_name_line.insert(
13811                        format!("{file}:{}:{}", symbol.line, symbol.name),
13812                        node.handle.clone(),
13813                    );
13814                    first_symbol_by_name
13815                        .entry(symbol.name.clone())
13816                        .or_insert_with(|| node.handle.clone());
13817                    let entry = TraversalSymbolIndexEntry {
13818                        handle: node.handle.clone(),
13819                        tokens: traversal_node_tokens(&node),
13820                        node: node.clone(),
13821                    };
13822                    graph.add_node(node.clone());
13823                    if let Some(file_handle) = file_handle_by_path.get(&file) {
13824                        graph.add_edge(
13825                            file_handle,
13826                            &node.handle,
13827                            "defines",
13828                            Some("file defines symbol".to_string()),
13829                            1,
13830                        );
13831                    }
13832                    if !source_by_file.contains_key(&symbol.file) {
13833                        let source_path =
13834                            traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
13835                        source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
13836                    }
13837                    if let Some(Some(source)) = source_by_file.get(&symbol.file)
13838                        && let Some((ast_node, mut ast_entry)) =
13839                            traversal_ast_span_node(root, symbol, source, &symbols)
13840                    {
13841                        ast_entry.symbol_handle = node.handle.clone();
13842                        ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
13843                        span_by_file_name_line.insert(
13844                            format!("{file}:{}:{}", symbol.line, symbol.name),
13845                            ast_node.handle.clone(),
13846                        );
13847                        first_span_by_name
13848                            .entry(symbol.name.clone())
13849                            .or_insert_with(|| ast_node.handle.clone());
13850                        graph.add_node(ast_node.clone());
13851                        graph.add_edge(
13852                            &node.handle,
13853                            &ast_node.handle,
13854                            "has_ast_span",
13855                            Some("symbol projects to indexed AST span".to_string()),
13856                            1,
13857                        );
13858                        graph.add_edge(
13859                            &ast_node.handle,
13860                            &node.handle,
13861                            "represents_symbol",
13862                            Some("AST span represents indexed symbol".to_string()),
13863                            1,
13864                        );
13865                        ast_entries.push(ast_entry);
13866                    }
13867                    symbol_entries.push(entry);
13868                }
13869                link_ast_navigation_edges(&mut graph, &ast_entries);
13870                link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
13871
13872                if !bounded_session_projection {
13873                    for edge in db.all_stored_edges()? {
13874                        if traversal_path_is_generated_artifact(
13875                            root,
13876                            &gate_source_root,
13877                            Path::new(&edge.caller_file),
13878                        ) {
13879                            continue;
13880                        }
13881                        let caller_file = relativize(&edge.caller_file, root);
13882                        let caller_key =
13883                            format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
13884                        let Some(caller_handle) =
13885                            symbol_by_file_name_line.get(&caller_key).cloned()
13886                        else {
13887                            continue;
13888                        };
13889                        let callee_handle = if let Some(handle) =
13890                            first_symbol_by_name.get(&edge.callee_name)
13891                        {
13892                            handle.clone()
13893                        } else {
13894                            let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
13895                            let handle = node.handle.clone();
13896                            graph.add_node(node);
13897                            handle
13898                        };
13899                        graph.add_edge(
13900                            &caller_handle,
13901                            &callee_handle,
13902                            "calls",
13903                            Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
13904                            1,
13905                        );
13906                        if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
13907                            && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
13908                        {
13909                            graph.add_edge(
13910                                caller_span,
13911                                callee_span,
13912                                "calls",
13913                                Some(format!(
13914                                    "AST call site {}:{}",
13915                                    caller_file, edge.call_site_line
13916                                )),
13917                                1,
13918                            );
13919                        }
13920                    }
13921                }
13922
13923                for route in db.all_routes()? {
13924                    if traversal_path_is_generated_artifact(
13925                        root,
13926                        &gate_source_root,
13927                        Path::new(&route.file),
13928                    ) {
13929                        continue;
13930                    }
13931                    let node = traversal_route_node(root, &route);
13932                    let entry = TraversalRouteIndexEntry {
13933                        handle: node.handle.clone(),
13934                        tokens: traversal_node_tokens(&node),
13935                        node: node.clone(),
13936                    };
13937                    graph.add_node(node.clone());
13938                    if let Some(path) = node.path.as_ref()
13939                        && let Some(file_handle) = file_handle_by_path.get(path)
13940                    {
13941                        graph.add_edge(
13942                            file_handle,
13943                            &node.handle,
13944                            "defines_route",
13945                            Some("file declares route".to_string()),
13946                            1,
13947                        );
13948                    }
13949                    let handler_handle =
13950                        if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
13951                            handle.clone()
13952                        } else {
13953                            let node = traversal_unresolved_symbol_node(root, &route.handler_name);
13954                            let handle = node.handle.clone();
13955                            graph.add_node(node);
13956                            handle
13957                        };
13958                    graph.add_edge(
13959                        &entry.handle,
13960                        &handler_handle,
13961                        "handled_by",
13962                        Some("route handler reference".to_string()),
13963                        1,
13964                    );
13965                    if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
13966                        graph.add_edge(
13967                            &entry.handle,
13968                            handler_span,
13969                            "handled_by",
13970                            Some("route handler AST span".to_string()),
13971                            1,
13972                        );
13973                        graph.add_edge(
13974                            handler_span,
13975                            &entry.handle,
13976                            "handles_route",
13977                            Some("AST span handles route".to_string()),
13978                            1,
13979                        );
13980                    }
13981                    route_entries.push(entry);
13982                }
13983            }
13984            _ => {
13985                add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
13986                    .with_context(|| {
13987                    format!(
13988                        "loading raw source fallback nodes from {}",
13989                        gate_source_root.display()
13990                    )
13991                })?;
13992                for entry in &file_entries {
13993                    if let Some(path) = entry.node.path.as_ref() {
13994                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
13995                    }
13996                }
13997            }
13998        }
13999        load_multiplicity_traversal_nodes(
14000            root,
14001            &gate_source_root,
14002            &mut graph,
14003            &file_handle_by_path,
14004            &mut multiplicity_entries,
14005        )?;
14006    }
14007
14008    let code_lookup = TraversalCodeLookup::new(
14009        &symbol_entries,
14010        &file_entries,
14011        &route_entries,
14012        &multiplicity_entries,
14013    );
14014    load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
14015    Ok(graph)
14016}
14017
14018#[cfg(test)]
14019fn build_traversal_graph_source(
14020    root: &Path,
14021    path_hint: &Path,
14022    scope: Option<&str>,
14023) -> Result<TraversalGraphBuild> {
14024    build_traversal_graph_source_with_options(root, path_hint, scope, false)
14025}
14026
14027pub(crate) fn write_traversal_graph_store_with_options(
14028    root: &Path,
14029    path_hint: &Path,
14030    scope: Option<&str>,
14031    session_only: bool,
14032) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14033    let source_graph =
14034        build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14035    let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14036    let graph_db = graph_substrate_db_path(root, scope);
14037    let mut store = SqliteGraphStore::open(&graph_db)?;
14038    let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14039        .ok()
14040        .flatten()
14041        .or_else(|| graph_projection_content_hash(&projection));
14042    let refresh = store.replace_projection_with_version(
14043        scope.unwrap_or("root"),
14044        &projection,
14045        Some(GRAPH_PROJECTION_VERSION),
14046        source_watermark,
14047    )?;
14048    Ok((source_graph, refresh))
14049}
14050
14051pub(crate) fn write_traversal_graph_store(
14052    root: &Path,
14053    path_hint: &Path,
14054    scope: Option<&str>,
14055) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14056    write_traversal_graph_store_with_options(root, path_hint, scope, false)
14057}
14058
14059fn refresh_traversal_graph_store_with_options(
14060    root: &Path,
14061    path_hint: &Path,
14062    scope: Option<&str>,
14063    session_only: bool,
14064) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14065    let (source_graph, refresh) =
14066        write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14067    let graph_db = graph_substrate_db_path(root, scope);
14068    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14069    let mut graph = traversal_graph_from_store(root, &store)?;
14070    graph.warnings = source_graph.warnings;
14071    Ok((graph, refresh))
14072}
14073
14074fn refresh_traversal_graph_store(
14075    root: &Path,
14076    path_hint: &Path,
14077    scope: Option<&str>,
14078) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14079    refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14080}
14081
14082pub(crate) fn build_traversal_graph(
14083    root: &Path,
14084    path_hint: &Path,
14085    scope: Option<&str>,
14086) -> Result<TraversalGraphBuild> {
14087    let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14088    Ok(graph)
14089}
14090
14091fn traversal_query_kind_priority(kind: &str) -> usize {
14092    match kind {
14093        "backlog" => 0,
14094        "job_packet" => 1,
14095        "worker_result" => 2,
14096        "symbol" => 3,
14097        "ast_span" => 4,
14098        "file" => 5,
14099        "route" => 6,
14100        "cargo_package" => 7,
14101        "cargo_workspace" => 8,
14102        "session" => 9,
14103        "semantic_concept" => 10,
14104        "semantic_entity" => 11,
14105        _ => 12,
14106    }
14107}
14108
14109fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14110    let trimmed = query.trim();
14111    if trimmed.is_empty() {
14112        return None;
14113    }
14114    let kind_priority = traversal_query_kind_priority(&node.kind);
14115    if node.handle == trimmed {
14116        return Some((0, kind_priority, node.handle.clone()));
14117    }
14118    if node.path.as_deref() == Some(trimmed) {
14119        let path_priority = if node.kind == "file" {
14120            0
14121        } else {
14122            kind_priority.saturating_add(1)
14123        };
14124        return Some((1, path_priority, node.handle.clone()));
14125    }
14126    let normalized_backlog = trimmed.trim_start_matches('#');
14127    if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14128    {
14129        return Some((2, kind_priority, node.handle.clone()));
14130    }
14131    if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14132        return Some((3, kind_priority, node.handle.clone()));
14133    }
14134    None
14135}
14136
14137fn resolve_traversal_node<'a>(
14138    graph: &'a TraversalGraphBuild,
14139    query: &str,
14140) -> Option<&'a TraversalNode> {
14141    graph
14142        .nodes
14143        .values()
14144        .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14145        .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14146        .map(|(_, node)| node)
14147}
14148
14149fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14150    let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14151    for edge in edges {
14152        adj.entry(edge.from.clone())
14153            .or_default()
14154            .insert(edge.to.clone());
14155        adj.entry(edge.to.clone())
14156            .or_default()
14157            .insert(edge.from.clone());
14158    }
14159    adj.into_iter()
14160        .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14161        .collect()
14162}
14163
14164fn traversal_shortest_handles(
14165    edges: &[TraversalEdge],
14166    from: &str,
14167    to: &str,
14168) -> Option<Vec<String>> {
14169    if from == to {
14170        return Some(vec![from.to_string()]);
14171    }
14172    let adj = traversal_adjacency(edges);
14173    if !adj.contains_key(from) || !adj.contains_key(to) {
14174        return None;
14175    }
14176    let mut visited = BTreeSet::new();
14177    let mut queue = VecDeque::new();
14178    let mut parent = BTreeMap::<String, String>::new();
14179    visited.insert(from.to_string());
14180    queue.push_back(from.to_string());
14181    while let Some(current) = queue.pop_front() {
14182        if let Some(neighbors) = adj.get(&current) {
14183            for neighbor in neighbors {
14184                if visited.insert(neighbor.clone()) {
14185                    parent.insert(neighbor.clone(), current.clone());
14186                    if neighbor == to {
14187                        let mut path = vec![to.to_string()];
14188                        let mut cursor = to.to_string();
14189                        while let Some(prev) = parent.get(&cursor) {
14190                            path.push(prev.clone());
14191                            cursor = prev.clone();
14192                        }
14193                        path.reverse();
14194                        return Some(path);
14195                    }
14196                    queue.push_back(neighbor.clone());
14197                }
14198            }
14199        }
14200    }
14201    None
14202}
14203
14204fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14205    let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14206    for edge in edges {
14207        let neighbor = if edge.from == current {
14208            edge.to.as_str()
14209        } else if edge.to == current {
14210            edge.from.as_str()
14211        } else {
14212            continue;
14213        };
14214        let score = traversal_relation_score(edge, current);
14215        best_score_by_neighbor
14216            .entry(neighbor.to_string())
14217            .and_modify(|best| *best = (*best).max(score))
14218            .or_insert(score);
14219    }
14220    let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14221    ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14222        right_score
14223            .cmp(left_score)
14224            .then_with(|| left_handle.cmp(right_handle))
14225    });
14226    ranked.into_iter().map(|(handle, _)| handle).collect()
14227}
14228
14229fn traversal_neighborhood_handles(
14230    edges: &[TraversalEdge],
14231    origin: &str,
14232    depth: usize,
14233    limit: usize,
14234) -> BTreeSet<String> {
14235    let mut seen = BTreeSet::new();
14236    let mut queue = VecDeque::new();
14237    seen.insert(origin.to_string());
14238    queue.push_back((origin.to_string(), 0usize));
14239    while let Some((current, current_depth)) = queue.pop_front() {
14240        if current_depth >= depth {
14241            continue;
14242        }
14243        for neighbor in traversal_scored_neighbors(edges, &current) {
14244            if limit > 0 && seen.len() >= limit {
14245                return seen;
14246            }
14247            if seen.insert(neighbor.clone()) {
14248                queue.push_back((neighbor, current_depth + 1));
14249            }
14250        }
14251    }
14252    seen
14253}
14254
14255fn traversal_edges_between(
14256    handles: &BTreeSet<String>,
14257    edges: &[TraversalEdge],
14258) -> Vec<TraversalEdge> {
14259    edges
14260        .iter()
14261        .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14262        .cloned()
14263        .collect()
14264}
14265
14266fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14267    let mut result = Vec::new();
14268    for pair in path.windows(2) {
14269        if let Some(edge) = edges.iter().find(|edge| {
14270            (edge.from == pair[0] && edge.to == pair[1])
14271                || (edge.from == pair[1] && edge.to == pair[0])
14272        }) {
14273            result.push(edge.clone());
14274        }
14275    }
14276    result
14277}
14278
14279fn sorted_traversal_nodes<'a>(
14280    nodes: impl IntoIterator<Item = &'a TraversalNode>,
14281) -> Vec<TraversalNode> {
14282    let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14283    nodes.sort_by(|left, right| {
14284        left.kind
14285            .cmp(&right.kind)
14286            .then_with(|| left.label.cmp(&right.label))
14287            .then_with(|| left.path.cmp(&right.path))
14288            .then_with(|| left.handle.cmp(&right.handle))
14289    });
14290    nodes
14291}
14292
14293fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14294    let base = match edge.relation.as_str() {
14295        "mentions" => 100,
14296        "contains" => 80,
14297        "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14298        "contains_embedded_symbol" | "embedded_in_fence" => 77,
14299        "contains_markdown_block"
14300        | "contains_embedded_code"
14301        | "enclosing_module"
14302        | "enclosing_section" => 76,
14303        "calls" => {
14304            if edge.from == origin {
14305                70
14306            } else {
14307                65
14308            }
14309        }
14310        "handled_by" | "handles_route" => 68,
14311        "defines_route" => 62,
14312        "imports" => 62,
14313        "previous_sibling" | "next_sibling" => 54,
14314        "mentions_concept" | "mentions_entity" => 66,
14315        "semantic_relation" => 64,
14316        "tagged_concept" | "related_concept" => 58,
14317        "defines" => {
14318            if edge.from == origin {
14319                60
14320            } else {
14321                55
14322            }
14323        }
14324        _ => 10,
14325    };
14326    base + edge.weight
14327}
14328
14329fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
14330    match edge.relation.as_str() {
14331        "mentions" => "matched from backlog/session text".to_string(),
14332        "contains" => "contained in the selected session artifact".to_string(),
14333        "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
14334        "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
14335        "parent" => "parent AST span".to_string(),
14336        "child" => "child AST span".to_string(),
14337        "previous_sibling" => "previous AST sibling".to_string(),
14338        "next_sibling" => "next AST sibling".to_string(),
14339        "contains_markdown_block" => "Markdown section block".to_string(),
14340        "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
14341        "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
14342        "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
14343        "enclosing_module" => "nearest enclosing module".to_string(),
14344        "enclosing_section" => "nearest enclosing Markdown section".to_string(),
14345        "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
14346        "defines" => "file that defines the selected symbol".to_string(),
14347        "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
14348        "defines_route" => "file that declares the selected route".to_string(),
14349        "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
14350        "handled_by" => "route handled by the selected symbol".to_string(),
14351        "handles_route" => "route handled by the selected AST span".to_string(),
14352        "imports" => "import dependency from the selected package".to_string(),
14353        "mentions_concept" => "cached summary concept for the selected source".to_string(),
14354        "mentions_entity" => "cached summary entity for the selected source".to_string(),
14355        "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
14356        "tagged_concept" => "concept label attached to the selected entity".to_string(),
14357        "related_concept" => "co-occurring cached summary concept".to_string(),
14358        "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
14359        "calls" => "caller of the selected symbol".to_string(),
14360        other => format!("connected by {other}"),
14361    }
14362}
14363
14364fn traversal_recommendations(
14365    graph: &TraversalGraphBuild,
14366    origin: Option<&str>,
14367    shortest_path: Option<&[String]>,
14368    limit: usize,
14369) -> Vec<TraversalRecommendation> {
14370    let Some(origin) = origin else {
14371        return Vec::new();
14372    };
14373    let mut recommendations = Vec::new();
14374    let mut seen = BTreeSet::new();
14375
14376    if let Some(path) = shortest_path
14377        && path.len() > 1
14378        && path.first().is_some_and(|handle| handle == origin)
14379        && let Some(next) = graph.nodes.get(&path[1])
14380    {
14381        seen.insert(next.handle.clone());
14382        recommendations.push(TraversalRecommendation {
14383            handle: next.handle.clone(),
14384            kind: next.kind.clone(),
14385            label: next.label.clone(),
14386            reason: "next hop on shortest path".to_string(),
14387            score: 1_000,
14388            expand: next.expand.clone(),
14389        });
14390    }
14391
14392    let mut candidates = graph
14393        .edges
14394        .iter()
14395        .filter_map(|edge| {
14396            let neighbor = if edge.from == origin {
14397                edge.to.as_str()
14398            } else if edge.to == origin {
14399                edge.from.as_str()
14400            } else {
14401                return None;
14402            };
14403            let node = graph.nodes.get(neighbor)?;
14404            Some((traversal_relation_score(edge, origin), edge, node))
14405        })
14406        .collect::<Vec<_>>();
14407    candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
14408        right_score
14409            .cmp(left_score)
14410            .then_with(|| left.kind.cmp(&right.kind))
14411            .then_with(|| left.label.cmp(&right.label))
14412            .then_with(|| left.handle.cmp(&right.handle))
14413    });
14414
14415    let max = if limit == 0 { usize::MAX } else { limit };
14416    for (score, edge, node) in candidates {
14417        if recommendations.len() >= max {
14418            break;
14419        }
14420        if seen.insert(node.handle.clone()) {
14421            recommendations.push(TraversalRecommendation {
14422                handle: node.handle.clone(),
14423                kind: node.kind.clone(),
14424                label: node.label.clone(),
14425                reason: traversal_recommendation_reason(edge, origin),
14426                score,
14427                expand: node.expand.clone(),
14428            });
14429        }
14430    }
14431
14432    recommendations
14433}
14434
14435fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
14436    let scale = nodes.saturating_add(edges);
14437    if scale <= 80 {
14438        ExplorationBudget {
14439            project_size: "small".to_string(),
14440            max_source_windows: 8,
14441            lines_per_window: 96,
14442            relationship_limit: 40,
14443        }
14444    } else if scale <= 800 {
14445        ExplorationBudget {
14446            project_size: "medium".to_string(),
14447            max_source_windows: 6,
14448            lines_per_window: 80,
14449            relationship_limit: 32,
14450        }
14451    } else {
14452        ExplorationBudget {
14453            project_size: "large".to_string(),
14454            max_source_windows: 4,
14455            lines_per_window: 64,
14456            relationship_limit: 24,
14457        }
14458    }
14459}
14460
14461fn exploration_node_label(node: &TraversalNode) -> String {
14462    format!("{}:{}", node.kind, node.label)
14463}
14464
14465fn exploration_source_window_for_node(
14466    root: &Path,
14467    node: &TraversalNode,
14468    budget: &ExplorationBudget,
14469) -> Option<ExplorationSourceWindow> {
14470    let file = node.path.as_ref()?;
14471    let anchor = node
14472        .line
14473        .and_then(|line| usize::try_from(line).ok())
14474        .and_then(|line| line.checked_add(1))
14475        .unwrap_or(1);
14476    let context_before = budget.lines_per_window / 3;
14477    let start = anchor.saturating_sub(context_before).max(1);
14478    let end = start
14479        .saturating_add(budget.lines_per_window)
14480        .saturating_sub(1);
14481    let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
14482    Some(ExplorationSourceWindow {
14483        handle,
14484        file: file.clone(),
14485        start,
14486        end,
14487        reason: format!("cluster around {}", exploration_node_label(node)),
14488        expand: source_read_command(root, file, start, budget.lines_per_window),
14489    })
14490}
14491
14492fn build_exploration_packet(
14493    root: &Path,
14494    totals: &TraversalTotals,
14495    selected_nodes: &[TraversalNode],
14496    selected_edges: &[TraversalEdge],
14497) -> ExplorationPacket {
14498    let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
14499    let node_by_handle = selected_nodes
14500        .iter()
14501        .map(|node| (node.handle.as_str(), node))
14502        .collect::<BTreeMap<_, _>>();
14503    let relationship_map = selected_edges
14504        .iter()
14505        .take(budget.relationship_limit)
14506        .filter_map(|edge| {
14507            let from = node_by_handle.get(edge.from.as_str())?;
14508            let to = node_by_handle.get(edge.to.as_str())?;
14509            Some(ExplorationRelation {
14510                from: exploration_node_label(from),
14511                relation: edge.relation.clone(),
14512                to: exploration_node_label(to),
14513                label: edge.label.clone(),
14514            })
14515        })
14516        .collect::<Vec<_>>();
14517
14518    let mut seen_windows = BTreeSet::new();
14519    let mut source_windows = Vec::new();
14520    for node in selected_nodes {
14521        if source_windows.len() >= budget.max_source_windows {
14522            break;
14523        }
14524        let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
14525            continue;
14526        };
14527        let key = (window.file.clone(), window.start, window.end);
14528        if seen_windows.insert(key) {
14529            source_windows.push(window);
14530        }
14531    }
14532
14533    ExplorationPacket {
14534        budget,
14535        relationship_map,
14536        source_windows,
14537        worker_context: Vec::new(),
14538        no_reread_guidance:
14539            "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
14540                .to_string(),
14541    }
14542}
14543
14544pub(crate) fn traversal_report(
14545    root: &Path,
14546    scope: Option<&str>,
14547    graph: TraversalGraphBuild,
14548    query: Option<&str>,
14549    target: Option<&str>,
14550    depth: usize,
14551    limit: usize,
14552) -> Result<TraversalReport> {
14553    let totals = TraversalTotals {
14554        nodes: graph.nodes.len(),
14555        edges: graph.edges.len(),
14556    };
14557    let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
14558    let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
14559    if let Some(query) = query
14560        && origin_node.is_none()
14561    {
14562        bail!("traversal node not found: {}", query);
14563    }
14564    if let Some(target) = target
14565        && target_node.is_none()
14566    {
14567        bail!("traversal target not found: {}", target);
14568    }
14569
14570    let (mode, selected_nodes, selected_edges, shortest_path) =
14571        if let (Some(origin), Some(target)) = (origin_node, target_node) {
14572            if let Some(handles) =
14573                traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
14574            {
14575                let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
14576                let nodes = handles
14577                    .iter()
14578                    .filter_map(|handle| graph.nodes.get(handle).cloned())
14579                    .collect::<Vec<_>>();
14580                let edges = traversal_path_edges(&handles, &graph.edges);
14581                let path = TraversalPathReport {
14582                    from: origin.clone(),
14583                    to: target.clone(),
14584                    hops: handles.len().saturating_sub(1),
14585                    nodes: nodes.clone(),
14586                    edges: edges.clone(),
14587                };
14588                (
14589                    "path".to_string(),
14590                    nodes,
14591                    traversal_edges_between(&handle_set, &graph.edges),
14592                    Some(path),
14593                )
14594            } else {
14595                (
14596                    "path".to_string(),
14597                    vec![origin.clone(), target.clone()],
14598                    Vec::new(),
14599                    None,
14600                )
14601            }
14602        } else if let Some(origin) = origin_node {
14603            let handles =
14604                traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
14605            let nodes =
14606                sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
14607            let edges = traversal_edges_between(&handles, &graph.edges);
14608            ("neighborhood".to_string(), nodes, edges, None)
14609        } else {
14610            let mut nodes = sorted_traversal_nodes(graph.nodes.values());
14611            let truncated_nodes = limit > 0 && nodes.len() > limit;
14612            if truncated_nodes {
14613                nodes.truncate(limit);
14614            }
14615            let handles = nodes
14616                .iter()
14617                .map(|node| node.handle.clone())
14618                .collect::<BTreeSet<_>>();
14619            let mut edges = traversal_edges_between(&handles, &graph.edges);
14620            let truncated_edges = limit > 0 && edges.len() > limit;
14621            if truncated_edges {
14622                edges.truncate(limit);
14623            }
14624            ("export".to_string(), nodes, edges, None)
14625        };
14626
14627    let shortest_handles = shortest_path.as_ref().map(|path| {
14628        path.nodes
14629            .iter()
14630            .map(|node| node.handle.clone())
14631            .collect::<Vec<_>>()
14632    });
14633    let recommendations = traversal_recommendations(
14634        &graph,
14635        origin_node.map(|node| node.handle.as_str()),
14636        shortest_handles.as_deref(),
14637        if limit == 0 { 10 } else { limit.min(10) },
14638    );
14639    let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
14640    let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
14641
14642    Ok(TraversalReport {
14643        root: root.to_string_lossy().to_string(),
14644        scope: scope.map(str::to_string),
14645        mode,
14646        totals,
14647        query: query.map(str::to_string),
14648        target: target.map(str::to_string),
14649        nodes: selected_nodes,
14650        edges: selected_edges,
14651        shortest_path,
14652        recommendations,
14653        exploration,
14654        truncated,
14655        warnings: graph.warnings,
14656    })
14657}
14658
14659fn html_escape(input: &str) -> String {
14660    input
14661        .replace('&', "&amp;")
14662        .replace('<', "&lt;")
14663        .replace('>', "&gt;")
14664        .replace('"', "&quot;")
14665        .replace('\'', "&#39;")
14666}
14667
14668pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
14669    let json = serde_json::to_string(report)?.replace("</", "<\\/");
14670    let mut html = String::new();
14671    html.push_str(
14672        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
14673    );
14674    html.push_str(
14675        r#"<style>
14676:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
14677@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
14678*{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}}
14679</style>"#,
14680    );
14681    html.push_str("</head><body>");
14682    html.push_str("<div class=\"page\">");
14683    html.push_str(&format!(
14684        "<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>",
14685        html_escape(&report.mode),
14686        report.nodes.len(),
14687        report.totals.nodes,
14688        report.edges.len(),
14689        report.totals.edges
14690    ));
14691    html.push_str(
14692        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>"#,
14693    );
14694    html.push_str("<script id=\"graph-data\" type=\"application/json\">");
14695    html.push_str(&json);
14696    html.push_str(
14697        r##"</script><script>
14698const report = JSON.parse(document.getElementById("graph-data").textContent);
14699const svg = document.getElementById("graph-canvas");
14700const list = document.getElementById("node-list");
14701const selected = document.getElementById("selected");
14702const filter = document.getElementById("filter");
14703const legend = document.getElementById("legend");
14704const nodes = report.nodes.map((node, index) => ({...node, index}));
14705const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
14706const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
14707const colorByKind = new Map([
14708  ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
14709  ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
14710  ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
14711  ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
14712]);
14713function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
14714function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
14715function text(value){ return value == null ? "" : String(value); }
14716function matches(node, query){
14717  if (!query) return true;
14718  const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
14719  return haystack.includes(query);
14720}
14721function layout(){
14722  const rect = svg.getBoundingClientRect();
14723  const width = rect.width || 900;
14724  const height = rect.height || 650;
14725  const cx = width / 2;
14726  const cy = height / 2;
14727  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14728  const counts = new Map();
14729  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
14730  const offsets = new Map();
14731  for (const node of nodes) {
14732    const group = kinds.indexOf(node.kind);
14733    const index = offsets.get(node.kind) || 0;
14734    offsets.set(node.kind, index + 1);
14735    const groupCount = counts.get(node.kind) || 1;
14736    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
14737    const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
14738    node.x = cx + Math.cos(angle) * ring;
14739    node.y = cy + Math.sin(angle) * ring;
14740  }
14741}
14742function draw(){
14743  const query = filter.value.trim().toLowerCase();
14744  const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
14745  svg.innerHTML = "";
14746  for (const edge of edges) {
14747    if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
14748    const from = nodeByHandle.get(edge.from);
14749    const to = nodeByHandle.get(edge.to);
14750    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
14751    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
14752    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
14753    line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
14754    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
14755    svg.appendChild(line);
14756  }
14757  for (const node of nodes) {
14758    if (!visible.has(node.handle)) continue;
14759    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
14760    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
14761    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
14762    circle.setAttribute("fill", color(node.kind));
14763    circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
14764    circle.addEventListener("click", () => selectNode(node));
14765    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
14766    svg.appendChild(circle);
14767    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
14768    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
14769    label.setAttribute("class", "node-label");
14770    label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
14771    svg.appendChild(label);
14772  }
14773  renderList(query);
14774}
14775function renderLegend(){
14776  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14777  legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">&#9679;</b> ${kind}</span>`).join("");
14778}
14779function renderList(query){
14780  const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
14781  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("");
14782  for (const row of list.querySelectorAll(".row")) {
14783    row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
14784  }
14785}
14786function selectNode(node){
14787  const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
14788  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>`;
14789}
14790function escapeHtml(value){
14791  return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));
14792}
14793filter.addEventListener("input", draw);
14794window.addEventListener("resize", () => { layout(); draw(); });
14795renderLegend();
14796layout();
14797draw();
14798if (nodes.length) selectNode(nodes[0]);
14799</script></div></body></html>"##,
14800    );
14801    Ok(html)
14802}
14803
14804fn semantic_related_report_from_store(
14805    root: &Path,
14806    scope: Option<&str>,
14807    query: &str,
14808    limit: usize,
14809    kind: SemanticRelatedKind,
14810    store: &impl GraphStore,
14811) -> Result<SemanticRelatedReport> {
14812    if query.trim().is_empty() {
14813        bail!("semantic query cannot be empty");
14814    }
14815
14816    let query_embedding = semantic_embedding(query);
14817    let node_kinds: &[&str] = match kind {
14818        SemanticRelatedKind::Concept => &["semantic_concept"],
14819        SemanticRelatedKind::Entity => &["semantic_entity"],
14820        SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
14821    };
14822
14823    let items = store
14824        .semantic_top_candidates(&query_embedding, node_kinds, limit)?
14825        .into_iter()
14826        .map(|candidate| {
14827            let node = candidate.node;
14828            SemanticRelatedItem {
14829                handle: node
14830                    .properties
14831                    .get("handle")
14832                    .cloned()
14833                    .unwrap_or_else(|| node.id.clone()),
14834                kind: node.kind,
14835                label: node.label,
14836                score: candidate.score,
14837                file_path: node
14838                    .properties
14839                    .get("source_file")
14840                    .or_else(|| node.properties.get("path"))
14841                    .cloned(),
14842                source_symbol: node.properties.get("source_symbol").cloned(),
14843                detail: node
14844                    .properties
14845                    .get("description")
14846                    .or_else(|| node.properties.get("detail"))
14847                    .cloned(),
14848                expand: node
14849                    .properties
14850                    .get("expand")
14851                    .cloned()
14852                    .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
14853            }
14854        })
14855        .collect::<Vec<_>>();
14856
14857    let mut warnings = Vec::new();
14858    if items.is_empty() {
14859        warnings.push(
14860            "no semantic graph rows found; run `tsift summarize --extract <path>` first"
14861                .to_string(),
14862        );
14863    }
14864
14865    Ok(SemanticRelatedReport {
14866        root: root.to_string_lossy().to_string(),
14867        scope: scope.map(str::to_string),
14868        query: query.to_string(),
14869        embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
14870        count: items.len(),
14871        items,
14872        warnings,
14873    })
14874}
14875
14876fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
14877    Ok(store.nodes_by_kind("semantic_concept")?.len()
14878        + store.nodes_by_kind("semantic_entity")?.len())
14879}
14880
14881fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
14882    if limit == 0 {
14883        return 0;
14884    }
14885    limit.saturating_mul(4).clamp(
14886        GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
14887        GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
14888    )
14889}
14890
14891fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
14892    if limit == 0 {
14893        return usize::MAX;
14894    }
14895    limit.saturating_mul(3).max(limit).max(seed_count)
14896}
14897
14898fn graph_db_semantic_seeded_neighborhood(
14899    store: &impl GraphStore,
14900    seed_ids: &[String],
14901    depth: usize,
14902    limit: usize,
14903) -> Result<GraphDbSemanticSeededSubgraph> {
14904    let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
14905    let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
14906    let mut diagnostics = vec![
14907        "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
14908        "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(),
14909        format!(
14910            "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
14911            if edge_scan_cap == 0 {
14912                "unbounded".to_string()
14913            } else {
14914                edge_scan_cap.to_string()
14915            },
14916            if node_discovery_cap == usize::MAX {
14917                "unbounded".to_string()
14918            } else {
14919                node_discovery_cap.to_string()
14920            }
14921        ),
14922    ];
14923
14924    let options = SemanticSeededNeighborhoodOptions::new(depth, limit)
14925        .with_edge_scan_cap(edge_scan_cap)
14926        .with_node_discovery_cap(node_discovery_cap);
14927    let result = store.semantic_seeded_neighborhood(seed_ids, &options)?;
14928
14929    for seed_id in &result.missing_seed_ids {
14930        diagnostics.push(format!(
14931            "semantic seed {seed_id} was not present in the graph store"
14932        ));
14933    }
14934
14935    if result.skipped_by_edge_cap > 0 {
14936        diagnostics.push(format!(
14937            "semantic-seeded expansion skipped {} lower-scoring incident/outgoing edge(s) after per-node caps",
14938            result.skipped_by_edge_cap
14939        ));
14940    }
14941    if result.skipped_by_node_cap > 0 {
14942        diagnostics.push(format!(
14943            "semantic-seeded expansion skipped {} lower-scoring node discovery edge(s) after the discovery cap",
14944            result.skipped_by_node_cap
14945        ));
14946    }
14947
14948    if result.truncated {
14949        diagnostics.push(format!(
14950            "semantic-seeded neighborhood truncated from {} to {limit} node(s)",
14951            result.total_discovered
14952        ));
14953    }
14954
14955    Ok(GraphDbSemanticSeededSubgraph {
14956        nodes: result.nodes,
14957        edges: result.edges,
14958        truncated: result.truncated,
14959        diagnostics,
14960    })
14961}
14962
14963#[allow(clippy::too_many_arguments)]
14964fn cmd_semantic_related(
14965    query: &str,
14966    path: &Path,
14967    scope: Option<&str>,
14968    limit: usize,
14969    kind: SemanticRelatedKind,
14970    json_output: bool,
14971    compact: bool,
14972    pretty: bool,
14973    terse: bool,
14974    schema: bool,
14975) -> Result<()> {
14976    let root = lint::resolve_project_root_or_canonical_path(path)?;
14977    write_traversal_graph_store(&root, path, scope)?;
14978    let graph_db = graph_substrate_db_path(&root, scope);
14979    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14980    let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
14981    if let Some(recovery) = store.read_only_recovery() {
14982        report
14983            .warnings
14984            .push(graph_db_read_recovery_diagnostic(recovery));
14985    }
14986
14987    if json_output {
14988        println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
14989    } else if compact {
14990        for item in &report.items {
14991            println!(
14992                "{:.3}\t{}\t{}\t{}",
14993                item.score, item.kind, item.label, item.handle
14994            );
14995        }
14996        for warning in &report.warnings {
14997            eprintln!("warning: {warning}");
14998        }
14999    } else {
15000        println!(
15001            "Related semantic graph rows for {:?} ({})",
15002            report.query, report.embedding_model
15003        );
15004        for item in &report.items {
15005            println!(
15006                "  {:.3} [{}] {} ({})",
15007                item.score, item.kind, item.label, item.handle
15008            );
15009            if let Some(detail) = &item.detail {
15010                println!("      {}", detail);
15011            }
15012            if let Some(file_path) = &item.file_path {
15013                println!("      file: {}", file_path);
15014            }
15015            println!("      expand: {}", item.expand);
15016        }
15017        for warning in &report.warnings {
15018            eprintln!("warning: {warning}");
15019        }
15020    }
15021
15022    Ok(())
15023}
15024
15025#[derive(Serialize)]
15026struct SourceLinePreview {
15027    line: usize,
15028    text: String,
15029}
15030
15031#[derive(Serialize)]
15032pub(crate) struct SourceRangePreview {
15033    start: usize,
15034    end: usize,
15035    total_lines: usize,
15036    truncated_before: bool,
15037    truncated_after: bool,
15038}
15039
15040#[derive(Serialize)]
15041struct SourceExpandCommands {
15042    #[serde(skip_serializing_if = "Option::is_none")]
15043    before: Option<String>,
15044    #[serde(skip_serializing_if = "Option::is_none")]
15045    after: Option<String>,
15046    #[serde(skip_serializing_if = "Option::is_none")]
15047    body: Option<String>,
15048    file: String,
15049    #[serde(skip_serializing_if = "Option::is_none")]
15050    markdown_ast: Option<String>,
15051}
15052
15053#[derive(Serialize)]
15054struct SourceSymbolRef {
15055    handle: String,
15056    name: String,
15057    kind: String,
15058    language: String,
15059    file: String,
15060    line: usize,
15061    #[serde(skip_serializing_if = "Option::is_none")]
15062    end_line: Option<usize>,
15063    #[serde(skip_serializing_if = "Option::is_none")]
15064    signature: Option<String>,
15065    #[serde(skip_serializing_if = "Option::is_none")]
15066    span: Option<AstSpanPreview>,
15067    expand: String,
15068}
15069
15070#[derive(Serialize)]
15071struct SourceSummaryRef {
15072    handle: String,
15073    symbol_name: String,
15074    file_path: String,
15075    summary: String,
15076    expand: String,
15077}
15078
15079#[derive(Serialize)]
15080struct SourceReadReport {
15081    handle: String,
15082    root: String,
15083    file: String,
15084    range: SourceRangePreview,
15085    preview: Vec<SourceLinePreview>,
15086    symbols: Vec<SourceSymbolRef>,
15087    summaries: Vec<SourceSummaryRef>,
15088    #[serde(skip_serializing_if = "Option::is_none")]
15089    markdown: Option<SourceReadMarkdownProjection>,
15090    expand: SourceExpandCommands,
15091    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15092    warnings: Vec<String>,
15093}
15094
15095#[derive(Serialize)]
15096struct SourceReadAstExpandCommands {
15097    window: String,
15098    file_window: String,
15099    #[serde(skip_serializing_if = "Option::is_none")]
15100    markdown_ast: Option<String>,
15101}
15102
15103#[derive(Serialize)]
15104struct SourceReadAstReport {
15105    handle: String,
15106    root: String,
15107    file: String,
15108    range: SourceRangePreview,
15109    symbols: Vec<SourceSymbolRef>,
15110    summaries: Vec<SourceSummaryRef>,
15111    #[serde(skip_serializing_if = "Option::is_none")]
15112    markdown: Option<SourceReadMarkdownProjection>,
15113    expand: SourceReadAstExpandCommands,
15114    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15115    warnings: Vec<String>,
15116}
15117
15118#[derive(Serialize)]
15119struct SymbolReadTarget {
15120    handle: String,
15121    name: String,
15122    kind: String,
15123    language: String,
15124    file: String,
15125    line: usize,
15126    #[serde(skip_serializing_if = "Option::is_none")]
15127    end_line: Option<usize>,
15128    #[serde(skip_serializing_if = "Option::is_none")]
15129    signature: Option<String>,
15130    #[serde(skip_serializing_if = "Option::is_none")]
15131    parent_module: Option<String>,
15132    #[serde(skip_serializing_if = "Option::is_none")]
15133    visibility: Option<String>,
15134    #[serde(skip_serializing_if = "Option::is_none")]
15135    span: Option<AstSpanPreview>,
15136}
15137
15138#[derive(Serialize)]
15139struct SymbolReadExpandCommands {
15140    source_window: String,
15141    #[serde(skip_serializing_if = "Option::is_none")]
15142    body: Option<String>,
15143    file: String,
15144    explain: String,
15145    callers: String,
15146    callees: String,
15147    #[serde(skip_serializing_if = "Option::is_none")]
15148    markdown_ast: Option<String>,
15149}
15150
15151#[derive(Serialize)]
15152struct SymbolReadReport {
15153    handle: String,
15154    root: String,
15155    query: String,
15156    symbol: SymbolReadTarget,
15157    range: SourceRangePreview,
15158    body: Vec<SourceLinePreview>,
15159    child_symbols: Vec<SourceSymbolRef>,
15160    summaries: Vec<SourceSummaryRef>,
15161    expand: SymbolReadExpandCommands,
15162    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15163    warnings: Vec<String>,
15164}
15165
15166#[derive(Clone)]
15167pub(crate) struct MarkdownAstRawNode {
15168    handle: String,
15169    span_handle: String,
15170    name: String,
15171    kind: String,
15172    block_kind: String,
15173    node_kind: String,
15174    start_byte: usize,
15175    end_byte: usize,
15176    body_start_byte: Option<usize>,
15177    body_end_byte: Option<usize>,
15178}
15179
15180#[derive(Clone)]
15181pub(crate) struct MarkdownAstProjection {
15182    source_hash: String,
15183    nodes: Vec<MarkdownAstRawNode>,
15184    parse_duration_micros: u128,
15185    cache_hit: bool,
15186}
15187
15188#[derive(Clone)]
15189struct MarkdownAstCacheEntry {
15190    source_hash: String,
15191    nodes: Vec<MarkdownAstRawNode>,
15192    parse_duration_micros: u128,
15193}
15194
15195static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15196    OnceLock::new();
15197
15198#[derive(Serialize, Clone)]
15199struct MarkdownAstNodeMetadata {
15200    #[serde(skip_serializing_if = "Option::is_none")]
15201    heading_level: Option<usize>,
15202    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15203    section_path: Vec<String>,
15204    #[serde(skip_serializing_if = "Option::is_none")]
15205    section_handle: Option<String>,
15206    #[serde(skip_serializing_if = "Option::is_none")]
15207    list_depth: Option<usize>,
15208    #[serde(skip_serializing_if = "Option::is_none")]
15209    list_marker: Option<String>,
15210    #[serde(skip_serializing_if = "Option::is_none")]
15211    list_order: Option<usize>,
15212    #[serde(skip_serializing_if = "Option::is_none")]
15213    fence_language: Option<String>,
15214    #[serde(skip_serializing_if = "Option::is_none")]
15215    fence_marker: Option<String>,
15216    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15217    embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15218}
15219
15220#[derive(Serialize, Clone)]
15221struct MarkdownAstNodeExpand {
15222    source_window: String,
15223    source_body: String,
15224    symbol_read: String,
15225    edit_intents: String,
15226}
15227
15228#[derive(Serialize, Clone)]
15229struct MarkdownAstCacheReport {
15230    source_hash: String,
15231    cache_hit: bool,
15232    parse_duration_micros: u128,
15233    node_count: usize,
15234    section_count: usize,
15235    list_item_count: usize,
15236    code_block_count: usize,
15237}
15238
15239#[derive(Serialize, Clone)]
15240struct MarkdownAstPhaseTiming {
15241    name: String,
15242    duration_micros: u128,
15243    detail: String,
15244}
15245
15246#[derive(Serialize, Clone)]
15247struct MarkdownAstOutlineEntry {
15248    handle: String,
15249    span_handle: String,
15250    name: String,
15251    kind: String,
15252    block_kind: String,
15253    line: usize,
15254    end_line: usize,
15255    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15256    section_path: Vec<String>,
15257    child_count: usize,
15258    expand: String,
15259}
15260
15261#[derive(Serialize, Clone)]
15262struct MarkdownAstProjectionPreview {
15263    mode: String,
15264    total_nodes: usize,
15265    returned_nodes: usize,
15266    omitted_nodes: usize,
15267    selected_node: Option<String>,
15268    cache: MarkdownAstCacheReport,
15269    outline: Vec<MarkdownAstOutlineEntry>,
15270    phase_timings: Vec<MarkdownAstPhaseTiming>,
15271}
15272
15273#[derive(Serialize)]
15274struct SourceReadMarkdownProjection {
15275    handle: String,
15276    mode: String,
15277    total_nodes: usize,
15278    visible_nodes: usize,
15279    outline: Vec<MarkdownAstOutlineEntry>,
15280    expand: String,
15281}
15282
15283#[derive(Serialize, Clone)]
15284struct SourceByteRangePreview {
15285    start: usize,
15286    end: usize,
15287}
15288
15289#[derive(Serialize, Clone)]
15290struct MarkdownAstNode {
15291    handle: String,
15292    span_handle: String,
15293    name: String,
15294    kind: String,
15295    block_kind: String,
15296    node_kind: String,
15297    line: usize,
15298    end_line: usize,
15299    byte_span: SourceByteRangePreview,
15300    #[serde(skip_serializing_if = "Option::is_none")]
15301    body_byte_span: Option<SourceByteRangePreview>,
15302    parent_handle: Option<String>,
15303    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15304    child_handles: Vec<String>,
15305    metadata: MarkdownAstNodeMetadata,
15306    expand: MarkdownAstNodeExpand,
15307}
15308
15309#[derive(Serialize)]
15310struct MarkdownAstExpandCommands {
15311    file: String,
15312    source_read: String,
15313    edit_intents: String,
15314}
15315
15316#[derive(Serialize)]
15317struct MarkdownAstReport {
15318    handle: String,
15319    root: String,
15320    file: String,
15321    range: SourceRangePreview,
15322    projection: MarkdownAstProjectionPreview,
15323    nodes: Vec<MarkdownAstNode>,
15324    expand: MarkdownAstExpandCommands,
15325    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15326    warnings: Vec<String>,
15327}
15328
15329pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
15330    let candidate = if file.is_absolute() {
15331        file.to_path_buf()
15332    } else {
15333        root.join(file)
15334    };
15335    let canonical = candidate
15336        .canonicalize()
15337        .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
15338    if !canonical.is_file() {
15339        bail!("source file is not a regular file: {}", canonical.display());
15340    }
15341    let canonical_root = root
15342        .canonicalize()
15343        .with_context(|| format!("canonicalizing project root {}", root.display()))?;
15344    if !canonical.starts_with(&canonical_root) {
15345        bail!(
15346            "source file {} is outside project root {}",
15347            canonical.display(),
15348            canonical_root.display()
15349        );
15350    }
15351    Ok(canonical)
15352}
15353
15354pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
15355    source_read_window_command(root, file, start, lines)
15356}
15357
15358pub(crate) fn source_read_window_command(
15359    root: &Path,
15360    file: &str,
15361    start: usize,
15362    lines: usize,
15363) -> String {
15364    format!(
15365        "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
15366        shell_quote(file),
15367        shell_quote(&root.to_string_lossy()),
15368        start,
15369        lines
15370    )
15371}
15372
15373pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
15374    format!(
15375        "tsift --envelope source-read {} --path {} --budget normal",
15376        shell_quote(file),
15377        shell_quote(&root.to_string_lossy())
15378    )
15379}
15380
15381pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
15382    format!(
15383        "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
15384        shell_quote(symbol),
15385        shell_quote(&root.to_string_lossy()),
15386        shell_quote(file)
15387    )
15388}
15389
15390fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
15391    format!(
15392        "tsift --envelope explain {} --path {} --budget normal",
15393        shell_quote(symbol),
15394        shell_quote(&root.to_string_lossy())
15395    )
15396}
15397
15398fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
15399    format!(
15400        "tsift graph {} --path {} --{} --json",
15401        shell_quote(symbol),
15402        shell_quote(&root.to_string_lossy()),
15403        relation
15404    )
15405}
15406
15407fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
15408    format!(
15409        "tsift summarize {} --path {} --json",
15410        shell_quote(symbol),
15411        shell_quote(&root.to_string_lossy())
15412    )
15413}
15414
15415pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
15416    let mut command = format!(
15417        "tsift --envelope markdown-ast {} --path {} --budget normal",
15418        shell_quote(file),
15419        shell_quote(&root.to_string_lossy())
15420    );
15421    if let Some(node) = node {
15422        command.push_str(" --node ");
15423        command.push_str(&shell_quote(node));
15424    }
15425    command
15426}
15427
15428fn markdown_edit_intents_command(root: &Path) -> String {
15429    format!(
15430        "tsift --envelope edit-intents --path {} --budget normal",
15431        shell_quote(&root.to_string_lossy())
15432    )
15433}
15434
15435pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
15436    usize::try_from(symbol.line)
15437        .ok()
15438        .and_then(|line| line.checked_add(1))
15439        .unwrap_or(1)
15440}
15441
15442fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
15443    symbol
15444        .end_line
15445        .and_then(|line| usize::try_from(line).ok())
15446        .and_then(|line| line.checked_add(1))
15447}
15448
15449fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
15450    value.and_then(|byte| usize::try_from(byte).ok())
15451}
15452
15453fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
15454    let byte = byte.min(source.len());
15455    source[..byte]
15456        .iter()
15457        .filter(|value| **value == b'\n')
15458        .count()
15459        .saturating_add(1)
15460}
15461
15462fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
15463    source_line_for_byte(source, end_byte.saturating_sub(1))
15464}
15465
15466fn ast_span_handle(
15467    file: &str,
15468    name: &str,
15469    kind: &str,
15470    start_byte: usize,
15471    end_byte: usize,
15472) -> String {
15473    stable_handle(
15474        "span",
15475        &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
15476    )
15477}
15478
15479pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
15480    Some((
15481        symbol_span_byte(symbol.start_byte)?,
15482        symbol_span_byte(symbol.end_byte)?,
15483    ))
15484}
15485
15486pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
15487    Some((
15488        symbol_span_byte(symbol.start_byte)?,
15489        symbol_span_byte(symbol.end_byte)?,
15490    ))
15491}
15492
15493pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
15494    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15495    Some(ast_span_handle(
15496        &symbol.file,
15497        &symbol.name,
15498        &symbol.kind,
15499        start_byte,
15500        end_byte,
15501    ))
15502}
15503
15504fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
15505    left.file == right.file
15506        && left.name == right.name
15507        && left.kind == right.kind
15508        && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
15509}
15510
15511fn stored_symbol_parent_span_handle(
15512    symbol: &index::StoredSymbol,
15513    symbols: &[index::StoredSymbol],
15514) -> Option<String> {
15515    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15516    symbols
15517        .iter()
15518        .filter(|candidate| {
15519            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15520                return false;
15521            }
15522            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15523            else {
15524                return false;
15525            };
15526            candidate_start <= start_byte && candidate_end >= end_byte
15527        })
15528        .min_by_key(|candidate| {
15529            stored_symbol_span_bounds(candidate)
15530                .map(|(start, end)| end.saturating_sub(start))
15531                .unwrap_or(usize::MAX)
15532        })
15533        .and_then(stored_symbol_span_handle)
15534}
15535
15536fn stored_symbol_child_span_handles(
15537    symbol: &index::StoredSymbol,
15538    symbols: &[index::StoredSymbol],
15539    limit: usize,
15540) -> Vec<String> {
15541    let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
15542        return Vec::new();
15543    };
15544    symbols
15545        .iter()
15546        .filter(|candidate| {
15547            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15548                return false;
15549            }
15550            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15551            else {
15552                return false;
15553            };
15554            candidate_start >= start_byte && candidate_end <= end_byte
15555        })
15556        .take(limit)
15557        .filter_map(stored_symbol_span_handle)
15558        .collect()
15559}
15560
15561fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
15562    let start = start_byte.min(source.len());
15563    let line_end = source[start..]
15564        .iter()
15565        .position(|value| *value == b'\n')
15566        .map(|pos| start + pos)
15567        .unwrap_or(source.len());
15568    let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
15569    let marker = line.trim_start();
15570    let level = marker.chars().take_while(|ch| *ch == '#').count();
15571    (1..=6).contains(&level).then_some(level)
15572}
15573
15574fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
15575    let start = start_byte.min(source.len());
15576    let line_start = source[..start]
15577        .iter()
15578        .rposition(|value| *value == b'\n')
15579        .map(|pos| pos + 1)
15580        .unwrap_or(0);
15581    source[line_start..start]
15582        .iter()
15583        .map(|byte| match byte {
15584            b'\t' => 4,
15585            b' ' => 1,
15586            _ => 0,
15587        })
15588        .sum::<usize>()
15589        / 2
15590}
15591
15592fn markdown_enclosing_heading_symbols<'a>(
15593    file: &str,
15594    start_byte: usize,
15595    end_byte: usize,
15596    symbols: &'a [index::StoredSymbol],
15597) -> Vec<&'a index::StoredSymbol> {
15598    let mut headings = symbols
15599        .iter()
15600        .filter(|candidate| candidate.file == file && candidate.kind == "heading")
15601        .filter(|candidate| {
15602            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15603            else {
15604                return false;
15605            };
15606            candidate_start <= start_byte && candidate_end >= end_byte
15607        })
15608        .collect::<Vec<_>>();
15609    headings.sort_by(|left, right| {
15610        stored_symbol_span_bounds(left)
15611            .map(|(start, _)| start)
15612            .unwrap_or(usize::MAX)
15613            .cmp(
15614                &stored_symbol_span_bounds(right)
15615                    .map(|(start, _)| start)
15616                    .unwrap_or(usize::MAX),
15617            )
15618            .then(left.name.cmp(&right.name))
15619    });
15620    headings
15621}
15622
15623fn markdown_stored_symbol_metadata(
15624    symbol: &index::StoredSymbol,
15625    source: &[u8],
15626    symbols: &[index::StoredSymbol],
15627) -> Option<MarkdownSpanMetadata> {
15628    if symbol.language != "markdown" {
15629        return None;
15630    }
15631    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15632    let section_symbols =
15633        markdown_enclosing_heading_symbols(&symbol.file, start_byte, end_byte, symbols);
15634    let section_path = section_symbols
15635        .iter()
15636        .map(|heading| heading.name.clone())
15637        .collect::<Vec<_>>();
15638    let section_handle = section_symbols
15639        .last()
15640        .and_then(|heading| stored_symbol_span_handle(heading));
15641    let heading_level = (symbol.kind == "heading")
15642        .then(|| markdown_heading_level(source, start_byte))
15643        .flatten();
15644    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15645    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15646    let embedded_symbols = if symbol.kind == "code_block" {
15647        markdown_embedded_symbols(
15648            &symbol.file,
15649            source,
15650            symbol_span_byte(symbol.body_start_byte),
15651            symbol_span_byte(symbol.body_end_byte),
15652            fence_language.as_deref(),
15653        )
15654    } else {
15655        Vec::new()
15656    };
15657
15658    (heading_level.is_some()
15659        || !section_path.is_empty()
15660        || section_handle.is_some()
15661        || list_depth.is_some()
15662        || fence_language.is_some()
15663        || !embedded_symbols.is_empty())
15664    .then_some(MarkdownSpanMetadata {
15665        heading_level,
15666        section_path,
15667        section_handle,
15668        list_depth,
15669        fence_language,
15670        embedded_symbols,
15671    })
15672}
15673
15674fn markdown_symbol_hit_metadata(
15675    symbol: &index::SymbolHit,
15676    source: &[u8],
15677    start_byte: usize,
15678) -> Option<MarkdownSpanMetadata> {
15679    if symbol.language != "markdown" {
15680        return None;
15681    }
15682    let heading_level = (symbol.kind == "heading")
15683        .then(|| markdown_heading_level(source, start_byte))
15684        .flatten();
15685    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15686    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15687    let embedded_symbols = if symbol.kind == "code_block" {
15688        markdown_embedded_symbols(
15689            &symbol.file,
15690            source,
15691            symbol_span_byte(symbol.body_start_byte),
15692            symbol_span_byte(symbol.body_end_byte),
15693            fence_language.as_deref(),
15694        )
15695    } else {
15696        Vec::new()
15697    };
15698    (heading_level.is_some()
15699        || list_depth.is_some()
15700        || fence_language.is_some()
15701        || !embedded_symbols.is_empty())
15702    .then_some(MarkdownSpanMetadata {
15703        heading_level,
15704        section_path: Vec::new(),
15705        section_handle: None,
15706        list_depth,
15707        fence_language,
15708        embedded_symbols,
15709    })
15710}
15711
15712fn is_markdown_path(path: &Path) -> bool {
15713    path.extension()
15714        .and_then(|ext| ext.to_str())
15715        .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
15716        .unwrap_or(false)
15717}
15718
15719fn markdown_ast_block_kind(kind: &str) -> String {
15720    match kind {
15721        "heading" => "section",
15722        "code_block" => "fenced_code_block",
15723        "list_item" => "list_item",
15724        other => other,
15725    }
15726    .to_string()
15727}
15728
15729fn markdown_embedded_language_key(language: &str) -> Option<String> {
15730    let key = language
15731        .split_whitespace()
15732        .next()
15733        .unwrap_or("")
15734        .trim()
15735        .trim_start_matches("language-")
15736        .trim_start_matches("lang-")
15737        .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
15738        .to_ascii_lowercase();
15739    (!key.is_empty()).then_some(key)
15740}
15741
15742fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
15743    let key = markdown_embedded_language_key(language)?;
15744    let extension = match key.as_str() {
15745        "rust" => "rs",
15746        "python" => "py",
15747        "typescript" => "ts",
15748        "javascript" => "js",
15749        "kotlin" => "kt",
15750        "shell" | "sh" | "zsh" => "bash",
15751        other => other,
15752    };
15753    let lang = graph::Lang::from_extension(extension)?;
15754    (lang.name() != "markdown").then_some(lang)
15755}
15756
15757fn markdown_embedded_ast_span_handle(
15758    file: &str,
15759    language: &str,
15760    name: &str,
15761    kind: &str,
15762    start_byte: usize,
15763    end_byte: usize,
15764) -> String {
15765    stable_handle(
15766        "span",
15767        &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
15768    )
15769}
15770
15771fn markdown_embedded_symbols(
15772    file: &str,
15773    source: &[u8],
15774    body_start_byte: Option<usize>,
15775    body_end_byte: Option<usize>,
15776    fence_language: Option<&str>,
15777) -> Vec<MarkdownEmbeddedSymbol> {
15778    let Some(fence_language) = fence_language else {
15779        return Vec::new();
15780    };
15781    let Some(lang) = markdown_embedded_lang(fence_language) else {
15782        return Vec::new();
15783    };
15784    let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
15785        return Vec::new();
15786    };
15787    let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
15788    else {
15789        return Vec::new();
15790    };
15791    if body.is_empty() {
15792        return Vec::new();
15793    }
15794
15795    let Ok(symbols) = lang.extract_symbols(body) else {
15796        return Vec::new();
15797    };
15798    let language = lang.name().to_string();
15799    symbols
15800        .into_iter()
15801        .map(|symbol| {
15802            let start_byte = body_start_byte.saturating_add(symbol.start_byte);
15803            let end_byte = body_start_byte.saturating_add(symbol.end_byte);
15804            let body_start = symbol
15805                .body_start_byte
15806                .map(|byte| body_start_byte.saturating_add(byte));
15807            let body_end = symbol
15808                .body_end_byte
15809                .map(|byte| body_start_byte.saturating_add(byte));
15810            let start_line = source_line_for_byte(source, start_byte);
15811            let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
15812            MarkdownEmbeddedSymbol {
15813                handle: markdown_embedded_ast_span_handle(
15814                    file,
15815                    &language,
15816                    &symbol.name,
15817                    &symbol.kind,
15818                    start_byte,
15819                    end_byte,
15820                ),
15821                name: symbol.name,
15822                kind: symbol.kind,
15823                language: language.clone(),
15824                node_kind: symbol.node_kind,
15825                start_byte,
15826                end_byte,
15827                start_line,
15828                end_line,
15829                body_start_byte: body_start,
15830                body_end_byte: body_end,
15831                body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
15832                body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
15833            }
15834        })
15835        .collect()
15836}
15837
15838fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
15839    let start = start_byte.min(source.len());
15840    let line_start = source[..start]
15841        .iter()
15842        .rposition(|value| *value == b'\n')
15843        .map(|pos| pos + 1)
15844        .unwrap_or(0);
15845    let line_end = source[start..]
15846        .iter()
15847        .position(|value| *value == b'\n')
15848        .map(|pos| start + pos)
15849        .unwrap_or(source.len());
15850    std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
15851}
15852
15853fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
15854    let line = markdown_source_line(source, start_byte);
15855    let trimmed = line.trim_start();
15856    for marker in ["-", "*", "+"] {
15857        if trimmed
15858            .strip_prefix(marker)
15859            .and_then(|rest| rest.strip_prefix(' '))
15860            .is_some()
15861        {
15862            return (Some(marker.to_string()), None);
15863        }
15864    }
15865
15866    let digit_end = trimmed
15867        .find(|ch: char| !ch.is_ascii_digit())
15868        .unwrap_or(trimmed.len());
15869    let (digits, rest) = trimmed.split_at(digit_end);
15870    if !digits.is_empty() {
15871        for marker in [".", ")"] {
15872            if rest
15873                .strip_prefix(marker)
15874                .and_then(|value| value.strip_prefix(' '))
15875                .is_some()
15876            {
15877                return (
15878                    Some(format!("{digits}{marker}")),
15879                    digits.parse::<usize>().ok(),
15880                );
15881            }
15882        }
15883    }
15884    (None, None)
15885}
15886
15887fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
15888    let line = markdown_source_line(source, start_byte);
15889    let trimmed = line.trim_start();
15890    ["```", "~~~"]
15891        .into_iter()
15892        .find(|marker| trimmed.starts_with(marker))
15893        .map(str::to_string)
15894}
15895
15896fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
15897    let mut nodes = graph::Lang::Markdown
15898        .extract_symbols(source)
15899        .context("extracting Markdown AST nodes")?
15900        .into_iter()
15901        .map(|symbol| {
15902            let body_start_byte = symbol.body_start_byte;
15903            let body_end_byte = symbol.body_end_byte;
15904            let span_handle = ast_span_handle(
15905                file,
15906                &symbol.name,
15907                &symbol.kind,
15908                symbol.start_byte,
15909                symbol.end_byte,
15910            );
15911            MarkdownAstRawNode {
15912                handle: stable_handle(
15913                    "mdast",
15914                    &format!(
15915                        "{}:{}:{}:{}:{}",
15916                        file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
15917                    ),
15918                ),
15919                span_handle,
15920                name: symbol.name,
15921                kind: symbol.kind.clone(),
15922                block_kind: markdown_ast_block_kind(&symbol.kind),
15923                node_kind: symbol.node_kind,
15924                start_byte: symbol.start_byte,
15925                end_byte: symbol.end_byte,
15926                body_start_byte,
15927                body_end_byte,
15928            }
15929        })
15930        .collect::<Vec<_>>();
15931    nodes.sort_by(|left, right| {
15932        left.start_byte
15933            .cmp(&right.start_byte)
15934            .then(left.end_byte.cmp(&right.end_byte))
15935            .then(left.kind.cmp(&right.kind))
15936            .then(left.name.cmp(&right.name))
15937    });
15938    Ok(nodes)
15939}
15940
15941pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
15942    let source_hash = blake3::hash(source).to_hex().to_string();
15943    let cache_key = format!("{file}:{source_hash}");
15944    let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
15945    if let Some(entry) = cache
15946        .lock()
15947        .expect("markdown ast cache poisoned")
15948        .get(&cache_key)
15949    {
15950        return Ok(MarkdownAstProjection {
15951            source_hash: entry.source_hash.clone(),
15952            nodes: entry.nodes.clone(),
15953            parse_duration_micros: entry.parse_duration_micros,
15954            cache_hit: true,
15955        });
15956    }
15957
15958    let started = Instant::now();
15959    let nodes = markdown_ast_extract_raw_nodes(file, source)?;
15960    let parse_duration_micros = started.elapsed().as_micros();
15961    cache.lock().expect("markdown ast cache poisoned").insert(
15962        cache_key,
15963        MarkdownAstCacheEntry {
15964            source_hash: source_hash.clone(),
15965            nodes: nodes.clone(),
15966            parse_duration_micros,
15967        },
15968    );
15969    Ok(MarkdownAstProjection {
15970        source_hash,
15971        nodes,
15972        parse_duration_micros,
15973        cache_hit: false,
15974    })
15975}
15976
15977fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
15978    MarkdownAstCacheReport {
15979        source_hash: projection.source_hash.clone(),
15980        cache_hit: projection.cache_hit,
15981        parse_duration_micros: projection.parse_duration_micros,
15982        node_count: projection.nodes.len(),
15983        section_count: projection
15984            .nodes
15985            .iter()
15986            .filter(|node| node.kind == "heading")
15987            .count(),
15988        list_item_count: projection
15989            .nodes
15990            .iter()
15991            .filter(|node| node.kind == "list_item")
15992            .count(),
15993        code_block_count: projection
15994            .nodes
15995            .iter()
15996            .filter(|node| node.kind == "code_block")
15997            .count(),
15998    }
15999}
16000
16001fn markdown_ast_node_direct_child_count(
16002    node: &MarkdownAstRawNode,
16003    nodes: &[MarkdownAstRawNode],
16004) -> usize {
16005    nodes
16006        .iter()
16007        .filter(|candidate| {
16008            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16009        })
16010        .count()
16011}
16012
16013fn markdown_ast_outline_entry(
16014    root: &Path,
16015    file: &str,
16016    source: &[u8],
16017    nodes: &[MarkdownAstRawNode],
16018    node: &MarkdownAstRawNode,
16019    max_bytes: usize,
16020) -> MarkdownAstOutlineEntry {
16021    let line = source_line_for_byte(source, node.start_byte);
16022    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16023    MarkdownAstOutlineEntry {
16024        handle: node.handle.clone(),
16025        span_handle: node.span_handle.clone(),
16026        name: truncate_for_budget(&node.name, max_bytes),
16027        kind: node.kind.clone(),
16028        block_kind: node.block_kind.clone(),
16029        line,
16030        end_line,
16031        section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16032        child_count: markdown_ast_node_direct_child_count(node, nodes),
16033        expand: markdown_ast_command(root, file, Some(&node.handle)),
16034    }
16035}
16036
16037fn markdown_ast_outline_entries(
16038    root: &Path,
16039    file: &str,
16040    source: &[u8],
16041    nodes: &[MarkdownAstRawNode],
16042    limit: usize,
16043    max_bytes: usize,
16044) -> Vec<MarkdownAstOutlineEntry> {
16045    let mut headings = nodes
16046        .iter()
16047        .filter(|node| node.kind == "heading")
16048        .collect::<Vec<_>>();
16049    let mut blocks = nodes
16050        .iter()
16051        .filter(|node| node.kind != "heading")
16052        .collect::<Vec<_>>();
16053    headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16054    blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16055    headings
16056        .into_iter()
16057        .chain(blocks)
16058        .take(limit)
16059        .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16060        .collect()
16061}
16062
16063fn markdown_ast_node_intersects_lines(
16064    source: &[u8],
16065    node: &MarkdownAstRawNode,
16066    start: usize,
16067    end: usize,
16068) -> bool {
16069    let line = source_line_for_byte(source, node.start_byte);
16070    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16071    line <= end && end_line >= start
16072}
16073
16074fn source_read_markdown_projection(
16075    root: &Path,
16076    file: &str,
16077    source: &[u8],
16078    start: usize,
16079    end: usize,
16080    budget: ResponseBudget,
16081) -> Result<SourceReadMarkdownProjection> {
16082    let projection = markdown_ast_projection(file, source)?;
16083    let visible_nodes = projection
16084        .nodes
16085        .iter()
16086        .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16087        .collect::<Vec<_>>();
16088    let mut outline_nodes = visible_nodes.clone();
16089    outline_nodes.sort_by_key(|node| {
16090        (
16091            node.kind != "heading",
16092            node.start_byte,
16093            node.end_byte,
16094            node.name.as_str(),
16095        )
16096    });
16097    let outline = outline_nodes
16098        .into_iter()
16099        .take(budget.preview_items())
16100        .map(|node| {
16101            markdown_ast_outline_entry(
16102                root,
16103                file,
16104                source,
16105                &projection.nodes,
16106                node,
16107                budget.preview_bytes(),
16108            )
16109        })
16110        .collect::<Vec<_>>();
16111    Ok(SourceReadMarkdownProjection {
16112        handle: stable_handle(
16113            "mdproj",
16114            &format!("{file}:{start}:{end}:{}", projection.source_hash),
16115        ),
16116        mode: "window_outline".to_string(),
16117        total_nodes: projection.nodes.len(),
16118        visible_nodes: visible_nodes.len(),
16119        outline,
16120        expand: markdown_ast_command(root, file, None),
16121    })
16122}
16123
16124fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16125    if parent.handle == child.handle {
16126        return false;
16127    }
16128    parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16129}
16130
16131fn markdown_ast_parent_handle(
16132    node: &MarkdownAstRawNode,
16133    nodes: &[MarkdownAstRawNode],
16134) -> Option<String> {
16135    nodes
16136        .iter()
16137        .filter(|candidate| markdown_ast_contains(candidate, node))
16138        .min_by_key(|candidate| {
16139            (
16140                candidate.end_byte.saturating_sub(candidate.start_byte),
16141                candidate.start_byte,
16142            )
16143        })
16144        .map(|candidate| candidate.handle.clone())
16145}
16146
16147fn markdown_ast_child_handles(
16148    node: &MarkdownAstRawNode,
16149    nodes: &[MarkdownAstRawNode],
16150    limit: usize,
16151) -> Vec<String> {
16152    nodes
16153        .iter()
16154        .filter(|candidate| {
16155            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16156        })
16157        .take(limit)
16158        .map(|candidate| candidate.handle.clone())
16159        .collect()
16160}
16161
16162fn markdown_ast_section_nodes<'a>(
16163    node: &MarkdownAstRawNode,
16164    nodes: &'a [MarkdownAstRawNode],
16165) -> Vec<&'a MarkdownAstRawNode> {
16166    let mut headings = nodes
16167        .iter()
16168        .filter(|candidate| candidate.kind == "heading")
16169        .filter(|candidate| {
16170            candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16171        })
16172        .collect::<Vec<_>>();
16173    headings.sort_by(|left, right| {
16174        left.start_byte
16175            .cmp(&right.start_byte)
16176            .then(left.end_byte.cmp(&right.end_byte))
16177            .then(left.name.cmp(&right.name))
16178    });
16179    headings
16180}
16181
16182fn markdown_ast_node_metadata(
16183    file: &str,
16184    node: &MarkdownAstRawNode,
16185    source: &[u8],
16186    nodes: &[MarkdownAstRawNode],
16187) -> MarkdownAstNodeMetadata {
16188    let section_nodes = markdown_ast_section_nodes(node, nodes);
16189    let section_path = section_nodes
16190        .iter()
16191        .map(|heading| heading.name.clone())
16192        .collect::<Vec<_>>();
16193    let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16194    let heading_level = (node.kind == "heading")
16195        .then(|| markdown_heading_level(source, node.start_byte))
16196        .flatten();
16197    let (list_marker, list_order) = if node.kind == "list_item" {
16198        markdown_list_attributes(source, node.start_byte)
16199    } else {
16200        (None, None)
16201    };
16202    let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16203    let embedded_symbols = if node.kind == "code_block" {
16204        markdown_embedded_symbols(
16205            file,
16206            source,
16207            node.body_start_byte,
16208            node.body_end_byte,
16209            fence_language.as_deref(),
16210        )
16211    } else {
16212        Vec::new()
16213    };
16214    MarkdownAstNodeMetadata {
16215        heading_level,
16216        section_path,
16217        section_handle,
16218        list_depth: (node.kind == "list_item")
16219            .then(|| markdown_list_depth(source, node.start_byte)),
16220        list_marker,
16221        list_order,
16222        fence_language,
16223        fence_marker: (node.kind == "code_block")
16224            .then(|| markdown_fence_marker(source, node.start_byte))
16225            .flatten(),
16226        embedded_symbols,
16227    }
16228}
16229
16230fn markdown_ast_node_expand(
16231    root: &Path,
16232    file: &str,
16233    node: &MarkdownAstRawNode,
16234    source: &[u8],
16235) -> MarkdownAstNodeExpand {
16236    let start_line = source_line_for_byte(source, node.start_byte);
16237    let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16238    let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16239    let body_start_line = node
16240        .body_start_byte
16241        .map(|byte| source_line_for_byte(source, byte))
16242        .unwrap_or(start_line);
16243    let body_end_line = node
16244        .body_end_byte
16245        .map(|byte| source_line_for_end_byte(source, byte))
16246        .unwrap_or(end_line)
16247        .max(body_start_line);
16248    let body_line_count = body_end_line
16249        .saturating_sub(body_start_line)
16250        .saturating_add(1)
16251        .max(1);
16252    MarkdownAstNodeExpand {
16253        source_window: source_read_command(root, file, start_line, line_count),
16254        source_body: source_read_command(root, file, body_start_line, body_line_count),
16255        symbol_read: source_symbol_read_command(root, &node.name, file),
16256        edit_intents: markdown_edit_intents_command(root),
16257    }
16258}
16259
16260fn markdown_ast_node(
16261    root: &Path,
16262    file: &str,
16263    node: &MarkdownAstRawNode,
16264    source: &[u8],
16265    nodes: &[MarkdownAstRawNode],
16266    child_limit: usize,
16267) -> MarkdownAstNode {
16268    let line = source_line_for_byte(source, node.start_byte);
16269    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16270    let body_byte_span = node
16271        .body_start_byte
16272        .zip(node.body_end_byte)
16273        .map(|(start, end)| SourceByteRangePreview { start, end });
16274    MarkdownAstNode {
16275        handle: node.handle.clone(),
16276        span_handle: node.span_handle.clone(),
16277        name: node.name.clone(),
16278        kind: node.kind.clone(),
16279        block_kind: node.block_kind.clone(),
16280        node_kind: node.node_kind.clone(),
16281        line,
16282        end_line,
16283        byte_span: SourceByteRangePreview {
16284            start: node.start_byte,
16285            end: node.end_byte,
16286        },
16287        body_byte_span,
16288        parent_handle: markdown_ast_parent_handle(node, nodes),
16289        child_handles: markdown_ast_child_handles(node, nodes, child_limit),
16290        metadata: markdown_ast_node_metadata(file, node, source, nodes),
16291        expand: markdown_ast_node_expand(root, file, node, source),
16292    }
16293}
16294
16295pub(crate) fn stored_symbol_ast_span(
16296    symbol: &index::StoredSymbol,
16297    source: &[u8],
16298    symbols: &[index::StoredSymbol],
16299    child_limit: usize,
16300) -> Option<AstSpanPreview> {
16301    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16302    let node_kind = symbol.node_kind.clone()?;
16303    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16304    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16305    Some(AstSpanPreview {
16306        handle: ast_span_handle(
16307            &symbol.file,
16308            &symbol.name,
16309            &symbol.kind,
16310            start_byte,
16311            end_byte,
16312        ),
16313        node_kind,
16314        start_byte,
16315        end_byte,
16316        start_line: source_line_for_byte(source, start_byte),
16317        end_line: source_line_for_end_byte(source, end_byte),
16318        body_start_byte,
16319        body_end_byte,
16320        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16321        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16322        parent_handle: stored_symbol_parent_span_handle(symbol, symbols),
16323        child_handles: stored_symbol_child_span_handles(symbol, symbols, child_limit),
16324        markdown: markdown_stored_symbol_metadata(symbol, source, symbols),
16325    })
16326}
16327
16328pub(crate) fn symbol_hit_ast_span(
16329    symbol: &index::SymbolHit,
16330    source: &[u8],
16331) -> Option<AstSpanPreview> {
16332    let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
16333    let node_kind = symbol.node_kind.clone()?;
16334    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16335    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16336    Some(AstSpanPreview {
16337        handle: ast_span_handle(
16338            &symbol.file,
16339            &symbol.name,
16340            &symbol.kind,
16341            start_byte,
16342            end_byte,
16343        ),
16344        node_kind,
16345        start_byte,
16346        end_byte,
16347        start_line: source_line_for_byte(source, start_byte),
16348        end_line: source_line_for_end_byte(source, end_byte),
16349        body_start_byte,
16350        body_end_byte,
16351        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16352        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16353        parent_handle: None,
16354        child_handles: Vec::new(),
16355        markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
16356    })
16357}
16358
16359pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
16360    usize::try_from(symbol.line)
16361        .ok()
16362        .and_then(|line| line.checked_add(1))
16363        .unwrap_or(1)
16364}
16365
16366pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
16367    symbol
16368        .end_line
16369        .and_then(|line| usize::try_from(line).ok())
16370        .and_then(|line| line.checked_add(1))
16371}
16372
16373fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
16374    if end == 0 {
16375        return false;
16376    }
16377    let symbol_start = source_symbol_line(symbol);
16378    let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
16379    symbol_start <= end && symbol_end >= start
16380}
16381
16382#[allow(clippy::too_many_arguments)]
16383fn load_source_symbols(
16384    root: &Path,
16385    file_abs: &Path,
16386    file_display: &str,
16387    source: &[u8],
16388    scope: Option<&str>,
16389    start: usize,
16390    end: usize,
16391    limit: usize,
16392    max_bytes: usize,
16393    warnings: &mut Vec<String>,
16394) -> Vec<SourceSymbolRef> {
16395    let db_path = match resolve_query_db_path(root, file_abs, scope) {
16396        Ok(path) => path,
16397        Err(err) => {
16398            warnings.push(format!("index refs unavailable: {err:#}"));
16399            return Vec::new();
16400        }
16401    };
16402    if !db_path.exists() {
16403        warnings.push(format!(
16404            "index refs unavailable: no index found at {}",
16405            db_path.display()
16406        ));
16407        return Vec::new();
16408    }
16409
16410    let db = match index::IndexDb::open_read_only_resilient(&db_path) {
16411        Ok(db) => db,
16412        Err(err) => {
16413            warnings.push(format!("index refs unavailable: {err:#}"));
16414            return Vec::new();
16415        }
16416    };
16417
16418    let file_key = file_abs.to_string_lossy().to_string();
16419    let symbols = match db.symbols_for_file(&file_key) {
16420        Ok(symbols) => symbols,
16421        Err(err) => {
16422            warnings.push(format!("symbol refs unavailable: {err:#}"));
16423            return Vec::new();
16424        }
16425    };
16426
16427    symbols
16428        .iter()
16429        .filter(|symbol| source_symbol_intersects(symbol, start, end))
16430        .take(limit)
16431        .map(|symbol| {
16432            let line = source_symbol_line(symbol);
16433            let end_line = source_symbol_end_line(symbol);
16434            let handle = stable_handle(
16435                "ssym",
16436                &format!("{}:{}:{}", file_display, symbol.name, line),
16437            );
16438            SourceSymbolRef {
16439                handle,
16440                name: truncate_for_budget(&symbol.name, max_bytes),
16441                kind: symbol.kind.clone(),
16442                language: symbol.language.clone(),
16443                file: file_display.to_string(),
16444                line,
16445                end_line,
16446                signature: symbol
16447                    .signature
16448                    .clone()
16449                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
16450                span: stored_symbol_ast_span(symbol, source, &symbols, limit),
16451                expand: source_symbol_read_command(root, &symbol.name, file_display),
16452            }
16453        })
16454        .collect()
16455}
16456
16457fn load_source_summaries(
16458    root: &Path,
16459    file_display: &str,
16460    limit: usize,
16461    max_bytes: usize,
16462    warnings: &mut Vec<String>,
16463) -> Vec<SourceSummaryRef> {
16464    let db_path = root.join(".tsift/summaries.db");
16465    if !db_path.exists() {
16466        return Vec::new();
16467    }
16468    let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
16469        Ok(db) => db,
16470        Err(err) => {
16471            warnings.push(format!("summary refs unavailable: {err:#}"));
16472            return Vec::new();
16473        }
16474    };
16475    let summaries = match db.get_by_file(file_display) {
16476        Ok(summaries) => summaries,
16477        Err(err) => {
16478            warnings.push(format!("summary refs unavailable: {err:#}"));
16479            return Vec::new();
16480        }
16481    };
16482
16483    summaries
16484        .into_iter()
16485        .take(limit)
16486        .map(|summary| SourceSummaryRef {
16487            handle: stable_handle(
16488                "sum",
16489                &format!(
16490                    "{}:{}:{}",
16491                    summary.file_path, summary.symbol_name, summary.id
16492                ),
16493            ),
16494            symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
16495            file_path: summary.file_path,
16496            summary: truncate_for_budget(&summary.summary, max_bytes),
16497            expand: source_summary_expand_command(root, &summary.symbol_name),
16498        })
16499        .collect()
16500}
16501
16502fn cmd_markdown_ast(
16503    file: &Path,
16504    path: &Path,
16505    node: Option<&str>,
16506    format: OutputFormat,
16507    absolute: bool,
16508    budget: ResponseBudget,
16509) -> Result<()> {
16510    let root = lint::resolve_project_root_or_canonical_path(path)?;
16511    let file_abs = resolve_source_file(&root, file)?;
16512    if !is_markdown_path(&file_abs) {
16513        bail!(
16514            "markdown-ast only supports Markdown files (.md/.mdx): {}",
16515            file_abs.display()
16516        );
16517    }
16518    let file_display = if absolute {
16519        file_abs.to_string_lossy().to_string()
16520    } else {
16521        relativize_pathbuf(&file_abs, &root)
16522            .to_string_lossy()
16523            .to_string()
16524    };
16525    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16526    let text = String::from_utf8_lossy(&source);
16527    let total_lines = text.lines().count();
16528    let projection = markdown_ast_projection(&file_display, &source)?;
16529    let raw_nodes = &projection.nodes;
16530    let max_items = budget.preview_items();
16531    let max_bytes = budget.preview_bytes();
16532
16533    let selected_nodes = if let Some(handle) = node {
16534        let matches = raw_nodes
16535            .iter()
16536            .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
16537            .collect::<Vec<_>>();
16538        if matches.is_empty() {
16539            bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
16540        }
16541        matches
16542    } else {
16543        raw_nodes.iter().take(max_items).collect::<Vec<_>>()
16544    };
16545    let nodes = selected_nodes
16546        .into_iter()
16547        .map(|raw| {
16548            let mut node =
16549                markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
16550            node.name = truncate_for_budget(&node.name, max_bytes);
16551            node
16552        })
16553        .collect::<Vec<_>>();
16554    let outline_started = Instant::now();
16555    let outline = markdown_ast_outline_entries(
16556        &root,
16557        &file_display,
16558        &source,
16559        raw_nodes,
16560        max_items,
16561        max_bytes,
16562    );
16563    let outline_duration_micros = outline_started.elapsed().as_micros();
16564    let projection_preview = MarkdownAstProjectionPreview {
16565        mode: if node.is_some() {
16566            "selected_node".to_string()
16567        } else {
16568            "outline_first".to_string()
16569        },
16570        total_nodes: raw_nodes.len(),
16571        returned_nodes: nodes.len(),
16572        omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
16573        selected_node: node.map(str::to_string),
16574        cache: markdown_ast_cache_report(&projection),
16575        outline,
16576        phase_timings: vec![
16577            MarkdownAstPhaseTiming {
16578                name: "parse_extract".to_string(),
16579                duration_micros: projection.parse_duration_micros,
16580                detail: if projection.cache_hit {
16581                    "reused cached tree-sitter Markdown symbol extraction".to_string()
16582                } else {
16583                    "tree-sitter Markdown symbol extraction".to_string()
16584                },
16585            },
16586            MarkdownAstPhaseTiming {
16587                name: "outline_projection".to_string(),
16588                duration_micros: outline_duration_micros,
16589                detail: "outline-first section/block preview construction".to_string(),
16590            },
16591        ],
16592    };
16593    let report = MarkdownAstReport {
16594        handle: stable_handle("mdastrep", &file_display),
16595        root: root.to_string_lossy().to_string(),
16596        file: file_display.clone(),
16597        range: SourceRangePreview {
16598            start: 1,
16599            end: total_lines,
16600            total_lines,
16601            truncated_before: false,
16602            truncated_after: false,
16603        },
16604        projection: projection_preview,
16605        nodes,
16606        expand: MarkdownAstExpandCommands {
16607            file: markdown_ast_command(&root, &file_display, None),
16608            source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
16609            edit_intents: markdown_edit_intents_command(&root),
16610        },
16611        warnings: Vec::new(),
16612    };
16613
16614    if format.json_output {
16615        let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
16616        let mut follow_up = vec![
16617            report.expand.file.clone(),
16618            report.expand.source_read.clone(),
16619            report.expand.edit_intents.clone(),
16620        ];
16621        follow_up.extend(
16622            report
16623                .nodes
16624                .iter()
16625                .map(|node| node.expand.source_window.clone()),
16626        );
16627        print_json_or_envelope(
16628            &report,
16629            &format,
16630            "markdown-ast",
16631            "ast",
16632            ToolEnvelopeSummary {
16633                text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
16634                metrics: vec![
16635                    envelope_metric("nodes", report.nodes.len()),
16636                    envelope_metric("total_nodes", report.projection.total_nodes),
16637                    envelope_metric(
16638                        "parse_duration_micros",
16639                        report.projection.cache.parse_duration_micros,
16640                    ),
16641                    envelope_metric("total_lines", report.range.total_lines),
16642                ],
16643            },
16644            truncated,
16645            follow_up,
16646        )?;
16647    } else if format.compact {
16648        println!(
16649            "markdown-ast {} nodes:{} handle:{}",
16650            report.file,
16651            report.nodes.len(),
16652            report.handle
16653        );
16654        for node in &report.nodes {
16655            println!(
16656                "  {} {} {}:{}-{}",
16657                node.handle, node.kind, node.name, node.line, node.end_line
16658            );
16659        }
16660        if node.is_none() && raw_nodes.len() > report.nodes.len() {
16661            println!("expand: {}", report.expand.file);
16662        }
16663    } else {
16664        println!(
16665            "Markdown AST `{}` nodes {} of {} ({})",
16666            report.file,
16667            report.nodes.len(),
16668            raw_nodes.len(),
16669            report.handle
16670        );
16671        for node in &report.nodes {
16672            println!(
16673                "  {} `{}` {}:{}-{} — {}",
16674                node.handle,
16675                node.name,
16676                node.kind,
16677                node.line,
16678                node.end_line,
16679                node.expand.source_window
16680            );
16681        }
16682        if node.is_none() && raw_nodes.len() > report.nodes.len() {
16683            println!();
16684            println!("Expand:");
16685            println!("  file: {}", report.expand.file);
16686        }
16687    }
16688
16689    Ok(())
16690}
16691
16692#[allow(clippy::too_many_arguments)]
16693fn cmd_source_read(
16694    file: &Path,
16695    path: &Path,
16696    style: SourceReadStyle,
16697    start: usize,
16698    lines: usize,
16699    end: Option<usize>,
16700    scope: Option<&str>,
16701    format: OutputFormat,
16702    absolute: bool,
16703    budget: ResponseBudget,
16704) -> Result<()> {
16705    if start == 0 {
16706        bail!("--start is 1-based and must be greater than zero");
16707    }
16708    if lines == 0 {
16709        bail!("--lines must be greater than zero");
16710    }
16711    if let Some(end) = end
16712        && end < start
16713    {
16714        bail!("--end must be greater than or equal to --start");
16715    }
16716
16717    let root = lint::resolve_project_root_or_canonical_path(path)?;
16718    let file_abs = resolve_source_file(&root, file)?;
16719    let file_display = if absolute {
16720        file_abs.to_string_lossy().to_string()
16721    } else {
16722        relativize_pathbuf(&file_abs, &root)
16723            .to_string_lossy()
16724            .to_string()
16725    };
16726
16727    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16728    let text = String::from_utf8_lossy(&source);
16729    let all_lines: Vec<&str> = text.lines().collect();
16730    let total_lines = all_lines.len();
16731    if total_lines > 0 && start > total_lines {
16732        bail!(
16733            "--start {} is beyond end of {} ({} lines)",
16734            start,
16735            file_display,
16736            total_lines
16737        );
16738    }
16739    let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
16740    let end_line = requested_end.min(total_lines);
16741    let mut warnings = Vec::new();
16742    let max_items = budget.preview_items();
16743    let max_bytes = budget.preview_bytes();
16744    if style == SourceReadStyle::Ast {
16745        let symbols = load_source_symbols(
16746            &root,
16747            &file_abs,
16748            &file_display,
16749            &source,
16750            scope,
16751            start,
16752            end_line,
16753            max_items,
16754            max_bytes,
16755            &mut warnings,
16756        );
16757        let summaries =
16758            load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
16759        let markdown = if is_markdown_path(&file_abs) {
16760            match source_read_markdown_projection(
16761                &root,
16762                &file_display,
16763                &source,
16764                start,
16765                end_line,
16766                budget,
16767            ) {
16768                Ok(markdown) => Some(markdown),
16769                Err(err) => {
16770                    warnings.push(format!("markdown projection unavailable: {err:#}"));
16771                    None
16772                }
16773            }
16774        } else {
16775            None
16776        };
16777        let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
16778        let report = SourceReadAstReport {
16779            handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
16780            root: root.to_string_lossy().to_string(),
16781            file: file_display.clone(),
16782            range: SourceRangePreview {
16783                start,
16784                end: end_line,
16785                total_lines,
16786                truncated_before: start > 1,
16787                truncated_after: end_line < total_lines,
16788            },
16789            symbols,
16790            summaries,
16791            markdown,
16792            expand: SourceReadAstExpandCommands {
16793                window: source_read_window_command(&root, &file_display, start, window_lines),
16794                file_window: source_read_window_command(
16795                    &root,
16796                    &file_display,
16797                    1,
16798                    total_lines.max(window_lines),
16799                ),
16800                markdown_ast: is_markdown_path(&file_abs)
16801                    .then(|| markdown_ast_command(&root, &file_display, None)),
16802            },
16803            warnings,
16804        };
16805
16806        if format.json_output {
16807            let truncated = report.range.truncated_before
16808                || report.range.truncated_after
16809                || report.symbols.len() >= max_items
16810                || report.summaries.len() >= max_items;
16811            let follow_up = [
16812                Some(report.expand.window.clone()),
16813                Some(report.expand.file_window.clone()),
16814                report.expand.markdown_ast.clone(),
16815            ]
16816            .into_iter()
16817            .flatten()
16818            .collect::<Vec<_>>();
16819            print_json_or_envelope(
16820                &report,
16821                &format,
16822                "source-read",
16823                "ast",
16824                ToolEnvelopeSummary {
16825                    text: format!(
16826                        "source ast {}:{}-{}",
16827                        report.file, report.range.start, report.range.end
16828                    ),
16829                    metrics: vec![
16830                        envelope_metric("symbols", report.symbols.len()),
16831                        envelope_metric("summaries", report.summaries.len()),
16832                        envelope_metric(
16833                            "markdown_nodes",
16834                            report
16835                                .markdown
16836                                .as_ref()
16837                                .map_or(0, |markdown| markdown.visible_nodes),
16838                        ),
16839                    ],
16840                },
16841                truncated,
16842                follow_up,
16843            )?;
16844        } else if format.compact {
16845            println!(
16846                "source-ast {}:{}-{} / {} handle:{}",
16847                report.file,
16848                report.range.start,
16849                report.range.end,
16850                report.range.total_lines,
16851                report.handle
16852            );
16853            for symbol in &report.symbols {
16854                println!(
16855                    "  {} {}:{} {}",
16856                    symbol.name, symbol.file, symbol.line, symbol.expand
16857                );
16858            }
16859            if !report.summaries.is_empty() {
16860                println!("summaries[{}]", report.summaries.len());
16861            }
16862            for warning in &report.warnings {
16863                eprintln!("warning: {warning}");
16864            }
16865        } else {
16866            println!(
16867                "Source AST `{}` lines {}-{} of {} ({})",
16868                report.file,
16869                report.range.start,
16870                report.range.end,
16871                report.range.total_lines,
16872                report.handle
16873            );
16874            if !report.symbols.is_empty() {
16875                println!();
16876                println!("Symbol refs:");
16877                for symbol in &report.symbols {
16878                    println!(
16879                        "  {} `{}` {}:{} — {}",
16880                        symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
16881                    );
16882                }
16883            }
16884            if !report.summaries.is_empty() {
16885                println!();
16886                println!("Summary refs:");
16887                for summary in &report.summaries {
16888                    println!(
16889                        "  {} `{}` — {}",
16890                        summary.handle, summary.symbol_name, summary.expand
16891                    );
16892                }
16893            }
16894            println!();
16895            println!("Expand:");
16896            println!("  window:      {}", report.expand.window);
16897            println!("  file window: {}", report.expand.file_window);
16898            if let Some(markdown_ast) = &report.expand.markdown_ast {
16899                println!("  markdown:    {}", markdown_ast);
16900            }
16901            for warning in &report.warnings {
16902                eprintln!("warning: {warning}");
16903            }
16904        }
16905
16906        return Ok(());
16907    }
16908    let max_bytes = budget.preview_bytes();
16909    let token_cap = budget.body_token_cap();
16910    let (preview, preview_end, body_truncated) = if total_lines == 0 {
16911        (Vec::new(), end_line, false)
16912    } else {
16913        let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
16914        (capped.preview, capped.capped_end, capped.was_capped)
16915    };
16916    let effective_end = if body_truncated {
16917        preview_end
16918    } else {
16919        end_line
16920    };
16921
16922    if body_truncated {
16923        warnings.push(format!(
16924            "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
16925        ));
16926    }
16927    let symbols = load_source_symbols(
16928        &root,
16929        &file_abs,
16930        &file_display,
16931        &source,
16932        scope,
16933        start,
16934        effective_end,
16935        max_items,
16936        max_bytes,
16937        &mut warnings,
16938    );
16939    let summaries =
16940        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
16941    let markdown = if is_markdown_path(&file_abs) {
16942        match source_read_markdown_projection(
16943            &root,
16944            &file_display,
16945            &source,
16946            start,
16947            effective_end,
16948            budget,
16949        ) {
16950            Ok(markdown) => Some(markdown),
16951            Err(err) => {
16952                warnings.push(format!("markdown projection unavailable: {err:#}"));
16953                None
16954            }
16955        }
16956    } else {
16957        None
16958    };
16959
16960    let expand = SourceExpandCommands {
16961        before: (start > 1).then(|| {
16962            let before_start = start.saturating_sub(lines).max(1);
16963            source_read_window_command(&root, &file_display, before_start, start - before_start)
16964        }),
16965        after: (effective_end < total_lines)
16966            .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
16967        body: body_truncated.then(|| {
16968            let remaining = end_line.saturating_sub(effective_end);
16969            source_read_window_command(&root, &file_display, effective_end + 1, remaining)
16970        }),
16971        file: source_read_ast_command(&root, &file_display),
16972        markdown_ast: is_markdown_path(&file_abs)
16973            .then(|| markdown_ast_command(&root, &file_display, None)),
16974    };
16975
16976    let report = SourceReadReport {
16977        handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
16978        root: root.to_string_lossy().to_string(),
16979        file: file_display,
16980        range: SourceRangePreview {
16981            start,
16982            end: effective_end,
16983            total_lines,
16984            truncated_before: start > 1,
16985            truncated_after: effective_end < total_lines,
16986        },
16987        preview,
16988        symbols,
16989        summaries,
16990        markdown,
16991        expand,
16992        warnings,
16993    };
16994
16995    if format.json_output {
16996        let truncated = report.range.truncated_before || report.range.truncated_after;
16997        let follow_up = [
16998            report.expand.before.clone(),
16999            report.expand.after.clone(),
17000            report.expand.body.clone(),
17001            Some(report.expand.file.clone()),
17002            report.expand.markdown_ast.clone(),
17003        ]
17004        .into_iter()
17005        .flatten()
17006        .collect::<Vec<_>>();
17007        print_json_or_envelope(
17008            &report,
17009            &format,
17010            "source-read",
17011            "window",
17012            ToolEnvelopeSummary {
17013                text: format!(
17014                    "source window {}:{}-{}",
17015                    report.file, report.range.start, report.range.end
17016                ),
17017                metrics: vec![
17018                    envelope_metric("lines", report.preview.len()),
17019                    envelope_metric("symbols", report.symbols.len()),
17020                    envelope_metric("summaries", report.summaries.len()),
17021                    envelope_metric(
17022                        "markdown_nodes",
17023                        report
17024                            .markdown
17025                            .as_ref()
17026                            .map_or(0, |markdown| markdown.visible_nodes),
17027                    ),
17028                ],
17029            },
17030            truncated,
17031            follow_up,
17032        )?;
17033    } else if format.compact {
17034        println!(
17035            "source {}:{}-{} / {} handle:{}",
17036            report.file,
17037            report.range.start,
17038            report.range.end,
17039            report.range.total_lines,
17040            report.handle
17041        );
17042        for line in &report.preview {
17043            println!("{:>5} {}", line.line, line.text);
17044        }
17045        if !report.symbols.is_empty() {
17046            println!("syms[{}]:", report.symbols.len());
17047            for symbol in &report.symbols {
17048                println!("  {} {}:{}", symbol.name, symbol.file, symbol.line);
17049            }
17050        }
17051        if report.range.truncated_before || report.range.truncated_after {
17052            println!("expand: {}", report.expand.file);
17053        }
17054    } else {
17055        println!(
17056            "Source window `{}` lines {}-{} of {} ({})",
17057            report.file,
17058            report.range.start,
17059            report.range.end,
17060            report.range.total_lines,
17061            report.handle
17062        );
17063        for line in &report.preview {
17064            println!("{:>5} | {}", line.line, line.text);
17065        }
17066        if !report.symbols.is_empty() {
17067            println!();
17068            println!("Symbol refs:");
17069            for symbol in &report.symbols {
17070                println!(
17071                    "  {} `{}` {}:{} — {}",
17072                    symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17073                );
17074            }
17075        }
17076        if !report.summaries.is_empty() {
17077            println!();
17078            println!("Summary refs:");
17079            for summary in &report.summaries {
17080                println!(
17081                    "  {} `{}` — {}",
17082                    summary.handle, summary.symbol_name, summary.expand
17083                );
17084            }
17085        }
17086        if report.range.truncated_before || report.range.truncated_after {
17087            println!();
17088            println!("Expand:");
17089            if let Some(before) = &report.expand.before {
17090                println!("  before: {}", before);
17091            }
17092            if let Some(after) = &report.expand.after {
17093                println!("  after: {}", after);
17094            }
17095            println!("  file:   {}", report.expand.file);
17096        }
17097        for warning in &report.warnings {
17098            eprintln!("warning: {warning}");
17099        }
17100    }
17101
17102    Ok(())
17103}
17104
17105#[allow(clippy::too_many_arguments)]
17106fn cmd_symbol_read(
17107    symbol: &str,
17108    file_hint: Option<&Path>,
17109    path: &Path,
17110    scope: Option<&str>,
17111    format: OutputFormat,
17112    absolute: bool,
17113    budget: ResponseBudget,
17114) -> Result<()> {
17115    let root = lint::resolve_project_root_or_canonical_path(path)?;
17116    let hinted_file_abs = file_hint
17117        .map(|file| resolve_source_file(&root, file))
17118        .transpose()?;
17119    let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17120    let db_path = resolve_query_db_path(&root, path_hint, scope)?;
17121    if !db_path.exists() {
17122        bail!(
17123            "index refs unavailable: no index found at {}",
17124            db_path.display()
17125        );
17126    }
17127    let db = index::IndexDb::open_read_only_resilient(&db_path)
17128        .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17129    let search_limit = budget.follow_up_items().max(10);
17130    let hits = db
17131        .symbol_search(symbol, search_limit)
17132        .with_context(|| format!("searching symbols for {symbol:?}"))?;
17133    let selected = hits
17134        .into_iter()
17135        .find(|hit| {
17136            let Some(hinted_file_abs) = &hinted_file_abs else {
17137                return true;
17138            };
17139            resolve_source_file(&root, Path::new(&hit.file))
17140                .map(|hit_file| hit_file == *hinted_file_abs)
17141                .unwrap_or(false)
17142        })
17143        .with_context(|| {
17144            let hint = file_hint
17145                .map(|file| format!(" in {}", file.display()))
17146                .unwrap_or_default();
17147            format!("no indexed symbol matched {symbol:?}{hint}")
17148        })?;
17149
17150    let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17151    let file_display = if absolute {
17152        file_abs.to_string_lossy().to_string()
17153    } else {
17154        relativize_pathbuf(&file_abs, &root)
17155            .to_string_lossy()
17156            .to_string()
17157    };
17158    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17159    let content_hash = blake3::hash(&source).to_hex().to_string();
17160    let text = String::from_utf8_lossy(&source);
17161    let all_lines: Vec<&str> = text.lines().collect();
17162    let total_lines = all_lines.len();
17163    let file_symbols = db
17164        .symbols_for_file(&file_abs.to_string_lossy())
17165        .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17166    let max_items = budget.preview_items();
17167    let max_bytes = budget.preview_bytes();
17168    let selected_start = symbol_hit_line(&selected);
17169    let selected_end = symbol_hit_end_line(&selected)
17170        .unwrap_or(selected_start)
17171        .max(selected_start);
17172    let stored_target = file_symbols.iter().find(|candidate| {
17173        candidate.name == selected.name
17174            && candidate.kind == selected.kind
17175            && source_symbol_line(candidate) == selected_start
17176    });
17177    let target_span = stored_target
17178        .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17179        .or_else(|| symbol_hit_ast_span(&selected, &source));
17180    let target_start = target_span
17181        .as_ref()
17182        .map(|span| span.start_line)
17183        .unwrap_or(selected_start);
17184    let target_end = target_span
17185        .as_ref()
17186        .map(|span| span.end_line)
17187        .or_else(|| stored_target.and_then(source_symbol_end_line))
17188        .unwrap_or(selected_end)
17189        .max(target_start);
17190    let target_bounds = stored_target
17191        .and_then(stored_symbol_span_bounds)
17192        .or_else(|| symbol_hit_span_bounds(&selected));
17193    let target_end = stored_target
17194        .and_then(source_symbol_end_line)
17195        .unwrap_or(target_end)
17196        .max(target_start);
17197    let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17198    let line_capped_end = target_start
17199        .saturating_add(body_line_budget)
17200        .saturating_sub(1)
17201        .min(target_end)
17202        .min(total_lines.max(target_start));
17203    let token_cap = budget.body_token_cap();
17204    let (body, effective_preview_end, body_truncated) =
17205        if total_lines == 0 || target_start > total_lines {
17206            (Vec::new(), line_capped_end, false)
17207        } else {
17208            let capped = build_token_capped_preview(
17209                &all_lines,
17210                target_start,
17211                line_capped_end,
17212                max_bytes,
17213                token_cap,
17214            );
17215            (capped.preview, capped.capped_end, capped.was_capped)
17216        };
17217    let preview_end = if body_truncated {
17218        effective_preview_end
17219    } else {
17220        line_capped_end
17221    };
17222    let child_symbols = file_symbols
17223        .iter()
17224        .filter(|candidate| {
17225            if let Some((target_start_byte, target_end_byte)) = target_bounds {
17226                let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17227                else {
17228                    return false;
17229                };
17230                return candidate_start >= target_start_byte
17231                    && candidate_end <= target_end_byte
17232                    && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17233            }
17234            let line = source_symbol_line(candidate);
17235            line > target_start && line <= target_end
17236        })
17237        .take(max_items)
17238        .map(|symbol| {
17239            let line = source_symbol_line(symbol);
17240            let end_line = source_symbol_end_line(symbol);
17241            SourceSymbolRef {
17242                handle: stable_handle(
17243                    "ssym",
17244                    &format!("{}:{}:{}", file_display, symbol.name, line),
17245                ),
17246                name: truncate_for_budget(&symbol.name, max_bytes),
17247                kind: symbol.kind.clone(),
17248                language: symbol.language.clone(),
17249                file: file_display.clone(),
17250                line,
17251                end_line,
17252                signature: symbol
17253                    .signature
17254                    .clone()
17255                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
17256                span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
17257                expand: source_symbol_read_command(&root, &symbol.name, &file_display),
17258            }
17259        })
17260        .collect::<Vec<_>>();
17261    let mut warnings = Vec::new();
17262    if body_truncated {
17263        warnings.push(format!(
17264            "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
17265        ));
17266    }
17267    let summaries =
17268        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17269    let symbol_handle = stable_handle(
17270        "sread",
17271        &format!("{}:{}:{}", file_display, selected.name, target_start),
17272    );
17273    let source_lines = preview_end
17274        .saturating_sub(target_start)
17275        .saturating_add(1)
17276        .max(1);
17277    let expand = SymbolReadExpandCommands {
17278        source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
17279        body: body_truncated.then(|| {
17280            let remaining = target_end.saturating_sub(preview_end);
17281            source_read_window_command(&root, &file_display, preview_end + 1, remaining)
17282        }),
17283        file: source_read_ast_command(&root, &file_display),
17284        explain: source_symbol_expand_command(&root, &selected.name),
17285        callers: source_symbol_graph_command(&root, &selected.name, "callers"),
17286        callees: source_symbol_graph_command(&root, &selected.name, "callees"),
17287        markdown_ast: (selected.language == "markdown").then(|| {
17288            markdown_ast_command(
17289                &root,
17290                &file_display,
17291                target_span.as_ref().map(|span| span.handle.as_str()),
17292            )
17293        }),
17294    };
17295    let report = SymbolReadReport {
17296        handle: symbol_handle.clone(),
17297        root: root.to_string_lossy().to_string(),
17298        query: symbol.to_string(),
17299        symbol: SymbolReadTarget {
17300            handle: symbol_handle,
17301            name: selected.name.clone(),
17302            kind: selected.kind.clone(),
17303            language: selected.language.clone(),
17304            file: file_display.clone(),
17305            line: target_start,
17306            end_line: Some(target_end),
17307            signature: stored_target
17308                .and_then(|stored| stored.signature.clone())
17309                .map(|signature| truncate_for_budget(&signature, max_bytes)),
17310            parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
17311            visibility: stored_target.and_then(|stored| stored.visibility.clone()),
17312            span: target_span,
17313        },
17314        range: SourceRangePreview {
17315            start: target_start,
17316            end: preview_end,
17317            total_lines,
17318            truncated_before: false,
17319            truncated_after: preview_end < target_end,
17320        },
17321        body,
17322        child_symbols,
17323        summaries,
17324        expand,
17325        warnings,
17326    };
17327
17328    if format.json_output {
17329        let truncated = report.range.truncated_after
17330            || report.body.iter().any(|line| line.text.len() >= max_bytes)
17331            || report.child_symbols.len() >= max_items;
17332        let follow_up = [
17333            Some(report.expand.source_window.clone()),
17334            report.expand.body.clone(),
17335            Some(report.expand.file.clone()),
17336            Some(report.expand.explain.clone()),
17337            Some(report.expand.callers.clone()),
17338            Some(report.expand.callees.clone()),
17339        ]
17340        .into_iter()
17341        .flatten()
17342        .chain(report.expand.markdown_ast.clone())
17343        .collect::<Vec<_>>();
17344        print_json_or_envelope(
17345            &report,
17346            &format,
17347            "symbol-read",
17348            "symbol",
17349            ToolEnvelopeSummary {
17350                text: format!(
17351                    "symbol {} {}:{}-{}",
17352                    report.symbol.name, report.symbol.file, report.range.start, report.range.end
17353                ),
17354                metrics: vec![
17355                    envelope_metric("body_lines", report.body.len()),
17356                    envelope_metric("child_symbols", report.child_symbols.len()),
17357                    envelope_metric("summaries", report.summaries.len()),
17358                ],
17359            },
17360            truncated,
17361            follow_up,
17362        )?;
17363    } else if format.compact {
17364        println!(
17365            "symbol {} {}:{}-{} handle:{} hash:{}",
17366            report.symbol.name,
17367            report.symbol.file,
17368            report.range.start,
17369            report.range.end,
17370            report.handle,
17371            content_hash
17372        );
17373        for line in &report.body {
17374            println!("{:>5} {}", line.line, line.text);
17375        }
17376        if !report.child_symbols.is_empty() {
17377            println!("children[{}]:", report.child_symbols.len());
17378            for child in &report.child_symbols {
17379                println!("  {} {}:{}", child.name, child.file, child.line);
17380            }
17381        }
17382    } else {
17383        println!(
17384            "Symbol `{}` in `{}` lines {}-{} ({})",
17385            report.symbol.name,
17386            report.symbol.file,
17387            report.range.start,
17388            report.range.end,
17389            report.handle
17390        );
17391        for line in &report.body {
17392            println!("{:>5} | {}", line.line, line.text);
17393        }
17394        if !report.child_symbols.is_empty() {
17395            println!();
17396            println!("Child symbols:");
17397            for child in &report.child_symbols {
17398                println!(
17399                    "  {} `{}` {}:{} — {}",
17400                    child.handle, child.name, child.file, child.line, child.expand
17401                );
17402            }
17403        }
17404        println!();
17405        println!("Expand:");
17406        println!("  source:  {}", report.expand.source_window);
17407        println!("  file:    {}", report.expand.file);
17408        println!("  explain: {}", report.expand.explain);
17409        println!("  callers: {}", report.expand.callers);
17410        println!("  callees: {}", report.expand.callees);
17411        for warning in &report.warnings {
17412            eprintln!("warning: {warning}");
17413        }
17414    }
17415
17416    Ok(())
17417}
17418
17419#[allow(clippy::too_many_arguments)]
17420#[derive(Serialize)]
17421struct ExplainBudgetDefinitionPreview {
17422    handle: String,
17423    #[serde(skip_serializing_if = "Option::is_none")]
17424    tag_alias: Option<String>,
17425    kind: String,
17426    name: String,
17427    file: String,
17428    line: i64,
17429    expand: String,
17430}
17431
17432#[derive(Serialize)]
17433struct ExplainBudgetEdgePreview {
17434    handle: String,
17435    #[serde(skip_serializing_if = "Option::is_none")]
17436    tag_alias: Option<String>,
17437    name: String,
17438    file: String,
17439    line: i64,
17440    expand: String,
17441}
17442
17443#[derive(Serialize)]
17444struct ExplainBudgetCommunityPreview {
17445    size: usize,
17446    members: Vec<String>,
17447}
17448
17449#[derive(Serialize)]
17450struct ExplainBudgetReport {
17451    symbol: String,
17452    max_items: usize,
17453    max_bytes: usize,
17454    definition_total: usize,
17455    callers_total: usize,
17456    callers_truncated_by_limit: bool,
17457    callees_total: usize,
17458    callees_truncated_by_limit: bool,
17459    truncated: bool,
17460    definitions: Vec<ExplainBudgetDefinitionPreview>,
17461    callers: Vec<ExplainBudgetEdgePreview>,
17462    callees: Vec<ExplainBudgetEdgePreview>,
17463    #[serde(skip_serializing_if = "Option::is_none")]
17464    community: Option<ExplainBudgetCommunityPreview>,
17465}
17466
17467#[allow(clippy::too_many_arguments)]
17468pub(crate) fn build_explain_budget_report(
17469    symbol: &str,
17470    _root: &Path,
17471    symbols: &[index::StoredSymbol],
17472    callers: &[index::StoredEdge],
17473    callers_total: usize,
17474    callers_truncated_by_limit: bool,
17475    callees: &[index::StoredEdge],
17476    callees_total: usize,
17477    callees_truncated_by_limit: bool,
17478    community: Option<&graph::Community>,
17479    budget: ResponseBudget,
17480) -> ExplainBudgetReport {
17481    let max_items = budget.preview_items();
17482    let max_bytes = budget.preview_bytes();
17483    let definitions = symbols
17484        .iter()
17485        .take(max_items)
17486        .map(|entry| {
17487            let symbol_ref = build_compact_symbol_ref(
17488                "edef",
17489                &format!(
17490                    "{}:{}:{}:{}",
17491                    entry.kind, entry.name, entry.file, entry.line
17492                ),
17493                &entry.name,
17494                entry.tags.as_deref(),
17495                max_bytes,
17496            );
17497            ExplainBudgetDefinitionPreview {
17498                handle: symbol_ref.handle,
17499                tag_alias: symbol_ref.tag_alias,
17500                kind: entry.kind.clone(),
17501                name: symbol_ref.name,
17502                file: truncate_for_budget(&entry.file, max_bytes),
17503                line: entry.line,
17504                expand: format!(
17505                    "tsift search {} --exact --path {} --limit 20",
17506                    shell_quote(&entry.name),
17507                    shell_quote(&entry.file)
17508                ),
17509            }
17510        })
17511        .collect();
17512    let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
17513        .iter()
17514        .take(max_items)
17515        .map(|entry| {
17516            let symbol_ref = build_compact_symbol_ref(
17517                "ecall",
17518                &format!(
17519                    "{}:{}:{}:{}",
17520                    entry.caller_name, entry.caller_file, entry.call_site_line, symbol
17521                ),
17522                &entry.caller_name,
17523                None,
17524                max_bytes,
17525            );
17526            ExplainBudgetEdgePreview {
17527                handle: symbol_ref.handle,
17528                tag_alias: symbol_ref.tag_alias,
17529                name: symbol_ref.name,
17530                file: truncate_for_budget(&entry.caller_file, max_bytes),
17531                line: entry.call_site_line,
17532                expand: format!(
17533                    "tsift explain {} --path {} --limit 0",
17534                    shell_quote(&entry.caller_name),
17535                    shell_quote(&entry.caller_file)
17536                ),
17537            }
17538        })
17539        .collect();
17540    let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
17541        .iter()
17542        .take(max_items)
17543        .map(|entry| {
17544            let symbol_ref = build_compact_symbol_ref(
17545                "eces",
17546                &format!(
17547                    "{}:{}:{}:{}",
17548                    entry.callee_name, entry.caller_file, entry.call_site_line, symbol
17549                ),
17550                &entry.callee_name,
17551                None,
17552                max_bytes,
17553            );
17554            ExplainBudgetEdgePreview {
17555                handle: symbol_ref.handle,
17556                tag_alias: symbol_ref.tag_alias,
17557                name: symbol_ref.name,
17558                file: truncate_for_budget(&entry.caller_file, max_bytes),
17559                line: entry.call_site_line,
17560                expand: format!(
17561                    "tsift explain {} --path {} --limit 0",
17562                    shell_quote(&entry.callee_name),
17563                    shell_quote(&entry.caller_file)
17564                ),
17565            }
17566        })
17567        .collect();
17568    let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
17569        size: entry.members.len(),
17570        members: entry
17571            .members
17572            .iter()
17573            .take(max_items)
17574            .map(|member| truncate_for_budget(&member.name, max_bytes))
17575            .collect(),
17576    });
17577
17578    ExplainBudgetReport {
17579        symbol: symbol.to_string(),
17580        max_items,
17581        max_bytes,
17582        definition_total: symbols.len(),
17583        callers_total,
17584        callers_truncated_by_limit,
17585        callees_total,
17586        callees_truncated_by_limit,
17587        truncated: symbols.len() > max_items
17588            || callers_total > callers_preview.len()
17589            || callees_total > callees_preview.len()
17590            || community
17591                .map(|entry| entry.members.len() > max_items)
17592                .unwrap_or(false),
17593        definitions,
17594        callers: callers_preview,
17595        callees: callees_preview,
17596        community: community_preview,
17597    }
17598}
17599
17600pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
17601    println!(
17602        "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
17603        shell_quote(&report.symbol),
17604        report.definitions.len(),
17605        report.definition_total,
17606        report.callers.len(),
17607        report.callers_total,
17608        report.callees.len(),
17609        report.callees_total
17610    );
17611    for entry in &report.definitions {
17612        println!(
17613            "def {} {} {}:{} expand:{}",
17614            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17615            entry.kind,
17616            entry.file,
17617            entry.line,
17618            entry.expand
17619        );
17620    }
17621    for entry in &report.callers {
17622        println!(
17623            "caller {} {}:{} expand:{}",
17624            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17625            entry.file,
17626            entry.line,
17627            entry.expand
17628        );
17629    }
17630    for entry in &report.callees {
17631        println!(
17632            "callee {} {}:{} expand:{}",
17633            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17634            entry.file,
17635            entry.line,
17636            entry.expand
17637        );
17638    }
17639    if let Some(community) = &report.community {
17640        println!(
17641            "community size:{} members:{}",
17642            community.size,
17643            community.members.join(", ")
17644        );
17645    }
17646    if report.truncated {
17647        println!(
17648            "budget truncated items:{} bytes:{}",
17649            report.max_items, report.max_bytes
17650        );
17651    }
17652}
17653
17654/// Reconcile the tsift symbol index against the tagpath `.naming/index.json`
17655/// source set and report files covered by one but not the other.
17656///
17657/// Today silent recall loss happens when tagpath's `[exclude]` / `extends`
17658/// chain or its hard-coded `SKIP_DIRS` skip files or languages that tsift
17659/// still indexes — the tsift symbols in those files cannot resolve a
17660/// `tagpath_handle` even with a fresh tagpath index. This audit surfaces
17661/// the diff so operators can decide whether to broaden the tagpath walk,
17662/// add an `[exclude]` to tsift, or accept the gap.
17663const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
17664    ".git",
17665    "node_modules",
17666    "target",
17667    "__pycache__",
17668    ".venv",
17669    "vendor",
17670];
17671
17672const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
17673    "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
17674    "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
17675    "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
17676    "sh", "bash", "zsh", "sql", "css", "tsx",
17677];
17678
17679pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
17680    let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
17681        .iter()
17682        .map(|ext| (*ext).to_string())
17683        .collect::<BTreeSet<_>>();
17684
17685    let config_path = root.join(".naming.toml");
17686    if !config_path.exists() {
17687        return extensions;
17688    }
17689
17690    match tagpath::config::resolve(&config_path) {
17691        Ok(config) => {
17692            if let Some(grammars) = config.grammars {
17693                for grammar in grammars.languages.values() {
17694                    for ext in &grammar.extensions {
17695                        if let Some(normalized) = normalize_extension(ext) {
17696                            extensions.insert(normalized);
17697                        }
17698                    }
17699                }
17700            }
17701        }
17702        Err(err) => {
17703            eprintln!("tagpath_policy_hint_config_unreadable: {err}");
17704        }
17705    }
17706    extensions
17707}
17708
17709pub(crate) fn tagpath_audit_policy_hints(
17710    rel_path: &str,
17711    supported_extensions: &BTreeSet<String>,
17712) -> Vec<String> {
17713    let path = Path::new(rel_path);
17714    let mut hints = BTreeSet::new();
17715    if let Some(parent) = path.parent() {
17716        for component in parent.components() {
17717            if let std::path::Component::Normal(name) = component {
17718                let name = name.to_string_lossy();
17719                if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
17720                    hints.insert(format!("skip_dir:{name}"));
17721                }
17722            }
17723        }
17724    }
17725    if path
17726        .extension()
17727        .and_then(|ext| ext.to_str())
17728        .and_then(normalize_extension)
17729        .is_some_and(|ext| !supported_extensions.contains(&ext))
17730    {
17731        hints.insert("extension_unsupported".to_string());
17732    }
17733    hints.into_iter().collect()
17734}
17735
17736fn normalize_extension(ext: &str) -> Option<String> {
17737    let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
17738    if normalized.is_empty() {
17739        None
17740    } else {
17741        Some(normalized)
17742    }
17743}
17744
17745pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
17746    match status {
17747        diff_digest::DiffDigestFileStatus::Added => "added",
17748        diff_digest::DiffDigestFileStatus::Modified => "modified",
17749        diff_digest::DiffDigestFileStatus::Deleted => "deleted",
17750    }
17751}
17752
17753pub(crate) fn diff_digest_summary_label(
17754    state: diff_digest::DiffDigestSummaryState,
17755) -> &'static str {
17756    match state {
17757        diff_digest::DiffDigestSummaryState::Current => "current",
17758        diff_digest::DiffDigestSummaryState::Stale => "stale",
17759        diff_digest::DiffDigestSummaryState::Missing => "missing",
17760        diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
17761    }
17762}
17763
17764fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
17765    match state {
17766        test_digest::TestDigestSummaryState::Current => "current",
17767        test_digest::TestDigestSummaryState::Stale => "stale",
17768        test_digest::TestDigestSummaryState::Missing => "missing",
17769        test_digest::TestDigestSummaryState::Unavailable => "unavailable",
17770    }
17771}
17772
17773fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
17774    match state {
17775        log_digest::LogDigestSummaryState::Current => "current",
17776        log_digest::LogDigestSummaryState::Stale => "stale",
17777        log_digest::LogDigestSummaryState::Missing => "missing",
17778        log_digest::LogDigestSummaryState::Unavailable => "unavailable",
17779    }
17780}
17781
17782pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
17783    match mode {
17784        diff_digest::DiffDigestMode::WorkingTree => "worktree",
17785        diff_digest::DiffDigestMode::Cached => "cached",
17786        diff_digest::DiffDigestMode::Revision => "revision",
17787    }
17788}
17789
17790pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
17791    match (&report.mode, &report.revision) {
17792        (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
17793        (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
17794        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17795            format!("revision {revision}")
17796        }
17797        (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
17798    }
17799}
17800
17801pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
17802    match (&report.mode, &report.revision) {
17803        (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
17804        (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
17805        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17806            format!("No diff found for revision {revision}.")
17807        }
17808        (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
17809    }
17810}
17811
17812fn cmd_impact(
17813    path: &Path,
17814    cached: bool,
17815    revision: Option<&str>,
17816    scope: Option<&str>,
17817    limit: usize,
17818    format: OutputFormat,
17819) -> Result<()> {
17820    let report = impact::compute(
17821        path,
17822        impact::ImpactOptions {
17823            cached,
17824            revision,
17825            scope,
17826            limit,
17827        },
17828    )?;
17829    if format.json_output {
17830        println!(
17831            "{}",
17832            to_json_schema(
17833                &report,
17834                format.pretty,
17835                format.terse,
17836                format.ultra_terse,
17837                format.schema
17838            )?
17839        );
17840        return Ok(());
17841    }
17842
17843    if format.compact {
17844        println!(
17845            "impact mode:{} changed:{} symbols:{} tests:{}/{}",
17846            diff_digest_mode_label(report.mode),
17847            report.changed_files.len(),
17848            report.changed_symbols.len(),
17849            report.affected_tests.len(),
17850            report.affected_tests_total
17851        );
17852        for target in &report.affected_tests {
17853            println!(
17854                "{} reasons:{} command:{}",
17855                target.path,
17856                target.reasons.len(),
17857                target.commands.join(" && ")
17858            );
17859        }
17860        for warning in &report.warnings {
17861            println!("warning {warning}");
17862        }
17863        return Ok(());
17864    }
17865
17866    println!("Impact ({})", diff_digest_mode_label(report.mode));
17867    println!("  changed files:          {}", report.changed_files.len());
17868    println!("  changed symbols:        {}", report.changed_symbols.len());
17869    println!(
17870        "  affected tests:         {}/{}",
17871        report.affected_tests.len(),
17872        report.affected_tests_total
17873    );
17874    for target in &report.affected_tests {
17875        println!();
17876        println!("{}", target.path);
17877        for reason in &target.reasons {
17878            println!("  - {reason}");
17879        }
17880        if !target.symbols.is_empty() {
17881            println!("  symbols: {}", target.symbols.join(", "));
17882        }
17883        for command in &target.commands {
17884            println!("  run: {}", command);
17885        }
17886    }
17887    for warning in &report.warnings {
17888        println!("warning: {warning}");
17889    }
17890    Ok(())
17891}
17892
17893pub(crate) fn render_test_digest_from_input(
17894    path: &Path,
17895    input: &str,
17896    runner: Option<&str>,
17897    format: OutputFormat,
17898) -> Result<()> {
17899    let report = test_digest::compute(path, input, runner)?;
17900    if format.json_output {
17901        println!(
17902            "{}",
17903            to_json_schema(
17904                &report,
17905                format.pretty,
17906                format.terse,
17907                format.ultra_terse,
17908                format.schema
17909            )?
17910        );
17911        return Ok(());
17912    }
17913
17914    if report.failure_groups.is_empty() {
17915        println!("No failures detected (runner: {}).", report.runner);
17916        for warning in &report.warnings {
17917            println!("warning: {warning}");
17918        }
17919        return Ok(());
17920    }
17921
17922    if format.compact {
17923        println!(
17924            "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
17925            report.runner,
17926            report.failures,
17927            report.grouped_failures,
17928            report.counts.passed.unwrap_or(0),
17929            report.counts.failed.unwrap_or(report.grouped_failures),
17930            report.counts.skipped.unwrap_or(0),
17931        );
17932        for failure in &report.failure_groups {
17933            let tests = truncate_for_compact(&failure.tests.join(","), 60);
17934            let location = match (&failure.path, failure.line) {
17935                (Some(path), Some(line)) => format!("{path}:{line}"),
17936                (Some(path), None) => path.clone(),
17937                _ => "-".to_string(),
17938            };
17939            println!(
17940                "{} tests:{} count:{} summaries:{} msg:{}",
17941                location,
17942                tests,
17943                failure.occurrences,
17944                test_digest_summary_label(failure.summary_state),
17945                truncate_for_compact(&failure.message, 80)
17946            );
17947        }
17948        for warning in &report.warnings {
17949            println!("warning: {warning}");
17950        }
17951        return Ok(());
17952    }
17953
17954    println!("Test digest ({})", report.runner);
17955    println!("  failures:        {}", report.failures);
17956    println!("  failure groups:  {}", report.grouped_failures);
17957    if let Some(passed) = report.counts.passed {
17958        println!("  passed:          {}", passed);
17959    }
17960    if let Some(failed) = report.counts.failed {
17961        println!("  failed:          {}", failed);
17962    }
17963    if let Some(skipped) = report.counts.skipped {
17964        println!("  skipped:         {}", skipped);
17965    }
17966
17967    for failure in &report.failure_groups {
17968        println!();
17969        match (&failure.path, failure.line, failure.column) {
17970            (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
17971            (Some(path), Some(line), None) => println!("{path}:{line}"),
17972            (Some(path), None, _) => println!("{path}"),
17973            (None, _, _) => println!("(no file anchor)"),
17974        }
17975        println!("  tests: {}", failure.tests.join(", "));
17976        println!("  occurrences: {}", failure.occurrences);
17977        println!("  message: {}", failure.message);
17978        println!(
17979            "  cached summaries: {}",
17980            test_digest_summary_label(failure.summary_state)
17981        );
17982        for summary in &failure.current_summaries {
17983            println!(
17984                "    - {}: {}",
17985                summary.symbol,
17986                truncate_for_compact(&summary.summary, 160)
17987            );
17988        }
17989    }
17990    for warning in &report.warnings {
17991        println!("warning: {warning}");
17992    }
17993    Ok(())
17994}
17995
17996#[derive(Clone, Serialize, Deserialize)]
17997struct DispatchTraceSummary {
17998    backlog: usize,
17999    job_packet: usize,
18000    worker_result: usize,
18001    worker_context: usize,
18002    source_handle: usize,
18003    semantic_rows: usize,
18004}
18005
18006#[derive(Clone, Serialize, Deserialize)]
18007struct DispatchTraceReport {
18008    contract_version: String,
18009    root: String,
18010    #[serde(skip_serializing_if = "Option::is_none")]
18011    scope: Option<String>,
18012    targets: Vec<String>,
18013    projection_freshness: GraphDbFreshnessReport,
18014    projection_hashes: Vec<String>,
18015    evidence_packet_ids: Vec<String>,
18016    shared_preparation: ConflictMatrixSharedPreparationSummary,
18017    worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18018    worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18019    summary: DispatchTraceSummary,
18020    nodes: Vec<SubstrateTerseGraphNode>,
18021    edges: Vec<SubstrateTerseGraphEdge>,
18022    conflict_matrix_decisions: Vec<String>,
18023    replay_commands: Vec<String>,
18024    repair_commands: Vec<String>,
18025    truncated: bool,
18026    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18027    warnings: Vec<String>,
18028}
18029
18030fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18031    matches!(
18032        kind,
18033        "session"
18034            | "backlog"
18035            | "job_packet"
18036            | "worker_result"
18037            | "worker_context"
18038            | "source_handle"
18039            | "semantic_concept"
18040            | "semantic_entity"
18041            | "file"
18042            | "symbol"
18043            | "route"
18044    )
18045}
18046
18047fn dispatch_trace_kind_rank(kind: &str) -> usize {
18048    match kind {
18049        "backlog" => 0,
18050        "job_packet" => 1,
18051        "worker_result" => 2,
18052        "worker_context" => 3,
18053        "source_handle" => 4,
18054        "file" => 5,
18055        "symbol" => 6,
18056        "route" => 7,
18057        "semantic_concept" => 8,
18058        "semantic_entity" => 9,
18059        "session" => 10,
18060        _ => 99,
18061    }
18062}
18063
18064fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18065    DispatchTraceSummary {
18066        backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18067        job_packet: nodes
18068            .iter()
18069            .filter(|node| node.kind == "job_packet")
18070            .count(),
18071        worker_result: nodes
18072            .iter()
18073            .filter(|node| node.kind == "worker_result")
18074            .count(),
18075        worker_context: nodes
18076            .iter()
18077            .filter(|node| node.kind == "worker_context")
18078            .count(),
18079        source_handle: nodes
18080            .iter()
18081            .filter(|node| node.kind == "source_handle")
18082            .count(),
18083        semantic_rows: nodes
18084            .iter()
18085            .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18086            .count(),
18087    }
18088}
18089
18090fn dispatch_trace_shared_preparation_summary(
18091    graph_nodes: &[SubstrateGraphNode],
18092    graph_edges: &[SubstrateGraphEdge],
18093    conflict: &ConflictMatrixReport,
18094) -> ConflictMatrixSharedPreparationSummary {
18095    ConflictMatrixSharedPreparationSummary {
18096        evidence_cache_status: conflict
18097            .inputs
18098            .shared_preparation
18099            .evidence_cache_status
18100            .clone(),
18101        graph_nodes: graph_nodes.len(),
18102        graph_edges: graph_edges.len(),
18103        evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18104        source_handles: conflict
18105            .candidates
18106            .iter()
18107            .map(|candidate| candidate.source_handles.len())
18108            .sum(),
18109        worker_context: conflict
18110            .candidates
18111            .iter()
18112            .map(|candidate| candidate.worker_context_handles.len())
18113            .sum(),
18114        worker_results: conflict
18115            .candidates
18116            .iter()
18117            .map(|candidate| candidate.worker_feedback.total)
18118            .sum(),
18119        semantic_rows: conflict
18120            .candidates
18121            .iter()
18122            .map(|candidate| candidate.semantic_related.len())
18123            .sum(),
18124        dispatch_trace_snapshot_nodes: graph_nodes.len(),
18125        dispatch_trace_snapshot_edges: graph_edges.len(),
18126    }
18127}
18128
18129fn dispatch_trace_collect_ids(
18130    targets: &[String],
18131    candidates: &[ConflictMatrixCandidate],
18132    graph_nodes: &[SubstrateGraphNode],
18133    graph_edges: &[SubstrateGraphEdge],
18134    depth: usize,
18135    limit: usize,
18136) -> (BTreeSet<String>, bool) {
18137    let target_refs = targets
18138        .iter()
18139        .map(|target| target.trim_start_matches('#').to_string())
18140        .collect::<BTreeSet<_>>();
18141    let mut ids = BTreeSet::new();
18142    for candidate in candidates {
18143        ids.insert(candidate.target_node_id.clone());
18144        for source in &candidate.source_handles {
18145            ids.insert(source.handle.clone());
18146        }
18147        for handle in &candidate.worker_context_handles {
18148            ids.insert(handle.clone());
18149        }
18150        for semantic in &candidate.semantic_related {
18151            ids.insert(semantic.handle.clone());
18152        }
18153    }
18154    for node in graph_nodes {
18155        if !dispatch_trace_allowed_node_kind(&node.kind) {
18156            continue;
18157        }
18158        if node
18159            .properties
18160            .get("ref_id")
18161            .is_some_and(|ref_id| target_refs.contains(ref_id))
18162        {
18163            ids.insert(node.id.clone());
18164        }
18165    }
18166
18167    let node_by_id = graph_nodes
18168        .iter()
18169        .map(|node| (node.id.as_str(), node))
18170        .collect::<BTreeMap<_, _>>();
18171    let max_nodes = if limit == 0 {
18172        usize::MAX
18173    } else {
18174        limit
18175            .saturating_mul(targets.len().max(1))
18176            .saturating_mul(12)
18177            .max(64)
18178    };
18179    let mut truncated = false;
18180    for _ in 0..depth.max(1) {
18181        let before = ids.len();
18182        let current_ids = ids.clone();
18183        for edge in graph_edges {
18184            if ids.len() >= max_nodes {
18185                truncated = true;
18186                break;
18187            }
18188            let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18189            if !touches {
18190                continue;
18191            }
18192            for endpoint in [&edge.from_id, &edge.to_id] {
18193                let Some(node) = node_by_id.get(endpoint.as_str()) else {
18194                    continue;
18195                };
18196                if dispatch_trace_allowed_node_kind(&node.kind) {
18197                    ids.insert(endpoint.clone());
18198                }
18199            }
18200        }
18201        if ids.len() == before || truncated {
18202            break;
18203        }
18204    }
18205    (ids, truncated)
18206}
18207
18208#[allow(clippy::too_many_arguments)]
18209fn build_dispatch_trace_report_from_conflict_snapshot(
18210    root: &Path,
18211    scope: Option<&str>,
18212    conflict: ConflictMatrixReport,
18213    graph_nodes: Vec<SubstrateGraphNode>,
18214    graph_edges: Vec<SubstrateGraphEdge>,
18215    depth: usize,
18216    limit: usize,
18217    extra_warnings: Vec<String>,
18218) -> Result<DispatchTraceReport> {
18219    let shared_preparation =
18220        dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18221    let (ids, truncated) = dispatch_trace_collect_ids(
18222        &conflict.targets,
18223        &conflict.candidates,
18224        &graph_nodes,
18225        &graph_edges,
18226        depth,
18227        limit,
18228    );
18229    let mut nodes = graph_nodes
18230        .into_iter()
18231        .filter(|node| ids.contains(&node.id))
18232        .collect::<Vec<_>>();
18233    nodes.sort_by(|left, right| {
18234        dispatch_trace_kind_rank(&left.kind)
18235            .cmp(&dispatch_trace_kind_rank(&right.kind))
18236            .then(left.id.cmp(&right.id))
18237    });
18238    let node_ids = nodes
18239        .iter()
18240        .map(|node| node.id.as_str())
18241        .collect::<BTreeSet<_>>();
18242    let mut edges = graph_edges
18243        .into_iter()
18244        .filter(|edge| {
18245            node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18246        })
18247        .collect::<Vec<_>>();
18248    edges.sort_by(|left, right| {
18249        left.from_id
18250            .cmp(&right.from_id)
18251            .then(left.kind.cmp(&right.kind))
18252            .then(left.to_id.cmp(&right.to_id))
18253    });
18254    let mut warnings = conflict.warnings;
18255    warnings.extend(extra_warnings);
18256
18257    Ok(DispatchTraceReport {
18258        contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
18259        root: conflict.root,
18260        scope: conflict.scope,
18261        targets: conflict.targets,
18262        projection_freshness: conflict.orchestration.projection_freshness,
18263        projection_hashes: conflict.orchestration.projection_hashes,
18264        evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
18265        shared_preparation,
18266        worker_prompt_packets: conflict.worker_prompt_packets,
18267        worker_feedback: conflict
18268            .candidates
18269            .iter()
18270            .map(|candidate| candidate.worker_feedback.clone())
18271            .collect(),
18272        summary: dispatch_trace_summary(&nodes),
18273        nodes: nodes.into_iter().map(Into::into).collect(),
18274        edges: edges.into_iter().map(Into::into).collect(),
18275        conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
18276        replay_commands: conflict.next_commands,
18277        repair_commands: graph_db_repair_commands(root, scope),
18278        truncated,
18279        warnings,
18280    })
18281}
18282
18283fn build_dispatch_trace_report(
18284    path: &Path,
18285    scope: Option<&str>,
18286    raw_targets: &[String],
18287    depth: usize,
18288    limit: usize,
18289    impact_limit: usize,
18290) -> Result<DispatchTraceReport> {
18291    let root = lint::resolve_project_root_or_canonical_path(path)?;
18292    let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
18293    if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
18294        write_traversal_graph_store(&root, path, scope)
18295            .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
18296    }
18297    let graph_db = graph_substrate_db_path(&root, scope);
18298    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
18299        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
18300    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
18301    let extra_warnings = store
18302        .read_only_recovery()
18303        .map(graph_db_read_recovery_diagnostic)
18304        .into_iter()
18305        .collect::<Vec<_>>();
18306    let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
18307    let graph_prepared = prepare_conflict_matrix_graph_orchestration(
18308        &root,
18309        scope,
18310        "sqlite",
18311        raw_targets,
18312        &prepared,
18313        depth,
18314        limit,
18315        &store,
18316        freshness.clone(),
18317    )?;
18318    let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
18319        &prepared.preparation_cache.source_watermark,
18320        &prepared.preparation_cache.document_watermark,
18321        &prepared.preparation_cache.staged_diff_watermark,
18322        &[
18323            &format!("targets:{}", raw_targets.join(",")),
18324            &format!("depth:{depth}"),
18325            &format!("limit:{limit}"),
18326        ],
18327    );
18328    if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
18329        &root,
18330        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18331        &dt_cache_key,
18332    ) {
18333        return Ok(cached_report);
18334    }
18335    let conflict = build_conflict_matrix_report_from_prepared_graph(
18336        &root,
18337        path,
18338        scope,
18339        depth,
18340        limit,
18341        impact_limit,
18342        freshness,
18343        extra_warnings.clone(),
18344        &prepared,
18345        &graph_prepared,
18346    )?;
18347    let report = build_dispatch_trace_report_from_conflict_snapshot(
18348        &root,
18349        scope,
18350        conflict,
18351        graph_prepared.graph.nodes,
18352        graph_prepared.graph.edges,
18353        depth,
18354        limit,
18355        extra_warnings,
18356    )?;
18357    cycle_packet_cache::cycle_packet_write_cache(
18358        &root,
18359        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18360        &dt_cache_key,
18361        &report,
18362    );
18363    Ok(report)
18364}
18365
18366fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
18367    let json = serde_json::to_string(report)?.replace("</", "<\\/");
18368    let mut html = String::new();
18369    html.push_str(
18370        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
18371    );
18372    html.push_str(
18373        r#"<style>
18374:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
18375@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
18376*{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}}
18377</style>"#,
18378    );
18379    html.push_str("</head><body><div class=\"page\">");
18380    html.push_str(&format!(
18381        "<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>",
18382        html_escape(&report.targets.join(", ")),
18383        report.evidence_packet_ids.len(),
18384        report.nodes.len(),
18385        report.worker_prompt_packets.len(),
18386        html_escape(&report.contract_version)
18387    ));
18388    html.push_str(
18389        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>"#,
18390    );
18391    html.push_str("<script id=\"trace-data\" type=\"application/json\">");
18392    html.push_str(&json);
18393    html.push_str(
18394        r##"</script><script>
18395const report = JSON.parse(document.getElementById("trace-data").textContent);
18396const svg = document.getElementById("graph-canvas");
18397const nodeList = document.getElementById("nodes");
18398const packets = document.getElementById("packets");
18399const feedback = document.getElementById("feedback");
18400const nodes = report.nodes.map((node, index) => ({...node, index}));
18401const nodeById = new Map(nodes.map(node => [node.id, node]));
18402const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
18403const 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"]]);
18404function color(kind){return colorByKind.get(kind)||"#6b7280";}
18405function text(value){return value == null ? "" : String(value);}
18406function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));}
18407function layout(){
18408  const rect = svg.getBoundingClientRect();
18409  const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
18410  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
18411  const counts = new Map();
18412  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
18413  const offsets = new Map();
18414  for (const node of nodes) {
18415    const group = kinds.indexOf(node.kind);
18416    const index = offsets.get(node.kind) || 0;
18417    offsets.set(node.kind, index + 1);
18418    const total = counts.get(node.kind) || 1;
18419    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
18420    const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
18421    node.x = cx + Math.cos(angle) * ring;
18422    node.y = cy + Math.sin(angle) * ring;
18423  }
18424}
18425function draw(){
18426  svg.innerHTML = "";
18427  for (const edge of edges) {
18428    const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
18429    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
18430    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
18431    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
18432    line.setAttribute("class", "edge");
18433    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
18434    svg.appendChild(line);
18435  }
18436  for (const node of nodes) {
18437    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
18438    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
18439    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
18440    circle.setAttribute("fill", color(node.kind));
18441    circle.setAttribute("class", "node");
18442    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
18443    svg.appendChild(circle);
18444    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
18445    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
18446    label.setAttribute("class", "node-label");
18447    label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
18448    svg.appendChild(label);
18449  }
18450}
18451packets.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>";
18452feedback.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>";
18453nodeList.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("");
18454window.addEventListener("resize", () => { layout(); draw(); });
18455layout(); draw();
18456</script></div></body></html>"##,
18457    );
18458    Ok(html)
18459}
18460
18461struct DispatchTraceOptions<'a> {
18462    path: &'a Path,
18463    scope: Option<&'a str>,
18464    raw_targets: &'a [String],
18465    depth: usize,
18466    limit: usize,
18467    impact_limit: usize,
18468    trace_format: DispatchTraceFormat,
18469}
18470
18471fn cmd_dispatch_trace(
18472    options: DispatchTraceOptions<'_>,
18473    output_format: OutputFormat,
18474) -> Result<()> {
18475    let report = build_dispatch_trace_report(
18476        options.path,
18477        options.scope,
18478        options.raw_targets,
18479        options.depth,
18480        options.limit,
18481        options.impact_limit,
18482    )?;
18483    match options.trace_format {
18484        DispatchTraceFormat::Json => {
18485            if output_format.envelope {
18486                print_json_or_envelope(
18487                    &report,
18488                    &output_format,
18489                    "dispatch-trace",
18490                    "operator-review",
18491                    ToolEnvelopeSummary {
18492                        text: format!(
18493                            "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
18494                            report.targets.len(),
18495                            report.nodes.len(),
18496                            report.worker_prompt_packets.len()
18497                        ),
18498                        metrics: vec![
18499                            envelope_metric("targets", report.targets.len()),
18500                            envelope_metric("nodes", report.nodes.len()),
18501                            envelope_metric("edges", report.edges.len()),
18502                            envelope_metric(
18503                                "worker_prompt_packets",
18504                                report.worker_prompt_packets.len(),
18505                            ),
18506                        ],
18507                    },
18508                    report.truncated,
18509                    report.replay_commands.clone(),
18510                )
18511            } else {
18512                println!(
18513                    "{}",
18514                    to_json_schema(
18515                        &report,
18516                        output_format.pretty,
18517                        output_format.terse,
18518                        output_format.ultra_terse,
18519                        output_format.schema
18520                    )?
18521                );
18522                Ok(())
18523            }
18524        }
18525        DispatchTraceFormat::Html => {
18526            println!("{}", dispatch_trace_html(&report)?);
18527            Ok(())
18528        }
18529    }
18530}
18531
18532#[derive(Clone, Debug)]
18533struct DependencyDagProfile {
18534    id: String,
18535    graph_node_id: String,
18536    label: String,
18537    path: Option<String>,
18538    line: Option<i64>,
18539    detail: Option<String>,
18540    source_files: BTreeSet<String>,
18541    source_symbols: BTreeSet<String>,
18542    config_files: BTreeSet<String>,
18543    expected_tests: BTreeSet<String>,
18544    semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
18545    worker_feedback: ConflictMatrixWorkerFeedback,
18546}
18547
18548#[derive(Clone, Debug, Serialize)]
18549struct DependencyDagNode {
18550    id: String,
18551    graph_node_id: String,
18552    label: String,
18553    #[serde(skip_serializing_if = "Option::is_none")]
18554    path: Option<String>,
18555    #[serde(skip_serializing_if = "Option::is_none")]
18556    line: Option<i64>,
18557    #[serde(skip_serializing_if = "Option::is_none")]
18558    detail: Option<String>,
18559    source_files: Vec<String>,
18560    source_symbols: Vec<String>,
18561    config_files: Vec<String>,
18562    expected_tests: Vec<String>,
18563    semantic_refs: Vec<ConflictMatrixSemanticRef>,
18564    worker_feedback: ConflictMatrixWorkerFeedback,
18565}
18566
18567#[derive(Clone, Debug, Serialize)]
18568struct DependencyDagEdge {
18569    from: String,
18570    to: String,
18571    kind: String,
18572    weight: usize,
18573    reasons: Vec<String>,
18574    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18575    shared_files: Vec<String>,
18576    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18577    shared_symbols: Vec<String>,
18578    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18579    shared_tests: Vec<String>,
18580    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18581    shared_config_files: Vec<String>,
18582    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18583    shared_semantic_refs: Vec<String>,
18584}
18585
18586#[derive(Clone, Debug, Serialize)]
18587struct DependencyDagTopoBatch {
18588    batch: usize,
18589    targets: Vec<String>,
18590}
18591
18592#[derive(Clone, Debug, Serialize)]
18593struct DependencyDagCycleDiagnostics {
18594    has_cycles: bool,
18595    blocked_nodes: Vec<String>,
18596    cycle_edges: Vec<DependencyDagEdge>,
18597}
18598
18599#[derive(Serialize)]
18600struct DependencyDagSummary {
18601    nodes: usize,
18602    edges: usize,
18603    topo_batches: usize,
18604    has_cycles: bool,
18605}
18606
18607#[derive(Serialize)]
18608struct DependencyDagReport {
18609    contract_version: &'static str,
18610    root: String,
18611    #[serde(skip_serializing_if = "Option::is_none")]
18612    scope: Option<String>,
18613    path: String,
18614    targets: Vec<String>,
18615    projection_freshness: GraphDbFreshnessReport,
18616    projection_hashes: Vec<String>,
18617    nodes: Vec<DependencyDagNode>,
18618    edges: Vec<DependencyDagEdge>,
18619    topo_batches: Vec<DependencyDagTopoBatch>,
18620    cycle_diagnostics: DependencyDagCycleDiagnostics,
18621    summary: DependencyDagSummary,
18622    replay_commands: Vec<String>,
18623    repair_commands: Vec<String>,
18624    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18625    warnings: Vec<String>,
18626}
18627
18628fn dependency_dag_backlog_node_for_target(
18629    store: &impl GraphStore,
18630    target: &str,
18631) -> Result<SubstrateGraphNode> {
18632    let resolved = graph_db_resolve_evidence_target(store, target)?
18633        .with_context(|| format!("dependency-dag target not found: {target}"))?;
18634    if resolved.kind == "backlog" {
18635        return Ok(resolved);
18636    }
18637    let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
18638        bail!(
18639            "dependency-dag target {} resolved to {} without a backlog ref_id",
18640            target,
18641            resolved.kind
18642        );
18643    };
18644    store
18645        .nodes_by_kind("backlog")?
18646        .into_iter()
18647        .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
18648        .min_by(|left, right| {
18649            left.properties
18650                .get("line")
18651                .and_then(|value| value.parse::<i64>().ok())
18652                .cmp(
18653                    &right
18654                        .properties
18655                        .get("line")
18656                        .and_then(|value| value.parse::<i64>().ok()),
18657                )
18658                .then(left.id.cmp(&right.id))
18659        })
18660        .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
18661}
18662
18663fn dependency_dag_resolve_backlog_nodes(
18664    root: &Path,
18665    path: &Path,
18666    store: &impl GraphStore,
18667    raw_targets: &[String],
18668) -> Result<Vec<SubstrateGraphNode>> {
18669    let mut nodes = Vec::new();
18670    let mut seen = BTreeSet::new();
18671    if raw_targets.is_empty() {
18672        let hinted_path = if path.is_absolute() {
18673            path.to_path_buf()
18674        } else {
18675            root.join(path)
18676        };
18677        let hinted_markdown = hinted_path
18678            .extension()
18679            .and_then(|ext| ext.to_str())
18680            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
18681        let hinted_rel = hinted_markdown.then(|| {
18682            relativize_pathbuf(&hinted_path, root)
18683                .to_string_lossy()
18684                .replace('\\', "/")
18685        });
18686        for node in store.nodes_by_kind("backlog")? {
18687            if let Some(expected_path) = &hinted_rel
18688                && node.properties.get("path") != Some(expected_path)
18689            {
18690                continue;
18691            }
18692            if seen.insert(node.id.clone()) {
18693                nodes.push(node);
18694            }
18695        }
18696        if nodes.is_empty() && hinted_rel.is_some() {
18697            for node in store.nodes_by_kind("backlog")? {
18698                if seen.insert(node.id.clone()) {
18699                    nodes.push(node);
18700                }
18701            }
18702        }
18703    } else {
18704        for target in raw_targets {
18705            let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
18706            let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
18707            if seen.insert(node.id.clone()) {
18708                nodes.push(node);
18709            }
18710        }
18711    }
18712    if nodes.is_empty() {
18713        bail!("dependency-dag needs at least one resolvable backlog id");
18714    }
18715    nodes.sort_by(|left, right| {
18716        left.properties
18717            .get("line")
18718            .and_then(|value| value.parse::<i64>().ok())
18719            .cmp(
18720                &right
18721                    .properties
18722                    .get("line")
18723                    .and_then(|value| value.parse::<i64>().ok()),
18724            )
18725            .then(left.id.cmp(&right.id))
18726    });
18727    Ok(nodes)
18728}
18729
18730fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
18731    node.properties
18732        .get("ref_id")
18733        .cloned()
18734        .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
18735}
18736
18737fn dependency_dag_node_profile(
18738    root: &Path,
18739    store: &impl GraphStore,
18740    node: &SubstrateGraphNode,
18741    graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
18742    graph_edges: &[SubstrateGraphEdge],
18743    depth: usize,
18744    limit: usize,
18745) -> Result<DependencyDagProfile> {
18746    let id = dependency_dag_node_id(node);
18747    let mut source_files = BTreeSet::new();
18748    let mut source_symbols = BTreeSet::new();
18749    for edge in graph_edges
18750        .iter()
18751        .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
18752    {
18753        let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
18754            continue;
18755        };
18756        match target.kind.as_str() {
18757            "file" | "route" => {
18758                if let Some(path) = target.properties.get("path") {
18759                    source_files.insert(path.clone());
18760                }
18761            }
18762            "symbol" => {
18763                source_symbols.insert(target.label.clone());
18764                if let Some(path) = target.properties.get("path") {
18765                    source_files.insert(path.clone());
18766                }
18767            }
18768            _ => {}
18769        }
18770    }
18771
18772    let max_rows = if limit == 0 { usize::MAX } else { limit };
18773    for (source, _) in
18774        graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
18775    {
18776        let terse: SubstrateTerseGraphNode = (&source).into();
18777        if let Some(handle) = conflict_matrix_source_handle(&terse) {
18778            source_files.insert(handle.file);
18779        }
18780    }
18781
18782    let worker_results = graph_nodes_by_id
18783        .values()
18784        .filter(|candidate| {
18785            candidate.kind == "worker_result"
18786                && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
18787        })
18788        .map(SubstrateTerseGraphNode::from)
18789        .collect::<Vec<_>>();
18790    let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
18791    let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
18792    let config_files = source_files
18793        .iter()
18794        .filter(|file| is_planner_config_path(file))
18795        .cloned()
18796        .collect();
18797
18798    let mut semantic_refs = BTreeMap::new();
18799    for kind in ["semantic_concept", "semantic_entity"] {
18800        for (semantic, _) in
18801            graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
18802        {
18803            let terse: SubstrateTerseGraphNode = (&semantic).into();
18804            let item = conflict_matrix_semantic_ref(root, &terse);
18805            semantic_refs
18806                .entry(format!("{}:{}", item.kind, item.label))
18807                .or_insert(item);
18808        }
18809    }
18810
18811    Ok(DependencyDagProfile {
18812        id,
18813        graph_node_id: node.id.clone(),
18814        label: node.label.clone(),
18815        path: node.properties.get("path").cloned(),
18816        line: node
18817            .properties
18818            .get("line")
18819            .and_then(|value| value.parse::<i64>().ok()),
18820        detail: node.properties.get("detail").cloned(),
18821        source_files,
18822        source_symbols,
18823        config_files,
18824        expected_tests,
18825        semantic_refs,
18826        worker_feedback,
18827    })
18828}
18829
18830fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
18831    let lower = text.to_ascii_lowercase();
18832    let mut refs = Vec::new();
18833    for marker in markers {
18834        let mut offset = 0usize;
18835        while let Some(pos) = lower[offset..].find(marker) {
18836            let start = offset + pos + marker.len();
18837            let segment = text[start..]
18838                .split(['\n', '.'])
18839                .next()
18840                .unwrap_or(&text[start..]);
18841            refs.extend(extract_conflict_target_refs(segment));
18842            offset = start;
18843        }
18844    }
18845    dedupe_preserve_order(refs)
18846}
18847
18848fn dependency_dag_push_edge(
18849    edges: &mut Vec<DependencyDagEdge>,
18850    seen: &mut BTreeSet<(String, String, String)>,
18851    edge: DependencyDagEdge,
18852) {
18853    if edge.from == edge.to {
18854        return;
18855    }
18856    if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
18857        edges.push(edge);
18858    }
18859}
18860
18861fn dependency_dag_explicit_edges(
18862    profiles: &[DependencyDagProfile],
18863    target_ids: &BTreeSet<String>,
18864    edges: &mut Vec<DependencyDagEdge>,
18865    seen: &mut BTreeSet<(String, String, String)>,
18866) {
18867    for profile in profiles {
18868        let detail = profile.detail.as_deref().unwrap_or_default();
18869        for dep in dependency_dag_marker_refs(
18870            detail,
18871            &[
18872                "depends on",
18873                "depends-on",
18874                "deps:",
18875                "after",
18876                "blocked by",
18877                "requires",
18878            ],
18879        ) {
18880            if target_ids.contains(&dep) {
18881                dependency_dag_push_edge(
18882                    edges,
18883                    seen,
18884                    DependencyDagEdge {
18885                        from: dep.clone(),
18886                        to: profile.id.clone(),
18887                        kind: "explicit_depends_on".to_string(),
18888                        weight: 1000,
18889                        reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
18890                        shared_files: Vec::new(),
18891                        shared_symbols: Vec::new(),
18892                        shared_tests: Vec::new(),
18893                        shared_config_files: Vec::new(),
18894                        shared_semantic_refs: Vec::new(),
18895                    },
18896                );
18897            }
18898        }
18899        for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
18900            if target_ids.contains(&downstream) {
18901                dependency_dag_push_edge(
18902                    edges,
18903                    seen,
18904                    DependencyDagEdge {
18905                        from: profile.id.clone(),
18906                        to: downstream.clone(),
18907                        kind: "explicit_before".to_string(),
18908                        weight: 900,
18909                        reasons: vec![format!(
18910                            "{} declares it should run before #{downstream}",
18911                            profile.id
18912                        )],
18913                        shared_files: Vec::new(),
18914                        shared_symbols: Vec::new(),
18915                        shared_tests: Vec::new(),
18916                        shared_config_files: Vec::new(),
18917                        shared_semantic_refs: Vec::new(),
18918                    },
18919                );
18920            }
18921        }
18922    }
18923}
18924
18925fn dependency_dag_worker_follow_up_edges(
18926    profiles: &[DependencyDagProfile],
18927    target_ids: &BTreeSet<String>,
18928    edges: &mut Vec<DependencyDagEdge>,
18929    seen: &mut BTreeSet<(String, String, String)>,
18930) {
18931    for profile in profiles {
18932        for follow_up in &profile.worker_feedback.follow_up_ids {
18933            if target_ids.contains(follow_up) {
18934                dependency_dag_push_edge(
18935                    edges,
18936                    seen,
18937                    DependencyDagEdge {
18938                        from: profile.id.clone(),
18939                        to: follow_up.clone(),
18940                        kind: "worker_result_follow_up".to_string(),
18941                        weight: 700,
18942                        reasons: vec![format!(
18943                            "worker_result for #{} references follow-up #{}",
18944                            profile.id, follow_up
18945                        )],
18946                        shared_files: Vec::new(),
18947                        shared_symbols: Vec::new(),
18948                        shared_tests: Vec::new(),
18949                        shared_config_files: Vec::new(),
18950                        shared_semantic_refs: Vec::new(),
18951                    },
18952                );
18953            }
18954        }
18955    }
18956}
18957
18958fn dependency_dag_overlap_edges(
18959    profiles: &[DependencyDagProfile],
18960    edges: &mut Vec<DependencyDagEdge>,
18961    seen: &mut BTreeSet<(String, String, String)>,
18962) {
18963    for left_idx in 0..profiles.len() {
18964        for right_idx in (left_idx + 1)..profiles.len() {
18965            let left = &profiles[left_idx];
18966            let right = &profiles[right_idx];
18967            let shared_files = sorted_intersection(&left.source_files, &right.source_files);
18968            let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
18969            let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
18970            let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
18971            let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
18972            let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
18973            let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
18974            if shared_files.is_empty()
18975                && shared_symbols.is_empty()
18976                && shared_tests.is_empty()
18977                && shared_config_files.is_empty()
18978                && shared_semantic_refs.is_empty()
18979            {
18980                continue;
18981            }
18982            let kind = if shared_files.is_empty()
18983                && shared_symbols.is_empty()
18984                && shared_tests.is_empty()
18985                && shared_config_files.is_empty()
18986            {
18987                "semantic_relation"
18988            } else {
18989                "shared_resource"
18990            };
18991            let mut reasons = Vec::new();
18992            if !shared_files.is_empty() {
18993                reasons.push(format!("shared files: {}", shared_files.join(", ")));
18994            }
18995            if !shared_symbols.is_empty() {
18996                reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
18997            }
18998            if !shared_tests.is_empty() {
18999                reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19000            }
19001            if !shared_config_files.is_empty() {
19002                reasons.push(format!(
19003                    "shared config files: {}",
19004                    shared_config_files.join(", ")
19005                ));
19006            }
19007            if !shared_semantic_refs.is_empty() {
19008                reasons.push(format!(
19009                    "shared semantic refs: {}",
19010                    shared_semantic_refs.join(", ")
19011                ));
19012            }
19013            let weight = shared_files.len() * 100
19014                + shared_config_files.len() * 100
19015                + shared_symbols.len() * 40
19016                + shared_tests.len() * 10
19017                + shared_semantic_refs.len() * 5;
19018            dependency_dag_push_edge(
19019                edges,
19020                seen,
19021                DependencyDagEdge {
19022                    from: left.id.clone(),
19023                    to: right.id.clone(),
19024                    kind: kind.to_string(),
19025                    weight,
19026                    reasons,
19027                    shared_files,
19028                    shared_symbols,
19029                    shared_tests,
19030                    shared_config_files,
19031                    shared_semantic_refs,
19032                },
19033            );
19034        }
19035    }
19036}
19037
19038fn dependency_dag_topo_batches(
19039    targets: &[String],
19040    edges: &[DependencyDagEdge],
19041) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19042    let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19043    let order = targets
19044        .iter()
19045        .enumerate()
19046        .map(|(idx, id)| (id.clone(), idx))
19047        .collect::<BTreeMap<_, _>>();
19048    let mut indegree = targets
19049        .iter()
19050        .map(|id| (id.clone(), 0usize))
19051        .collect::<BTreeMap<_, _>>();
19052    let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19053    let mut seen_pairs = BTreeSet::<(String, String)>::new();
19054    for edge in edges {
19055        if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19056            continue;
19057        }
19058        if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19059            continue;
19060        }
19061        *indegree.entry(edge.to.clone()).or_default() += 1;
19062        outgoing
19063            .entry(edge.from.clone())
19064            .or_default()
19065            .push(edge.to.clone());
19066    }
19067    for values in outgoing.values_mut() {
19068        values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19069        values.dedup();
19070    }
19071
19072    let mut processed = BTreeSet::new();
19073    let mut batches = Vec::new();
19074    loop {
19075        let mut ready = targets
19076            .iter()
19077            .filter(|id| !processed.contains(*id))
19078            .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19079            .cloned()
19080            .collect::<Vec<_>>();
19081        ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19082        if ready.is_empty() {
19083            break;
19084        }
19085        for id in &ready {
19086            processed.insert(id.clone());
19087            for next in outgoing.get(id).into_iter().flatten() {
19088                if let Some(value) = indegree.get_mut(next) {
19089                    *value = value.saturating_sub(1);
19090                }
19091            }
19092        }
19093        batches.push(DependencyDagTopoBatch {
19094            batch: batches.len() + 1,
19095            targets: ready,
19096        });
19097    }
19098
19099    let blocked_nodes = targets
19100        .iter()
19101        .filter(|id| !processed.contains(*id))
19102        .cloned()
19103        .collect::<Vec<_>>();
19104    let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19105    let cycle_edges = edges
19106        .iter()
19107        .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19108        .cloned()
19109        .collect::<Vec<_>>();
19110    (
19111        batches,
19112        DependencyDagCycleDiagnostics {
19113            has_cycles: !blocked_nodes.is_empty(),
19114            blocked_nodes,
19115            cycle_edges,
19116        },
19117    )
19118}
19119
19120fn dependency_dag_replay_commands(
19121    path: &Path,
19122    scope: Option<&str>,
19123    targets: &[String],
19124    depth: usize,
19125    limit: usize,
19126) -> Vec<String> {
19127    let target_args = targets
19128        .iter()
19129        .map(|target| shell_quote(target))
19130        .collect::<Vec<_>>()
19131        .join(" ");
19132    let mut command = format!(
19133        "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19134        shell_quote(path.to_string_lossy().as_ref()),
19135        scope
19136            .map(|scope| format!(" --scope {}", shell_quote(scope)))
19137            .unwrap_or_default(),
19138        depth,
19139        limit
19140    );
19141    if !target_args.is_empty() {
19142        command.push(' ');
19143        command.push_str(&target_args);
19144    }
19145    vec![command]
19146}
19147
19148fn build_dependency_dag_report(
19149    path: &Path,
19150    scope: Option<&str>,
19151    raw_targets: &[String],
19152    depth: usize,
19153    limit: usize,
19154) -> Result<DependencyDagReport> {
19155    let root = lint::resolve_project_root_or_canonical_path(path)?;
19156    write_traversal_graph_store(&root, path, scope)
19157        .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19158    let graph_db = graph_substrate_db_path(&root, scope);
19159    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19160        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19161    let mut warnings = Vec::new();
19162    if let Some(recovery) = store.read_only_recovery() {
19163        warnings.push(graph_db_read_recovery_diagnostic(recovery));
19164    }
19165    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19166    if freshness.fail_closed {
19167        bail!(
19168            "dependency-dag graph projection failed closed: {}; repair: {}",
19169            freshness.diagnostics.join("; "),
19170            graph_db_repair_commands(&root, scope).join("; ")
19171        );
19172    }
19173
19174    let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19175    let graph_nodes = store.all_nodes()?;
19176    let graph_edges = store.all_edges()?;
19177    let graph_nodes_by_id = graph_nodes
19178        .into_iter()
19179        .map(|node| (node.id.clone(), node))
19180        .collect::<BTreeMap<_, _>>();
19181    let profiles = target_nodes
19182        .iter()
19183        .map(|node| {
19184            dependency_dag_node_profile(
19185                &root,
19186                &store,
19187                node,
19188                &graph_nodes_by_id,
19189                &graph_edges,
19190                depth,
19191                limit,
19192            )
19193        })
19194        .collect::<Result<Vec<_>>>()?;
19195    let targets = profiles
19196        .iter()
19197        .map(|profile| profile.id.clone())
19198        .collect::<Vec<_>>();
19199    let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19200
19201    let mut edges = Vec::new();
19202    let mut seen_edges = BTreeSet::new();
19203    dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19204    dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19205    dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19206    edges.sort_by(|left, right| {
19207        left.from
19208            .cmp(&right.from)
19209            .then(left.to.cmp(&right.to))
19210            .then(left.kind.cmp(&right.kind))
19211    });
19212    let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19213
19214    let nodes = profiles
19215        .into_iter()
19216        .map(|profile| DependencyDagNode {
19217            id: profile.id,
19218            graph_node_id: profile.graph_node_id,
19219            label: profile.label,
19220            path: profile.path,
19221            line: profile.line,
19222            detail: profile.detail,
19223            source_files: sorted_set(&profile.source_files),
19224            source_symbols: sorted_set(&profile.source_symbols),
19225            config_files: sorted_set(&profile.config_files),
19226            expected_tests: sorted_set(&profile.expected_tests),
19227            semantic_refs: profile.semantic_refs.into_values().collect(),
19228            worker_feedback: profile.worker_feedback,
19229        })
19230        .collect::<Vec<_>>();
19231    let projection_hashes = freshness
19232        .content_hash
19233        .clone()
19234        .into_iter()
19235        .collect::<Vec<_>>();
19236    let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19237    let repair_commands = graph_db_repair_commands(&root, scope);
19238    let summary = DependencyDagSummary {
19239        nodes: nodes.len(),
19240        edges: edges.len(),
19241        topo_batches: topo_batches.len(),
19242        has_cycles: cycle_diagnostics.has_cycles,
19243    };
19244
19245    Ok(DependencyDagReport {
19246        contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19247        root: root.to_string_lossy().to_string(),
19248        scope: scope.map(str::to_string),
19249        path: path.to_string_lossy().to_string(),
19250        targets,
19251        projection_freshness: freshness,
19252        projection_hashes,
19253        nodes,
19254        edges,
19255        topo_batches,
19256        cycle_diagnostics,
19257        summary,
19258        replay_commands,
19259        repair_commands,
19260        warnings,
19261    })
19262}
19263
19264fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
19265    if compact {
19266        println!(
19267            "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
19268            report.targets.len(),
19269            report.edges.len(),
19270            report.topo_batches.len(),
19271            report.cycle_diagnostics.has_cycles
19272        );
19273    } else {
19274        println!("Dependency DAG");
19275        println!("  targets: {}", report.targets.join(", "));
19276        println!("  edges:   {}", report.edges.len());
19277        println!("  cycles:  {}", report.cycle_diagnostics.has_cycles);
19278    }
19279    for batch in &report.topo_batches {
19280        println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
19281    }
19282    for edge in &report.edges {
19283        println!(
19284            "edge {} -> {} kind:{} weight:{}",
19285            edge.from, edge.to, edge.kind, edge.weight
19286        );
19287        for reason in &edge.reasons {
19288            println!("  reason: {reason}");
19289        }
19290    }
19291    if report.cycle_diagnostics.has_cycles {
19292        println!(
19293            "cycle blocked nodes: {}",
19294            report.cycle_diagnostics.blocked_nodes.join(", ")
19295        );
19296    }
19297    for command in &report.replay_commands {
19298        println!("replay: {command}");
19299    }
19300    for command in &report.repair_commands {
19301        println!("repair: {command}");
19302    }
19303    for warning in &report.warnings {
19304        println!("warning: {warning}");
19305    }
19306}
19307
19308fn cmd_dependency_dag(
19309    path: &Path,
19310    scope: Option<&str>,
19311    raw_targets: &[String],
19312    depth: usize,
19313    limit: usize,
19314    format: OutputFormat,
19315) -> Result<()> {
19316    let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
19317    if format.json_output {
19318        print_json_or_envelope(
19319            &report,
19320            &format,
19321            "dependency-dag",
19322            "topological-planning",
19323            ToolEnvelopeSummary {
19324                text: format!(
19325                    "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
19326                    report.targets.len(),
19327                    report.edges.len(),
19328                    report.topo_batches.len(),
19329                    report.cycle_diagnostics.has_cycles
19330                ),
19331                metrics: vec![
19332                    envelope_metric("targets", report.targets.len()),
19333                    envelope_metric("edges", report.edges.len()),
19334                    envelope_metric("topo_batches", report.topo_batches.len()),
19335                    envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
19336                ],
19337            },
19338            report.cycle_diagnostics.has_cycles,
19339            report.replay_commands.clone(),
19340        )
19341    } else {
19342        print_dependency_dag_human(&report, format.compact);
19343        Ok(())
19344    }
19345}
19346
19347/// Persist a bulky raw log behind an artifact handle and attach it to the
19348/// report, so the bounded digest references the full transcript via a stable
19349/// handle + expansion command instead of losing it (stdin) or relying on
19350/// inlined groups. No-op for small logs or when the artifacts dir is unwritable.
19351fn maybe_attach_log_digest_raw_artifact(
19352    root: &Path,
19353    report: &mut log_digest::LogDigestReport,
19354    input: &str,
19355) -> Result<()> {
19356    if input.trim().is_empty() || !log_digest::raw_log_artifact_recommended(report, input.len()) {
19357        return Ok(());
19358    }
19359    let key = format!("logdigest:{}:{}", report.total_lines, input.len());
19360    let artifact_path = root
19361        .join(".tsift/artifacts")
19362        .join(format!("{}.log", stable_handle("logdg", &key)));
19363    let expand = format!(
19364        "tsift log-digest --path {} --input {} --json",
19365        shell_quote(root.to_string_lossy().as_ref()),
19366        shell_quote(artifact_path.to_string_lossy().as_ref())
19367    );
19368    let artifact = persist_transcript_artifact(root, "logdg", "log", &key, input, expand)?;
19369    report.raw_log_artifact = Some(log_digest::LogDigestArtifactRef {
19370        handle: artifact.handle,
19371        path: artifact.path,
19372        bytes: artifact.bytes,
19373        lines: artifact.lines,
19374        expand: artifact.expand,
19375    });
19376    Ok(())
19377}
19378
19379/// Run the log-digest token-savings + false-negative fixture gate: prove the
19380/// digest both compresses raw cargo/pytest/npm/pnpm/agent-doc logs and preserves
19381/// their real signals. With `fail_under`, exits non-zero on any case miss.
19382pub(crate) fn render_log_digest_fixture(
19383    path: &Path,
19384    fixture_path: &Path,
19385    fail_under: bool,
19386    format: OutputFormat,
19387) -> Result<()> {
19388    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
19389    let fixture_body = fs::read_to_string(fixture_path)
19390        .with_context(|| format!("reading log-digest fixture: {}", fixture_path.display()))?;
19391    let fixture: log_digest::LogDigestFixture = serde_json::from_str(&fixture_body)
19392        .with_context(|| format!("parsing log-digest fixture: {}", fixture_path.display()))?;
19393    let report = log_digest::evaluate_fixture(&root, &fixture)?;
19394
19395    if format.json_output {
19396        print_json_or_envelope(
19397            &report,
19398            &format,
19399            "log-digest-fixture",
19400            "report",
19401            ToolEnvelopeSummary {
19402                text: if report.passed {
19403                    format!("log-digest gate passed for {} case(s)", report.total_cases)
19404                } else {
19405                    format!("log-digest gate failed {} case(s)", report.failed_cases)
19406                },
19407                metrics: vec![
19408                    envelope_metric("cases", report.total_cases),
19409                    envelope_metric("failed", report.failed_cases),
19410                    envelope_metric("passed", report.passed),
19411                ],
19412            },
19413            false,
19414            vec![],
19415        )?;
19416    } else {
19417        println!("Log digest fixture gate");
19418        println!("  cases:  {}", report.total_cases);
19419        println!("  failed: {}", report.failed_cases);
19420        println!("  status: {}", if report.passed { "pass" } else { "fail" });
19421        for case in &report.cases {
19422            println!(
19423                "  [{}] {} ({}): savings {:.1}% (min {:.1}%) raw_tok {} digest_tok {}",
19424                if case.passed { "pass" } else { "FAIL" },
19425                case.name,
19426                case.ecosystem,
19427                case.savings_percent,
19428                case.minimum_savings_percent,
19429                case.raw_tokens,
19430                case.digest_tokens
19431            );
19432            if !case.missing_required_signals.is_empty() {
19433                println!(
19434                    "    missing required signals: {}",
19435                    case.missing_required_signals.join(", ")
19436                );
19437            }
19438            if !case.present_forbidden_signals.is_empty() {
19439                println!(
19440                    "    present forbidden signals: {}",
19441                    case.present_forbidden_signals.join(", ")
19442                );
19443            }
19444        }
19445    }
19446
19447    if fail_under && !report.passed {
19448        bail!("log-digest fixture gate failed");
19449    }
19450    Ok(())
19451}
19452
19453pub(crate) fn render_log_digest_from_input(
19454    path: &Path,
19455    input: &str,
19456    format: OutputFormat,
19457) -> Result<()> {
19458    let mut report = log_digest::compute(path, input)?;
19459    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
19460    maybe_attach_log_digest_raw_artifact(&root, &mut report, input)?;
19461    if format.json_output {
19462        println!(
19463            "{}",
19464            to_json_schema(
19465                &report,
19466                format.pretty,
19467                format.terse,
19468                format.ultra_terse,
19469                format.schema
19470            )?
19471        );
19472        return Ok(());
19473    }
19474
19475    if format.compact {
19476        println!(
19477            "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
19478            report.non_empty_lines,
19479            report.signal_groups,
19480            report.repeated_line_groups,
19481            report.file_ref_groups,
19482            report.symbol_ref_groups,
19483            report.stack_groups
19484        );
19485        for signal in &report.signals {
19486            let location = match (&signal.path, signal.line) {
19487                (Some(path), Some(line)) => format!("{path}:{line}"),
19488                (Some(path), None) => path.clone(),
19489                _ => "-".to_string(),
19490            };
19491            println!(
19492                "{} sev:{} count:{} sums:{} msg:{}",
19493                location,
19494                signal.severity,
19495                signal.occurrences,
19496                log_digest_summary_label(signal.summary_state),
19497                truncate_for_compact(&signal.message, 80)
19498            );
19499        }
19500        for repeated in &report.repeated_lines {
19501            println!(
19502                "repeat count:{} line:{}",
19503                repeated.occurrences,
19504                truncate_for_compact(&repeated.line, 80)
19505            );
19506        }
19507        for family in &report.line_families {
19508            println!(
19509                "family count:{} variants:{} template:{}",
19510                family.occurrences,
19511                family.variants,
19512                truncate_for_compact(&family.template, 80)
19513            );
19514        }
19515        for symbol in &report.symbol_refs {
19516            println!(
19517                "sym:{} count:{} sums:{}",
19518                symbol.symbol,
19519                symbol.occurrences,
19520                log_digest_summary_label(symbol.summary_state)
19521            );
19522        }
19523        if let Some(artifact) = &report.raw_log_artifact {
19524            println!(
19525                "raw-artifact handle:{} lines:{} bytes:{} expand:{}",
19526                artifact.handle, artifact.lines, artifact.bytes, artifact.expand
19527            );
19528        }
19529        for warning in &report.warnings {
19530            println!("warning: {warning}");
19531        }
19532        return Ok(());
19533    }
19534
19535    println!("Log digest");
19536    println!("  lines:                    {}", report.total_lines);
19537    println!("  non-empty lines:          {}", report.non_empty_lines);
19538    println!("  signal groups:            {}", report.signal_groups);
19539    println!(
19540        "  repeated lines:           {}",
19541        report.repeated_line_groups
19542    );
19543    println!(
19544        "  repeated line instances:  {}",
19545        report.repeated_line_occurrences
19546    );
19547    println!(
19548        "  line families:            {}",
19549        report.line_family_groups
19550    );
19551    println!("  file refs:                {}", report.file_ref_groups);
19552    println!("  symbol refs:              {}", report.symbol_ref_groups);
19553    println!("  stack groups:             {}", report.stack_groups);
19554
19555    if !report.signals.is_empty() {
19556        println!();
19557        println!("Signals:");
19558        for signal in &report.signals {
19559            match (&signal.path, signal.line, signal.column) {
19560                (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
19561                (Some(path), Some(line), None) => println!("{path}:{line}"),
19562                (Some(path), None, _) => println!("{path}"),
19563                (None, _, _) => println!("(no file anchor)"),
19564            }
19565            println!("  severity: {}", signal.severity);
19566            println!("  occurrences: {}", signal.occurrences);
19567            println!("  message: {}", signal.message);
19568            println!(
19569                "  cached summaries: {}",
19570                log_digest_summary_label(signal.summary_state)
19571            );
19572            for summary in &signal.current_summaries {
19573                println!(
19574                    "    - {}: {}",
19575                    summary.symbol,
19576                    truncate_for_compact(&summary.summary, 160)
19577                );
19578            }
19579        }
19580    }
19581
19582    if !report.repeated_lines.is_empty() {
19583        println!();
19584        println!("Repeated lines:");
19585        for repeated in &report.repeated_lines {
19586            println!(
19587                "  {}x {}",
19588                repeated.occurrences,
19589                truncate_for_compact(&repeated.line, 180)
19590            );
19591        }
19592    }
19593
19594    if !report.line_families.is_empty() {
19595        println!();
19596        println!("Line families (near-duplicate folds):");
19597        for family in &report.line_families {
19598            println!(
19599                "  {}x ({} variants) {}",
19600                family.occurrences,
19601                family.variants,
19602                truncate_for_compact(&family.template, 180)
19603            );
19604            println!(
19605                "    first: {}",
19606                truncate_for_compact(&family.first_sample, 180)
19607            );
19608            println!(
19609                "    last:  {}",
19610                truncate_for_compact(&family.last_sample, 180)
19611            );
19612        }
19613    }
19614
19615    if !report.file_refs.is_empty() {
19616        println!();
19617        println!("Anchored files:");
19618        for file_ref in &report.file_refs {
19619            match (file_ref.line, file_ref.column) {
19620                (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
19621                (Some(line), None) => println!("{}:{}", file_ref.path, line),
19622                (None, _) => println!("{}", file_ref.path),
19623            }
19624            println!("  occurrences: {}", file_ref.occurrences);
19625            println!(
19626                "  cached summaries: {}",
19627                log_digest_summary_label(file_ref.summary_state)
19628            );
19629            for summary in &file_ref.current_summaries {
19630                println!(
19631                    "    - {}: {}",
19632                    summary.symbol,
19633                    truncate_for_compact(&summary.summary, 160)
19634                );
19635            }
19636        }
19637    }
19638
19639    if !report.symbol_refs.is_empty() {
19640        println!();
19641        println!("Symbol candidates:");
19642        for symbol in &report.symbol_refs {
19643            println!("{}", symbol.symbol);
19644            println!("  occurrences: {}", symbol.occurrences);
19645            println!(
19646                "  cached summaries: {}",
19647                log_digest_summary_label(symbol.summary_state)
19648            );
19649            for summary in &symbol.current_summaries {
19650                println!(
19651                    "    - {}: {}",
19652                    summary.symbol,
19653                    truncate_for_compact(&summary.summary, 160)
19654                );
19655            }
19656        }
19657    }
19658
19659    if !report.stack_traces.is_empty() {
19660        println!();
19661        println!("Stack groups:");
19662        for stack in &report.stack_traces {
19663            println!("  occurrences: {}", stack.occurrences);
19664            for frame in &stack.frames {
19665                println!("    - {}", frame);
19666            }
19667        }
19668    }
19669
19670    if let Some(artifact) = &report.raw_log_artifact {
19671        println!();
19672        println!("Raw log artifact:");
19673        println!("  handle: {}", artifact.handle);
19674        println!("  path:   {}", artifact.path);
19675        println!("  lines:  {}", artifact.lines);
19676        println!("  bytes:  {}", artifact.bytes);
19677        println!("  expand: {}", artifact.expand);
19678    }
19679
19680    for warning in &report.warnings {
19681        println!("warning: {warning}");
19682    }
19683    Ok(())
19684}
19685
19686pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
19687    match trend {
19688        metric_digest::MetricDigestTrend::Improved => "improved",
19689        metric_digest::MetricDigestTrend::Regressed => "regressed",
19690        metric_digest::MetricDigestTrend::Flat => "flat",
19691        metric_digest::MetricDigestTrend::Unknown => "changed",
19692    }
19693}
19694
19695pub(crate) fn metric_digest_gate_label(
19696    decision: metric_digest::CommunitySearchGateDecision,
19697) -> &'static str {
19698    match decision {
19699        metric_digest::CommunitySearchGateDecision::Pass => "pass",
19700        metric_digest::CommunitySearchGateDecision::Block => "block",
19701    }
19702}
19703
19704pub(crate) fn memgraphrag_metric_digest_gate_label(
19705    decision: metric_digest::MemGraphRagPerformanceGateDecision,
19706) -> &'static str {
19707    match decision {
19708        metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
19709        metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
19710    }
19711}
19712
19713fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
19714    let input = fs::read_to_string(fixture_path)
19715        .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
19716    let report = dci_benchmark::compute(&input)?;
19717
19718    if format.json_output {
19719        println!(
19720            "{}",
19721            to_json_schema(
19722                &report,
19723                format.pretty,
19724                format.terse,
19725                format.ultra_terse,
19726                format.schema
19727            )?
19728        );
19729        return Ok(());
19730    }
19731
19732    if format.compact {
19733        println!(
19734            "dci tasks:{} strategies:{} warnings:{}",
19735            report.tasks_loaded,
19736            report.strategies_compared,
19737            report.warnings.len()
19738        );
19739        for summary in &report.strategy_summaries {
19740            println!(
19741                "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
19742                summary.strategy,
19743                summary.rank,
19744                summary.localized,
19745                summary.task_runs,
19746                dci_benchmark::format_number(summary.localization_rate * 100.0),
19747                dci_benchmark::format_number(summary.avg_useful_hits),
19748                dci_benchmark::format_number(summary.zero_output_rate * 100.0),
19749                dci_benchmark::format_number(summary.avg_tool_calls),
19750                dci_benchmark::format_number(summary.avg_latency_ms),
19751                dci_benchmark::format_number(summary.avg_estimated_tokens),
19752                dci_benchmark::format_number(summary.avg_output_tokens)
19753            );
19754        }
19755        if let Some(gate) = &report.memory_retrieval_gate {
19756            println!(
19757                "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
19758                gate.decision,
19759                gate.baseline_strategy,
19760                dci_benchmark::format_number(gate.min_avg_useful_hits),
19761                gate.max_zero_output_failures,
19762                gate.diagnostics.len()
19763            );
19764        }
19765        for warning in &report.warnings {
19766            println!("warning: {warning}");
19767        }
19768        return Ok(());
19769    }
19770
19771    println!("DCI benchmark");
19772    if let Some(description) = &report.description {
19773        println!("  description: {}", description);
19774    }
19775    println!("  tasks loaded:        {}", report.tasks_loaded);
19776    println!("  strategies compared: {}", report.strategies_compared);
19777
19778    println!();
19779    println!("Strategy summary:");
19780    for summary in &report.strategy_summaries {
19781        println!(
19782            "  #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
19783            summary.rank,
19784            summary.strategy,
19785            summary.localized,
19786            summary.task_runs,
19787            summary.localization_rate * 100.0,
19788            dci_benchmark::format_number(summary.avg_useful_hits),
19789            summary.zero_output_rate * 100.0,
19790            dci_benchmark::format_number(summary.avg_tool_calls),
19791            dci_benchmark::format_number(summary.avg_latency_ms),
19792            dci_benchmark::format_number(summary.avg_estimated_tokens),
19793            dci_benchmark::format_number(summary.avg_output_tokens)
19794        );
19795    }
19796
19797    if let Some(gate) = &report.memory_retrieval_gate {
19798        println!();
19799        println!("Memory retrieval gate:");
19800        println!("  decision: {}", gate.decision);
19801        println!(
19802            "  baseline: {}, min avg useful hits {}, max zero-output failures {}",
19803            gate.baseline_strategy,
19804            dci_benchmark::format_number(gate.min_avg_useful_hits),
19805            gate.max_zero_output_failures
19806        );
19807        for row in &gate.rows {
19808            println!(
19809                "  {}: status {}, avg useful hits {}, zero-output failures {}",
19810                row.strategy,
19811                row.status,
19812                dci_benchmark::format_number(row.avg_useful_hits),
19813                row.zero_output_failures
19814            );
19815        }
19816        for diagnostic in &gate.diagnostics {
19817            println!("  diagnostic: {diagnostic}");
19818        }
19819    }
19820
19821    println!();
19822    println!("Task winners:");
19823    for row in &report.task_rows {
19824        let label = row
19825            .label
19826            .as_ref()
19827            .map(|value| format!(" ({value})"))
19828            .unwrap_or_default();
19829        println!("  {}{}", row.task_id, label);
19830        println!("    localized: {}", row.best_localization.join(", "));
19831        println!("    most useful hits: {}", row.most_useful_hits.join(", "));
19832        println!(
19833            "    lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
19834            row.lowest_tool_calls.as_deref().unwrap_or("-"),
19835            row.lowest_latency.as_deref().unwrap_or("-"),
19836            row.lowest_token_budget.as_deref().unwrap_or("-"),
19837            row.lowest_output_tokens.as_deref().unwrap_or("-")
19838        );
19839        if !row.zero_output_failures.is_empty() {
19840            println!("    zero output: {}", row.zero_output_failures.join(", "));
19841        }
19842    }
19843
19844    for warning in &report.warnings {
19845        println!("warning: {warning}");
19846    }
19847    Ok(())
19848}
19849
19850pub(crate) fn format_compact_count(value: u64) -> String {
19851    if value >= 1_000_000 {
19852        format!("{:.1}M", value as f64 / 1_000_000.0)
19853    } else if value >= 1_000 {
19854        format!("{:.1}K", value as f64 / 1_000.0)
19855    } else {
19856        value.to_string()
19857    }
19858}
19859
19860fn cmd_digest_runner(
19861    kind: &str,
19862    path: &Path,
19863    runner: Option<&str>,
19864    shell_command: &str,
19865    format: OutputFormat,
19866) -> Result<()> {
19867    let digest_kind = DigestRunnerKind::parse(kind)?;
19868    let root = transcript_artifact_root(path)?;
19869    let execution = run_digest_runner_command(shell_command)?;
19870    let output = &execution.output;
19871    let captured = String::from_utf8_lossy(&output.stdout).into_owned();
19872    let exit_code = output.status.code().unwrap_or(-1);
19873    if format.json_output && format.envelope {
19874        let artifact_key = format!(
19875            "{}:{}:{}:{}",
19876            digest_kind.as_str(),
19877            shell_command,
19878            execution.executed_command,
19879            captured
19880        );
19881        let artifact = if captured.trim().is_empty() {
19882            None
19883        } else {
19884            let (suffix, expand) = match digest_kind {
19885                DigestRunnerKind::Test => (
19886                    "test.log",
19887                    format!(
19888                        "tsift test-digest --path {} --input {}{} --json",
19889                        shell_quote(root.to_string_lossy().as_ref()),
19890                        shell_quote(
19891                            root.join(".tsift/artifacts")
19892                                .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
19893                                .to_string_lossy()
19894                                .as_ref()
19895                        ),
19896                        runner
19897                            .map(|value| format!(" --runner {}", shell_quote(value)))
19898                            .unwrap_or_default()
19899                    ),
19900                ),
19901                DigestRunnerKind::Log => (
19902                    "log",
19903                    format!(
19904                        "tsift log-digest --path {} --input {} --json",
19905                        shell_quote(root.to_string_lossy().as_ref()),
19906                        shell_quote(
19907                            root.join(".tsift/artifacts")
19908                                .join(format!("{}.log", stable_handle("tart", &artifact_key)))
19909                                .to_string_lossy()
19910                                .as_ref()
19911                        )
19912                    ),
19913                ),
19914            };
19915            Some(persist_transcript_artifact(
19916                &root,
19917                "tart",
19918                suffix,
19919                &artifact_key,
19920                &captured,
19921                expand,
19922            )?)
19923        };
19924        let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
19925
19926        match digest_kind {
19927            DigestRunnerKind::Test => {
19928                let digest_report = test_digest::compute(path, &captured, runner)?;
19929                let report = serde_json::json!({
19930                    "kind": digest_kind.as_str(),
19931                    "command": shell_command,
19932                    "executed_command": execution.executed_command,
19933                    "exit_code": exit_code,
19934                    "success": output.status.success(),
19935                    "filter": filter_report,
19936                    "artifact": artifact,
19937                    "digest": digest_report,
19938                });
19939                let mut follow_up = artifact
19940                    .as_ref()
19941                    .map(|entry| vec![entry.expand.clone()])
19942                    .unwrap_or_default();
19943                follow_up.push(format!(
19944                    "tsift rewrite --run {}",
19945                    shell_quote(shell_command)
19946                ));
19947                let summary_text = if output.status.success() && digest_report.failures == 0 {
19948                    format!("test run passed for {}", runner.unwrap_or("auto"))
19949                } else {
19950                    format!("test run captured {} failure(s)", digest_report.failures)
19951                };
19952                print_json_or_envelope(
19953                    &report,
19954                    &format,
19955                    "digest-runner",
19956                    "test-run",
19957                    ToolEnvelopeSummary {
19958                        text: summary_text,
19959                        metrics: vec![
19960                            envelope_metric("runner", &digest_report.runner),
19961                            envelope_metric("exit_code", exit_code),
19962                            envelope_metric("filter", execution.filter_label()),
19963                            envelope_metric("failures", digest_report.failures),
19964                            envelope_metric("groups", digest_report.grouped_failures),
19965                            envelope_metric(
19966                                "artifact",
19967                                artifact
19968                                    .as_ref()
19969                                    .map(|entry| entry.handle.as_str())
19970                                    .unwrap_or("-"),
19971                            ),
19972                        ],
19973                    },
19974                    false,
19975                    follow_up,
19976                )?;
19977            }
19978            DigestRunnerKind::Log => {
19979                let digest_report = log_digest::compute(path, &captured)?;
19980                let report = serde_json::json!({
19981                    "kind": digest_kind.as_str(),
19982                    "command": shell_command,
19983                    "executed_command": execution.executed_command,
19984                    "exit_code": exit_code,
19985                    "success": output.status.success(),
19986                    "filter": filter_report,
19987                    "artifact": artifact,
19988                    "digest": digest_report,
19989                });
19990                let mut follow_up = artifact
19991                    .as_ref()
19992                    .map(|entry| vec![entry.expand.clone()])
19993                    .unwrap_or_default();
19994                follow_up.push(format!(
19995                    "tsift rewrite --run {}",
19996                    shell_quote(shell_command)
19997                ));
19998                let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
19999                    "command finished without log signals".to_string()
20000                } else {
20001                    format!(
20002                        "command emitted {} log signal group(s)",
20003                        digest_report.signal_groups
20004                    )
20005                };
20006                print_json_or_envelope(
20007                    &report,
20008                    &format,
20009                    "digest-runner",
20010                    "command-run",
20011                    ToolEnvelopeSummary {
20012                        text: summary_text,
20013                        metrics: vec![
20014                            envelope_metric("exit_code", exit_code),
20015                            envelope_metric("filter", execution.filter_label()),
20016                            envelope_metric("signals", digest_report.signal_groups),
20017                            envelope_metric("file_refs", digest_report.file_ref_groups),
20018                            envelope_metric(
20019                                "artifact",
20020                                artifact
20021                                    .as_ref()
20022                                    .map(|entry| entry.handle.as_str())
20023                                    .unwrap_or("-"),
20024                            ),
20025                        ],
20026                    },
20027                    false,
20028                    follow_up,
20029                )?;
20030            }
20031        }
20032
20033        if output.status.success() {
20034            return Ok(());
20035        }
20036        if let Some(code) = output.status.code() {
20037            std::process::exit(code);
20038        }
20039        bail!("digest-wrapped command terminated by signal: {shell_command}");
20040    }
20041
20042    if captured.trim().is_empty() {
20043        let label = match digest_kind {
20044            DigestRunnerKind::Test => "test",
20045            DigestRunnerKind::Log => "log",
20046        };
20047        println!("No {label} output captured.");
20048    } else {
20049        match digest_kind {
20050            DigestRunnerKind::Test => {
20051                render_test_digest_from_input(path, &captured, runner, format)?
20052            }
20053            DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20054        }
20055    }
20056
20057    if output.status.success() {
20058        return Ok(());
20059    }
20060    if let Some(code) = output.status.code() {
20061        std::process::exit(code);
20062    }
20063    bail!("digest-wrapped command terminated by signal: {shell_command}");
20064}
20065
20066struct DigestRunnerExecution {
20067    output: std::process::Output,
20068    executed_command: String,
20069    filter: Option<DigestRunnerFilter>,
20070}
20071
20072impl DigestRunnerExecution {
20073    fn filter_label(&self) -> &'static str {
20074        self.filter
20075            .as_ref()
20076            .map(|filter| filter.tool)
20077            .unwrap_or("none")
20078    }
20079}
20080
20081struct DigestRunnerFilter {
20082    tool: &'static str,
20083    command: String,
20084}
20085
20086impl DigestRunnerFilter {
20087    fn to_json(&self) -> serde_json::Value {
20088        serde_json::json!({
20089            "tool": self.tool,
20090            "command": self.command,
20091        })
20092    }
20093}
20094
20095fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20096    let filter = rtk_rewrite_for_digest_runner(shell_command);
20097    let executed_command = filter
20098        .as_ref()
20099        .map(|filter| filter.command.as_str())
20100        .unwrap_or(shell_command);
20101    let output = Command::new("sh")
20102        .arg("-lc")
20103        .arg(format!("({executed_command}) 2>&1"))
20104        .stdout(Stdio::piped())
20105        .output()
20106        .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20107
20108    Ok(DigestRunnerExecution {
20109        output,
20110        executed_command: executed_command.to_string(),
20111        filter,
20112    })
20113}
20114
20115fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20116    if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20117        return None;
20118    }
20119    let output = Command::new("rtk")
20120        .arg("rewrite")
20121        .arg(shell_command)
20122        .output()
20123        .ok()?;
20124    if !output.status.success() {
20125        return None;
20126    }
20127    let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20128    if rewritten.is_empty() || rewritten == shell_command {
20129        return None;
20130    }
20131    Some(DigestRunnerFilter {
20132        tool: "rtk",
20133        command: rewritten,
20134    })
20135}
20136
20137fn find_command_on_path(command: &str) -> Option<PathBuf> {
20138    let path_var = std::env::var_os("PATH")?;
20139    std::env::split_paths(&path_var)
20140        .map(|dir| dir.join(command))
20141        .find(|candidate| candidate.is_file())
20142}
20143
20144pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20145    if !db_path.exists() {
20146        bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20147    }
20148    summarize::SummaryDb::open_read_only_resilient(db_path)
20149}
20150
20151fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20152    !matches!(report.index, status::IndexStatus::Fresh { .. })
20153}
20154
20155fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20156    !matches!(report.instructions, init::InstructionStatus::Current { .. })
20157}
20158
20159pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20160    if status_instructions_need_fix(report) {
20161        eprintln!("status fix: refreshing tsift instructions");
20162        init::init(root, false, false)?;
20163    }
20164
20165    let eviction = cycle_packet_cache::cycle_packet_cache_evict(
20166        root,
20167        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
20168        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
20169    );
20170    if eviction.evicted_entries > 0 {
20171        eprintln!(
20172            "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
20173            eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
20174        );
20175    }
20176
20177    if !status_index_needs_fix(report) {
20178        return Ok(());
20179    }
20180
20181    let scopes = config::Config::submodule_dirs(root)?;
20182    if scopes.is_empty() {
20183        eprintln!("status fix: refreshing index");
20184        run_index_update(
20185            &root.join(".tsift/index.db"),
20186            root,
20187            "status --fix refreshing index".to_string(),
20188            root,
20189            None,
20190            false,
20191            false,
20192        )?;
20193        return Ok(());
20194    }
20195
20196    let cfg = config::Config::load(root)?;
20197    for scope in scopes {
20198        if !scope.source_root.exists() {
20199            eprintln!(
20200                "status fix: skipping missing submodule `{}` ({})",
20201                scope.id,
20202                scope.source_root.display()
20203            );
20204            continue;
20205        }
20206        eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20207        run_index_update(
20208            &cfg.db_path_for(root, &scope.id),
20209            &scope.source_root,
20210            format!("status --fix refreshing submodule `{}` index", scope.id),
20211            root,
20212            Some(scope.id.as_str()),
20213            false,
20214            false,
20215        )?;
20216    }
20217
20218    Ok(())
20219}
20220
20221pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20222    match &report.index {
20223        status::IndexStatus::Fresh { missing_scopes, .. }
20224        | status::IndexStatus::Stale { missing_scopes, .. }
20225        | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20226    }
20227}
20228
20229pub(crate) fn autoindex_missing_workspace_scopes(
20230    root: &Path,
20231    report: &status::StatusReport,
20232) -> Result<()> {
20233    let missing_scopes = match &report.index {
20234        status::IndexStatus::Fresh { missing_scopes, .. }
20235        | status::IndexStatus::Stale { missing_scopes, .. }
20236        | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20237    };
20238    if missing_scopes.is_empty() {
20239        return Ok(());
20240    }
20241
20242    let missing_scope_ids = missing_scopes
20243        .iter()
20244        .map(|scope| scope.scope.as_str())
20245        .collect::<std::collections::HashSet<_>>();
20246    let cfg = config::Config::load(root)?;
20247    for scope in config::Config::submodule_dirs(root)? {
20248        if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20249            continue;
20250        }
20251        let db_path = cfg.db_path_for(root, &scope.id);
20252        run_index_update(
20253            &db_path,
20254            &scope.source_root,
20255            format!(
20256                "autoindexing missing submodule `{}` during status",
20257                scope.id
20258            ),
20259            root,
20260            Some(scope.id.as_str()),
20261            false,
20262            false,
20263        )?;
20264    }
20265    Ok(())
20266}
20267
20268pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
20269    for warning in &stats.warnings {
20270        let rel_path = relativize_pathbuf(&warning.path, root);
20271        eprintln!(
20272            "warning: summarize stats {}: {}",
20273            rel_path.display(),
20274            warning.message
20275        );
20276    }
20277}
20278
20279fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
20280    Result::<(), anyhow::Error>::Err(err)
20281        .context(context)
20282        .unwrap_err()
20283}
20284
20285fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
20286    let message = err.to_string();
20287    message.contains("another tsift index writer is already active")
20288        || substrate::error_mentions_locked_db(err)
20289}
20290
20291fn add_write_lock_context(
20292    err: anyhow::Error,
20293    action: String,
20294    root: &std::path::Path,
20295    scope: Option<&str>,
20296) -> anyhow::Error {
20297    if !should_attach_lock_diagnostics(&err) {
20298        return contextualize_error(err, action);
20299    }
20300
20301    let Ok(report) = status::check_locks(root, None, scope) else {
20302        return contextualize_error(err, action);
20303    };
20304
20305    contextualize_error(
20306        err,
20307        format!(
20308            "{}\n\nlock diagnostics:\n{}",
20309            action,
20310            status::format_locks_human(&report, false).trim_end()
20311        ),
20312    )
20313}
20314
20315pub(crate) fn run_index_update(
20316    db_path: &std::path::Path,
20317    source_root: &std::path::Path,
20318    action: String,
20319    root: &std::path::Path,
20320    scope: Option<&str>,
20321    rebuild: bool,
20322    prune: bool,
20323) -> Result<index::IndexSummary> {
20324    let result = (|| {
20325        let db = index::IndexDb::open(db_path)?;
20326        if rebuild {
20327            db.rebuild(source_root)
20328        } else if prune {
20329            db.apply_changes_pruned(source_root)
20330        } else {
20331            db.apply_changes(source_root)
20332        }
20333    })();
20334
20335    let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
20336    emit_index_warnings(&summary, source_root, scope);
20337    Ok(summary)
20338}
20339
20340pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
20341    for change in &mut summary.changes {
20342        change.path = relativize_pathbuf(&change.path, root);
20343    }
20344    for warning in &mut summary.warnings {
20345        warning.path = relativize_pathbuf(&warning.path, root);
20346    }
20347}
20348
20349fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
20350    for warning in &summary.warnings {
20351        let rel_path = relativize_pathbuf(&warning.path, root);
20352        let stage = match warning.stage {
20353            index::IndexWarningStage::ReadSource => "read failed",
20354            index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
20355            index::IndexWarningStage::ExtractCallSites => "call extraction failed",
20356            index::IndexWarningStage::ExtractRoutes => "route extraction failed",
20357        };
20358        let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
20359        let lang_suffix = warning
20360            .language
20361            .as_deref()
20362            .map(|lang| format!(" [{}]", lang))
20363            .unwrap_or_default();
20364        eprintln!(
20365            "warning: {}{}{}: {}: {}",
20366            scope_prefix,
20367            rel_path.display(),
20368            lang_suffix,
20369            stage,
20370            warning.message
20371        );
20372    }
20373}
20374
20375pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
20376    let config_path = root.join(".tsift/config.toml");
20377    if !config_path.exists() {
20378        return summarize::SummarizeConfig::default();
20379    }
20380    #[derive(serde::Deserialize, Default)]
20381    struct RawConfig {
20382        #[serde(default)]
20383        summarize: Option<RawSummarize>,
20384    }
20385    #[derive(serde::Deserialize)]
20386    struct RawSummarize {
20387        model: Option<String>,
20388        max_file_tokens: Option<usize>,
20389        api_key_env: Option<String>,
20390    }
20391    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
20392    let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
20393    let defaults = summarize::SummarizeConfig::default();
20394    match raw.summarize {
20395        Some(s) => summarize::SummarizeConfig {
20396            model: s.model.unwrap_or(defaults.model),
20397            max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
20398            api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
20399        },
20400        None => defaults,
20401    }
20402}
20403
20404#[derive(Debug, Clone, PartialEq, Eq)]
20405struct ExtractSymbolContext {
20406    db_path: PathBuf,
20407    source_root: PathBuf,
20408}
20409
20410pub(crate) fn find_symbols_db_for_file(
20411    root: &Path,
20412    file_path: &Path,
20413) -> Result<Option<ExtractSymbolContext>> {
20414    let cfg = config::Config::load(root)?;
20415    let mut submodules = config::Config::submodule_dirs(root)?;
20416    submodules.sort_by(|left, right| {
20417        right
20418            .source_root
20419            .components()
20420            .count()
20421            .cmp(&left.source_root.components().count())
20422    });
20423
20424    for scope in submodules {
20425        if !file_path.starts_with(&scope.source_root) {
20426            continue;
20427        }
20428        let db_path = cfg.db_path_for(root, &scope.id);
20429        if db_path.exists() {
20430            return Ok(Some(ExtractSymbolContext {
20431                db_path,
20432                source_root: scope.source_root,
20433            }));
20434        }
20435    }
20436
20437    let single = root.join(".tsift/index.db");
20438    if single.exists() && file_path.starts_with(root) {
20439        return Ok(Some(ExtractSymbolContext {
20440            db_path: single,
20441            source_root: root.to_path_buf(),
20442        }));
20443    }
20444
20445    Ok(None)
20446}
20447
20448pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
20449    let canonical = path
20450        .canonicalize()
20451        .with_context(|| format!("canonicalizing {}", path.display()))?;
20452
20453    Ok(if canonical.is_dir() {
20454        canonical
20455    } else {
20456        canonical
20457            .parent()
20458            .map(Path::to_path_buf)
20459            .unwrap_or(canonical)
20460    })
20461}
20462
20463fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
20464    if path.exists() {
20465        return path
20466            .canonicalize()
20467            .with_context(|| format!("canonicalizing extract scope {}", path.display()));
20468    }
20469
20470    Ok(summarize::normalize_lexical_path(path))
20471}
20472
20473pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
20474    let scope = if extract_path.is_absolute() {
20475        extract_path.to_path_buf()
20476    } else {
20477        root.join(extract_path)
20478    };
20479    normalize_extract_scope_path(&scope)
20480}
20481
20482pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
20483    normalize_extract_scope_path(changed_path)
20484        .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
20485        .starts_with(extract_scope)
20486}
20487
20488pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
20489    summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
20490}
20491
20492pub(crate) fn summarize_full_extract_deleted_summary_paths(
20493    summary_db: &summarize::SummaryDb,
20494    root: &Path,
20495    extract_scope: &Path,
20496    files_to_extract: &[PathBuf],
20497) -> Result<BTreeSet<String>> {
20498    let live_paths = files_to_extract
20499        .iter()
20500        .map(|file_path| summarize_relative_file_path(root, file_path))
20501        .collect::<BTreeSet<_>>();
20502    let mut deleted = BTreeSet::new();
20503
20504    for cached_path in summary_db.cached_file_paths()? {
20505        if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
20506            continue;
20507        }
20508        if !live_paths.contains(&cached_path) {
20509            deleted.insert(cached_path);
20510        }
20511    }
20512
20513    Ok(deleted)
20514}
20515
20516#[derive(Debug, Clone)]
20517struct SearchIndexTarget {
20518    label: String,
20519    db_path: PathBuf,
20520    source_root: PathBuf,
20521    scope_name: Option<String>,
20522    reindex_cmd: String,
20523}
20524
20525fn cargo_package_index_target(
20526    root: &Path,
20527    package: multiplicity::CargoPackageInfo,
20528) -> SearchIndexTarget {
20529    SearchIndexTarget {
20530        label: format!("cargo package `{}` index", package.scope_id),
20531        db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
20532        source_root: package.package_root.clone(),
20533        scope_name: Some(package.scope_id.clone()),
20534        reindex_cmd: format!(
20535            "tsift index --submodule {} {}",
20536            package.scope_id,
20537            root.display()
20538        ),
20539    }
20540}
20541
20542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20543enum SearchIndexState {
20544    Missing,
20545    Fresh,
20546    Stale { stale_files: usize },
20547}
20548
20549fn resolve_search_index_targets(
20550    root: &Path,
20551    path_hint: &Path,
20552    scope: Option<&str>,
20553    federated: bool,
20554) -> Result<Vec<SearchIndexTarget>> {
20555    if let Some(scope_name) = scope {
20556        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
20557            let cfg = config::Config::load(root)?;
20558            return Ok(vec![SearchIndexTarget {
20559                label: format!("submodule `{}` index", scope.id),
20560                db_path: cfg.db_path_for(root, &scope.id),
20561                source_root: scope.source_root.clone(),
20562                scope_name: Some(scope.id.clone()),
20563                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20564            }]);
20565        }
20566        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
20567            return Ok(vec![cargo_package_index_target(root, package)]);
20568        }
20569        config::Config::resolve_submodule(root, scope_name)?;
20570    }
20571
20572    if federated {
20573        let cfg = config::Config::load(root)?;
20574        let mut targets = Vec::new();
20575        for scope in config::Config::submodule_dirs(root)? {
20576            if !cfg.federation_for_scope(&scope) {
20577                continue;
20578            }
20579            targets.push(SearchIndexTarget {
20580                label: format!("submodule `{}` index", scope.id),
20581                db_path: cfg.db_path_for(root, &scope.id),
20582                source_root: scope.source_root.clone(),
20583                scope_name: Some(scope.id.clone()),
20584                reindex_cmd: format!("tsift index --workspace {}", root.display()),
20585            });
20586        }
20587        return Ok(targets);
20588    }
20589
20590    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
20591        let cfg = config::Config::load(root)?;
20592        return Ok(vec![SearchIndexTarget {
20593            label: format!("submodule `{}` index", scope.id),
20594            db_path: cfg.db_path_for(root, &scope.id),
20595            source_root: scope.source_root.clone(),
20596            scope_name: Some(scope.id.clone()),
20597            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20598        }]);
20599    }
20600
20601    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
20602        return Ok(vec![cargo_package_index_target(root, package)]);
20603    }
20604
20605    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
20606        let cfg = config::Config::load(root)?;
20607        return Ok(vec![SearchIndexTarget {
20608            label: format!("submodule `{}` index", scope.id),
20609            db_path: cfg.db_path_for(root, &scope.id),
20610            source_root: scope.source_root.clone(),
20611            scope_name: Some(scope.id.clone()),
20612            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20613        }]);
20614    }
20615
20616    let scopes = config::Config::submodule_dirs(root)?;
20617    if !scopes.is_empty() {
20618        let root_db = root.join(".tsift/index.db");
20619        if !root_db.exists() {
20620            let available_scopes = scopes
20621                .iter()
20622                .map(|scope| scope.id.as_str())
20623                .collect::<Vec<_>>()
20624                .join(", ");
20625            let cfg = config::Config::load(root)?;
20626            let indexed_scopes = scopes
20627                .iter()
20628                .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
20629                .map(|scope| scope.id.as_str())
20630                .collect::<Vec<_>>();
20631            let indexed_label = if indexed_scopes.is_empty() {
20632                "none".to_string()
20633            } else {
20634                indexed_scopes.join(", ")
20635            };
20636            bail!(
20637                "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: {}.",
20638                root.display(),
20639                root_db.display(),
20640                available_scopes,
20641                indexed_label,
20642            );
20643        }
20644    }
20645
20646    Ok(vec![SearchIndexTarget {
20647        label: "index".to_string(),
20648        db_path: root.join(".tsift/index.db"),
20649        source_root: root.to_path_buf(),
20650        scope_name: None,
20651        reindex_cmd: format!("tsift index {}", root.display()),
20652    }])
20653}
20654
20655fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
20656    if !target.source_root.exists() || !target.db_path.exists() {
20657        return Ok(SearchIndexState::Missing);
20658    }
20659
20660    let inspection =
20661        index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
20662    let stale_files =
20663        inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
20664    if stale_files == 0 {
20665        Ok(SearchIndexState::Fresh)
20666    } else {
20667        Ok(SearchIndexState::Stale { stale_files })
20668    }
20669}
20670
20671#[derive(Debug, Clone, PartialEq, Eq)]
20672struct RebuildSearchTarget {
20673    label: String,
20674    reason: RebuildSearchReason,
20675    reindex_cmd: String,
20676}
20677
20678#[derive(Debug, Clone, PartialEq, Eq)]
20679enum RebuildSearchReason {
20680    Missing,
20681    Stale { stale_files: usize },
20682}
20683
20684#[derive(Debug, Clone, PartialEq, Eq)]
20685struct DegradedSearchTarget {
20686    label: String,
20687    reason: RebuildSearchReason,
20688    reindex_cmd: String,
20689}
20690
20691#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20692pub(crate) enum DegradedSearchMode {
20693    ReadOnly,
20694    Exact,
20695}
20696
20697#[derive(Debug)]
20698struct SearchPrecheck {
20699    targets: Vec<SearchIndexTarget>,
20700    degraded_targets: Vec<DegradedSearchTarget>,
20701}
20702
20703fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
20704    err.chain().any(|cause| {
20705        cause
20706            .to_string()
20707            .contains("another tsift index writer is already active")
20708    })
20709}
20710
20711fn infer_agent_doc_task_submodule(
20712    root: &Path,
20713    path_hint: &Path,
20714) -> Result<Option<config::WorkspaceScope>> {
20715    let hinted_path = if path_hint.is_absolute() {
20716        path_hint.to_path_buf()
20717    } else {
20718        root.join(path_hint)
20719    };
20720    let Ok(relative) = hinted_path.strip_prefix(root) else {
20721        return Ok(None);
20722    };
20723    let mut components = relative.components();
20724    let Some(std::path::Component::Normal(first)) = components.next() else {
20725        return Ok(None);
20726    };
20727    if first != "tasks" {
20728        return Ok(None);
20729    }
20730    let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
20731        return Ok(None);
20732    };
20733    config::Config::find_submodule(root, file_stem)
20734}
20735
20736fn degraded_search_target(
20737    target: &SearchIndexTarget,
20738    reason: RebuildSearchReason,
20739) -> DegradedSearchTarget {
20740    DegradedSearchTarget {
20741        label: target.label.clone(),
20742        reason,
20743        reindex_cmd: target.reindex_cmd.clone(),
20744    }
20745}
20746
20747fn apply_search_index_update(
20748    root: &Path,
20749    target: &SearchIndexTarget,
20750) -> Result<index::IndexSummary> {
20751    run_index_update(
20752        &target.db_path,
20753        &target.source_root,
20754        format!("autoindexing {}", target.label),
20755        root,
20756        target.scope_name.as_deref(),
20757        false,
20758        false,
20759    )
20760}
20761
20762fn collect_rebuild_search_targets(
20763    targets: &[SearchIndexTarget],
20764) -> Result<Vec<RebuildSearchTarget>> {
20765    let mut rebuild_targets = Vec::new();
20766    for target in targets {
20767        let reason = match inspect_search_index(target)? {
20768            SearchIndexState::Missing => RebuildSearchReason::Missing,
20769            SearchIndexState::Fresh => continue,
20770            SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
20771        };
20772        rebuild_targets.push(RebuildSearchTarget {
20773            label: target.label.clone(),
20774            reason,
20775            reindex_cmd: target.reindex_cmd.clone(),
20776        });
20777    }
20778    Ok(rebuild_targets)
20779}
20780
20781fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
20782    match target.reason {
20783        RebuildSearchReason::Missing => format!("{} is missing", target.label),
20784        RebuildSearchReason::Stale { stale_files } => {
20785            let file_suffix = if stale_files == 1 { "" } else { "s" };
20786            format!(
20787                "{} is stale ({} file{})",
20788                target.label, stale_files, file_suffix
20789            )
20790        }
20791    }
20792}
20793
20794fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
20795    if rebuild_targets.len() == 1 {
20796        let target = &rebuild_targets[0];
20797        return format!(
20798            "{}. Run `{}` to rebuild before retrying.",
20799            rebuild_search_target_detail(target),
20800            target.reindex_cmd
20801        );
20802    }
20803
20804    let summary: Vec<String> = rebuild_targets
20805        .iter()
20806        .take(3)
20807        .map(rebuild_search_target_detail)
20808        .collect();
20809    let overflow = rebuild_targets.len().saturating_sub(summary.len());
20810    let mut details = summary.join(", ");
20811    if overflow > 0 {
20812        details.push_str(&format!(", +{} more", overflow));
20813    }
20814    let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
20815    format!(
20816        "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
20817        rebuild_targets.len(),
20818        details,
20819        reindex_cmd
20820    )
20821}
20822
20823pub(crate) fn precheck_search_indexes(
20824    root: &Path,
20825    path_hint: &Path,
20826    scope: Option<&str>,
20827    federated: bool,
20828    autoindex: bool,
20829) -> Result<SearchPrecheck> {
20830    let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
20831    let mut stale_targets = Vec::new();
20832    let mut degraded_targets = Vec::new();
20833
20834    for target in &targets {
20835        match inspect_search_index(target)? {
20836            SearchIndexState::Missing => {
20837                if autoindex && let Err(err) = apply_search_index_update(root, target) {
20838                    if is_active_writer_lock_error(&err) {
20839                        degraded_targets
20840                            .push(degraded_search_target(target, RebuildSearchReason::Missing));
20841                    } else {
20842                        return Err(err);
20843                    }
20844                }
20845            }
20846            SearchIndexState::Fresh => {}
20847            SearchIndexState::Stale { stale_files } => {
20848                if autoindex {
20849                    if let Err(err) = apply_search_index_update(root, target) {
20850                        if is_active_writer_lock_error(&err) {
20851                            degraded_targets.push(degraded_search_target(
20852                                target,
20853                                RebuildSearchReason::Stale { stale_files },
20854                            ));
20855                        } else {
20856                            return Err(err);
20857                        }
20858                    }
20859                } else {
20860                    stale_targets.push(RebuildSearchTarget {
20861                        label: target.label.clone(),
20862                        reason: RebuildSearchReason::Stale { stale_files },
20863                        reindex_cmd: target.reindex_cmd.clone(),
20864                    });
20865                }
20866            }
20867        }
20868    }
20869
20870    if stale_targets.is_empty() {
20871        return Ok(SearchPrecheck {
20872            targets,
20873            degraded_targets,
20874        });
20875    }
20876
20877    bail!(
20878        "tsift search aborted: {} \
20879         or re-run without `--no-autoindex`.",
20880        rebuild_search_targets_message(&stale_targets),
20881    );
20882}
20883
20884pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
20885    if targets.is_empty() {
20886        return None;
20887    }
20888
20889    if targets
20890        .iter()
20891        .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
20892    {
20893        Some(DegradedSearchMode::Exact)
20894    } else {
20895        Some(DegradedSearchMode::ReadOnly)
20896    }
20897}
20898
20899fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
20900    if targets.len() == 1 {
20901        let target = &targets[0];
20902        return match target.reason {
20903            RebuildSearchReason::Missing => format!("{} is missing", target.label),
20904            RebuildSearchReason::Stale { stale_files } => {
20905                let file_suffix = if stale_files == 1 { "" } else { "s" };
20906                format!(
20907                    "{} is stale ({} file{})",
20908                    target.label, stale_files, file_suffix
20909                )
20910            }
20911        };
20912    }
20913
20914    let missing = targets
20915        .iter()
20916        .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
20917        .count();
20918    let stale = targets.len().saturating_sub(missing);
20919    let mut parts = Vec::new();
20920    if stale > 0 {
20921        let suffix = if stale == 1 { "" } else { "es" };
20922        parts.push(format!("{stale} stale index{suffix}"));
20923    }
20924    if missing > 0 {
20925        let suffix = if missing == 1 { "" } else { "es" };
20926        parts.push(format!("{missing} missing index{suffix}"));
20927    }
20928    parts.join(", ")
20929}
20930
20931pub(crate) fn emit_degraded_search_note(
20932    targets: &[DegradedSearchTarget],
20933    mode: DegradedSearchMode,
20934) {
20935    let summary = degraded_search_targets_summary(targets);
20936    let reindex_cmd = &targets[0].reindex_cmd;
20937    match mode {
20938        DegradedSearchMode::ReadOnly => eprintln!(
20939            "note: active tsift writer detected; skipping autoindex because {}. \
20940             Continuing with read-only search and the current index snapshot; symbol hits may lag. \
20941             Retry `{}` after the active writer finishes for fresh index results.",
20942            summary, reindex_cmd
20943        ),
20944        DegradedSearchMode::Exact => eprintln!(
20945            "note: active tsift writer detected; skipping autoindex because {}. \
20946             Continuing with exact live-file search. Retry `{}` after the active writer finishes \
20947             for indexed symbol hits.",
20948            summary, reindex_cmd
20949        ),
20950    }
20951}
20952
20953fn search_timeout_message(
20954    timeout_secs: u64,
20955    strategy: &str,
20956    targets: &[SearchIndexTarget],
20957) -> Result<String> {
20958    let rebuild_targets = collect_rebuild_search_targets(targets)?;
20959    if rebuild_targets.is_empty() {
20960        return Ok(format!(
20961            "tsift search timed out after {}s (strategy: {}). \
20962             The search root looks fresh, so reindexing is unlikely to help. \
20963             Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
20964             or try a different strategy.",
20965            timeout_secs, strategy,
20966        ));
20967    }
20968
20969    Ok(format!(
20970        "tsift search timed out after {}s (strategy: {}). {}",
20971        timeout_secs,
20972        strategy,
20973        rebuild_search_targets_message(&rebuild_targets),
20974    ))
20975}
20976
20977fn is_exact_preferring_query_char(ch: char) -> bool {
20978    matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
20979}
20980
20981fn query_prefers_exact_search(query: &str) -> bool {
20982    let trimmed = query.trim();
20983    !trimmed.is_empty()
20984        && !trimmed.chars().any(char::is_whitespace)
20985        && trimmed.chars().any(|ch| ch.is_alphanumeric())
20986        && trimmed.chars().any(is_exact_preferring_query_char)
20987        && trimmed
20988            .chars()
20989            .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
20990}
20991
20992pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
20993    strategy.unwrap_or_else(|| {
20994        if query_prefers_exact_search(query) {
20995            "exact".to_string()
20996        } else {
20997            "lexical".to_string()
20998        }
20999    })
21000}
21001
21002pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21003    let mut files = Vec::new();
21004    if path.is_file() {
21005        files.push(path.to_path_buf());
21006        return Ok(files);
21007    }
21008    let walker = ignore::WalkBuilder::new(path)
21009        .hidden(true)
21010        .git_ignore(true)
21011        .build();
21012    for entry in walker {
21013        let entry = entry?;
21014        if entry.file_type().is_some_and(|ft| ft.is_file()) {
21015            let p = entry.path();
21016            if let Some(ext) = p.extension() {
21017                let ext = ext.to_string_lossy();
21018                if matches!(
21019                    ext.as_ref(),
21020                    "rs" | "py"
21021                        | "ts"
21022                        | "tsx"
21023                        | "js"
21024                        | "jsx"
21025                        | "kt"
21026                        | "kts"
21027                        | "zig"
21028                        | "sh"
21029                        | "bash"
21030                        | "zsh"
21031                ) {
21032                    files.push(p.to_path_buf());
21033                }
21034            }
21035        }
21036    }
21037    Ok(files)
21038}
21039
21040#[cfg(test)]
21041mod tests {
21042    use super::semantic_edit::{
21043        EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21044        markdown_section_spans,
21045    };
21046    use super::*;
21047    use tsift_memory::{MemoryEventKind, MemoryStore};
21048
21049    use std::cell::RefCell;
21050    use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21051    fn parse_cli<I, T>(itr: I) -> Cli
21052    where
21053        I: IntoIterator<Item = T> + Send + 'static,
21054        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21055    {
21056        std::thread::Builder::new()
21057            .name("cli-parse".to_string())
21058            .stack_size(16 * 1024 * 1024)
21059            .spawn(move || Cli::parse_from(itr))
21060            .unwrap()
21061            .join()
21062            .unwrap()
21063    }
21064
21065    fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21066    where
21067        I: IntoIterator<Item = T> + Send + 'static,
21068        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21069    {
21070        std::thread::Builder::new()
21071            .name("cli-try-parse".to_string())
21072            .stack_size(16 * 1024 * 1024)
21073            .spawn(move || Cli::try_parse_from(itr))
21074            .unwrap()
21075            .join()
21076            .unwrap()
21077    }
21078
21079    fn build_relative_search_budget_report(
21080        query: &str,
21081        strategy: &str,
21082        root: &Path,
21083        response: &sift::SearchResponse,
21084        symbol_hits: &[index::SymbolHit],
21085        budget: ResponseBudget,
21086        filters: &SearchFacetFilters,
21087    ) -> SearchBudgetReport {
21088        build_search_budget_report(SearchBudgetReportInput {
21089            query,
21090            strategy,
21091            root,
21092            response,
21093            symbol_hits,
21094            absolute: false,
21095            budget,
21096            filters,
21097        })
21098    }
21099
21100    #[derive(Default)]
21101    struct MemoryConvexGraphClient {
21102        nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
21103        edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
21104    }
21105
21106    impl ConvexGraphClient for MemoryConvexGraphClient {
21107        fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
21108            self.nodes
21109                .borrow_mut()
21110                .insert(row.external_id.clone(), row.clone());
21111            Ok(())
21112        }
21113
21114        fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
21115            self.edges
21116                .borrow_mut()
21117                .insert(row.edge_key.clone(), row.clone());
21118            Ok(())
21119        }
21120
21121        fn delete_node_row(&self, external_id: &str) -> Result<usize> {
21122            Ok(usize::from(
21123                self.nodes.borrow_mut().remove(external_id).is_some(),
21124            ))
21125        }
21126
21127        fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
21128            Ok(usize::from(
21129                self.edges.borrow_mut().remove(edge_key).is_some(),
21130            ))
21131        }
21132
21133        fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
21134            Ok(self.nodes.borrow().get(external_id).cloned())
21135        }
21136
21137        fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
21138            Ok(self.nodes.borrow().values().cloned().collect())
21139        }
21140
21141        fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
21142            Ok(self.edges.borrow().values().cloned().collect())
21143        }
21144
21145        fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
21146            Ok(self
21147                .nodes
21148                .borrow()
21149                .values()
21150                .filter(|row| row.kind == kind)
21151                .cloned()
21152                .collect())
21153        }
21154
21155        fn outgoing_edge_rows(
21156            &self,
21157            from_external_id: &str,
21158            kind: Option<&str>,
21159        ) -> Result<Vec<ConvexEdgeRow>> {
21160            Ok(self
21161                .edges
21162                .borrow()
21163                .values()
21164                .filter(|row| row.from_external_id == from_external_id)
21165                .filter(|row| kind.is_none_or(|kind| row.kind == kind))
21166                .cloned()
21167                .collect())
21168        }
21169    }
21170
21171    fn init_git_repo(path: &Path) {
21172        let status = std::process::Command::new("git")
21173            .args(["init"])
21174            .current_dir(path)
21175            .status()
21176            .unwrap();
21177        assert!(status.success(), "git init failed");
21178
21179        let status = std::process::Command::new("git")
21180            .args(["add", "."])
21181            .current_dir(path)
21182            .status()
21183            .unwrap();
21184        assert!(status.success(), "git add failed");
21185
21186        let status = std::process::Command::new("git")
21187            .args([
21188                "-c",
21189                "user.name=tsift-tests",
21190                "-c",
21191                "user.email=tsift-tests@example.com",
21192                "commit",
21193                "--quiet",
21194                "-m",
21195                "init",
21196            ])
21197            .current_dir(path)
21198            .status()
21199            .unwrap();
21200        assert!(status.success(), "git commit failed");
21201    }
21202
21203    fn write_empty_root_index(root: &Path) {
21204        let index_dir = root.join(".tsift");
21205        fs::create_dir_all(&index_dir).unwrap();
21206        fs::write(index_dir.join("index.db"), "").unwrap();
21207    }
21208
21209    fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
21210        if let Some(parent) = path.parent() {
21211            fs::create_dir_all(parent).unwrap();
21212        }
21213        let body = std::iter::repeat_n(line, lines)
21214            .collect::<Vec<_>>()
21215            .join("\n");
21216        fs::write(path, format!("{body}\n")).unwrap();
21217        path.to_path_buf()
21218    }
21219
21220    // --- build_token_capped_preview ---
21221
21222    #[test]
21223    fn token_capped_preview_returns_all_lines_when_under_cap() {
21224        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 1", "}"];
21225        let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
21226        assert!(!result.was_capped);
21227        assert_eq!(result.preview.len(), 3);
21228        assert_eq!(result.capped_end, 3);
21229    }
21230
21231    #[test]
21232    fn token_capped_preview_truncates_when_over_cap() {
21233        let lines: Vec<&str> = (0..200)
21234            .map(|_| "    let x = some_very_long_expression_here();")
21235            .collect();
21236        let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
21237        assert!(result.was_capped);
21238        assert!(result.preview.len() < 200);
21239        assert!(result.capped_end < 200);
21240    }
21241
21242    #[test]
21243    fn token_capped_preview_keeps_at_least_one_line() {
21244        let long_line: String = "x".repeat(8000);
21245        let lines: Vec<&str> = vec![&long_line];
21246        let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
21247        assert!(!result.was_capped);
21248        assert_eq!(result.preview.len(), 1);
21249    }
21250
21251    #[test]
21252    fn token_capped_preview_cap_at_boundary() {
21253        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21254        let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
21255        assert!(!result.was_capped);
21256        assert_eq!(result.preview.len(), 4);
21257    }
21258
21259    #[test]
21260    fn token_capped_preview_cap_just_over_boundary() {
21261        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21262        let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
21263        assert!(result.was_capped);
21264        assert_eq!(result.preview.len(), 3);
21265        assert_eq!(result.capped_end, 3);
21266    }
21267
21268    #[test]
21269    fn token_capped_preview_empty_lines() {
21270        let lines: Vec<&str> = vec![];
21271        let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
21272        assert!(!result.was_capped);
21273        assert!(result.preview.is_empty());
21274    }
21275
21276    #[test]
21277    fn token_capped_preview_per_line_truncation_applied() {
21278        let long_line = "x".repeat(500);
21279        let lines: Vec<&str> = vec![&long_line, "short"];
21280        let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
21281        assert!(!result.was_capped);
21282        assert_eq!(result.preview.len(), 2);
21283        assert!(result.preview[0].text.len() <= 23);
21284        assert!(result.preview[0].text.ends_with("..."));
21285    }
21286
21287    // --- classify_task ---
21288
21289    #[test]
21290    fn route_search_defaults_to_haiku() {
21291        let (tier, model) = classify_task("find all uses of authenticate");
21292        assert_eq!(tier, "haiku");
21293        assert!(
21294            model.contains("haiku"),
21295            "expected haiku model, got {}",
21296            model
21297        );
21298    }
21299
21300    #[test]
21301    fn route_edit_keywords_to_sonnet() {
21302        for kw in &[
21303            "edit the file",
21304            "fix the bug",
21305            "update the config",
21306            "remove dead code",
21307            "create a new module",
21308        ] {
21309            let (tier, _) = classify_task(kw);
21310            assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
21311        }
21312    }
21313
21314    #[test]
21315    fn route_architecture_keywords_to_opus() {
21316        for kw in &[
21317            "design the API",
21318            "architecture review",
21319            "plan the migration",
21320            "analyze the system",
21321            "evaluate trade-offs",
21322        ] {
21323            let (tier, _) = classify_task(kw);
21324            assert_eq!(tier, "opus", "expected opus for {:?}", kw);
21325        }
21326    }
21327
21328    #[test]
21329    fn route_architecture_beats_edit() {
21330        // "design and implement" — architecture signal wins (checked first)
21331        let (tier, _) = classify_task("design and implement the new auth service");
21332        assert_eq!(tier, "opus");
21333    }
21334
21335    #[test]
21336    fn cli_accepts_global_compact_flag() {
21337        let cli = parse_cli(["tsift", "--compact", "status"]);
21338        assert!(cli.compact);
21339        assert!(matches!(cli.command, Some(Commands::Status { .. })));
21340    }
21341
21342    #[test]
21343    fn summarize_diff_scope_matches_relative_directory() {
21344        let root = Path::new("/repo");
21345        let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
21346
21347        assert!(summarize_diff_matches_scope(
21348            Path::new("/repo/src/feature/main.rs"),
21349            &extract_scope
21350        ));
21351        assert!(!summarize_diff_matches_scope(
21352            Path::new("/repo/src/other/main.rs"),
21353            &extract_scope
21354        ));
21355    }
21356
21357    #[test]
21358    fn summarize_diff_scope_matches_relative_file() {
21359        let root = Path::new("/repo");
21360        let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
21361
21362        assert!(summarize_diff_matches_scope(
21363            Path::new("/repo/src/feature/main.rs"),
21364            &extract_scope
21365        ));
21366        assert!(!summarize_diff_matches_scope(
21367            Path::new("/repo/src/feature/lib.rs"),
21368            &extract_scope
21369        ));
21370    }
21371
21372    #[test]
21373    fn summarize_extract_scope_walks_relative_paths_from_root() {
21374        let dir = tempfile::tempdir().unwrap();
21375        let source_dir = dir.path().join("src");
21376        std::fs::create_dir_all(&source_dir).unwrap();
21377        let main_rs = source_dir.join("main.rs");
21378        std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
21379
21380        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
21381        let files = collect_source_files(&extract_scope).unwrap();
21382
21383        assert_eq!(files, vec![main_rs]);
21384    }
21385
21386    #[test]
21387    fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
21388        let dir = tempfile::tempdir().unwrap();
21389        let nested = dir.path().join("src/nested");
21390        std::fs::create_dir_all(&nested).unwrap();
21391        std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
21392        let nested_file = nested.join("main.rs");
21393        std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
21394
21395        let extract_base = resolve_extract_base(&nested).unwrap();
21396        let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
21397        let files = collect_source_files(&extract_scope).unwrap();
21398
21399        assert_eq!(extract_scope, nested);
21400        assert_eq!(files, vec![nested_file]);
21401    }
21402
21403    #[test]
21404    fn summarize_extract_base_uses_parent_of_file_path() {
21405        let dir = tempfile::tempdir().unwrap();
21406        let nested = dir.path().join("src/nested");
21407        std::fs::create_dir_all(&nested).unwrap();
21408        let file_path = nested.join("main.rs");
21409        std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
21410
21411        let extract_base = resolve_extract_base(&file_path).unwrap();
21412
21413        assert_eq!(extract_base, nested);
21414    }
21415
21416    #[test]
21417    fn summarize_extract_scope_normalizes_dotdot_segments() {
21418        let dir = tempfile::tempdir().unwrap();
21419        let source_dir = dir.path().join("src");
21420        std::fs::create_dir_all(&source_dir).unwrap();
21421
21422        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
21423
21424        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21425        assert!(summarize_diff_matches_scope(
21426            &source_dir.join("main.rs"),
21427            &extract_scope
21428        ));
21429    }
21430
21431    #[cfg(unix)]
21432    #[test]
21433    fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
21434        use std::os::unix::fs::symlink;
21435
21436        let dir = tempfile::tempdir().unwrap();
21437        let real_root = dir.path().join("real");
21438        let source_dir = real_root.join("src");
21439        std::fs::create_dir_all(&source_dir).unwrap();
21440        let symlink_scope = dir.path().join("scope-link");
21441        symlink(&source_dir, &symlink_scope).unwrap();
21442
21443        let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
21444
21445        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21446        assert!(summarize_diff_matches_scope(
21447            &source_dir.join("lib.rs"),
21448            &extract_scope
21449        ));
21450    }
21451
21452    #[test]
21453    fn summarize_diff_extract_includes_untracked_files() {
21454        let dir = tempfile::tempdir().unwrap();
21455        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21456        init_git_repo(dir.path());
21457
21458        let source_dir = dir.path().join("src");
21459        std::fs::create_dir_all(&source_dir).unwrap();
21460        let new_file = source_dir.join("new.rs");
21461        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21462
21463        let files = summarize::git_changed_files(dir.path()).unwrap();
21464
21465        assert_eq!(files.existing, vec![new_file]);
21466        assert!(files.deleted.is_empty());
21467    }
21468
21469    #[test]
21470    fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
21471        let dir = tempfile::tempdir().unwrap();
21472        let status = std::process::Command::new("git")
21473            .args(["init"])
21474            .current_dir(dir.path())
21475            .status()
21476            .unwrap();
21477        assert!(status.success(), "git init failed");
21478
21479        let source_dir = dir.path().join("src");
21480        std::fs::create_dir_all(&source_dir).unwrap();
21481        let new_file = source_dir.join("new.rs");
21482        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21483
21484        let files = summarize::git_changed_files(dir.path()).unwrap();
21485
21486        assert_eq!(files.existing, vec![new_file]);
21487        assert!(files.deleted.is_empty());
21488    }
21489
21490    #[test]
21491    fn summarize_diff_extract_tracks_deleted_files() {
21492        let dir = tempfile::tempdir().unwrap();
21493        let source_dir = dir.path().join("src");
21494        std::fs::create_dir_all(&source_dir).unwrap();
21495        let deleted_file = source_dir.join("gone.rs");
21496        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21497        init_git_repo(dir.path());
21498
21499        std::fs::remove_file(&deleted_file).unwrap();
21500
21501        let files = summarize::git_changed_files(dir.path()).unwrap();
21502
21503        assert!(files.existing.is_empty());
21504        assert_eq!(files.deleted, vec![deleted_file]);
21505    }
21506
21507    #[test]
21508    fn summarize_diff_extract_tracks_git_renames() {
21509        let dir = tempfile::tempdir().unwrap();
21510        let source_dir = dir.path().join("src");
21511        std::fs::create_dir_all(&source_dir).unwrap();
21512        let old_file = source_dir.join("old.rs");
21513        let new_file = source_dir.join("new.rs");
21514        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21515        init_git_repo(dir.path());
21516
21517        let status = std::process::Command::new("git")
21518            .args(["mv", "src/old.rs", "src/new.rs"])
21519            .current_dir(dir.path())
21520            .status()
21521            .unwrap();
21522        assert!(status.success(), "git mv failed");
21523
21524        let files = summarize::git_changed_files(dir.path()).unwrap();
21525
21526        assert_eq!(files.existing, vec![new_file]);
21527        assert_eq!(files.deleted, vec![old_file]);
21528    }
21529
21530    #[test]
21531    fn summarize_diff_extract_deletes_removed_summary_rows() {
21532        let dir = tempfile::tempdir().unwrap();
21533        let source_dir = dir.path().join("src");
21534        std::fs::create_dir_all(&source_dir).unwrap();
21535        let deleted_file = source_dir.join("gone.rs");
21536        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21537        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21538        init_git_repo(dir.path());
21539
21540        let summary_db =
21541            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21542        summary_db
21543            .insert(&summarize::Summary {
21544                id: 0,
21545                symbol_name: "stale".to_string(),
21546                file_path: "src/gone.rs".to_string(),
21547                content_hash: "hash1".to_string(),
21548                summary: "stale summary".to_string(),
21549                entities: None,
21550                relationships: None,
21551                concept_labels: None,
21552                extracted_at: "1700000000".to_string(),
21553                model: "test".to_string(),
21554                tokens_input: Some(100),
21555                tokens_output: Some(50),
21556            })
21557            .unwrap();
21558
21559        std::fs::remove_file(&deleted_file).unwrap();
21560
21561        cmd_summarize(
21562            None,
21563            None,
21564            Some(PathBuf::from("src")),
21565            true,
21566            false,
21567            dir.path(),
21568            false,
21569            true,
21570            false,
21571            false,
21572            false,
21573        )
21574        .unwrap();
21575
21576        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21577    }
21578
21579    #[test]
21580    fn summarize_diff_extract_deletes_renamed_summary_rows() {
21581        let dir = tempfile::tempdir().unwrap();
21582        let source_dir = dir.path().join("src");
21583        std::fs::create_dir_all(&source_dir).unwrap();
21584        let old_file = source_dir.join("old.rs");
21585        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21586        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21587        init_git_repo(dir.path());
21588
21589        let summary_db =
21590            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21591        summary_db
21592            .insert(&summarize::Summary {
21593                id: 0,
21594                symbol_name: "stale".to_string(),
21595                file_path: "src/old.rs".to_string(),
21596                content_hash: "hash1".to_string(),
21597                summary: "stale summary".to_string(),
21598                entities: None,
21599                relationships: None,
21600                concept_labels: None,
21601                extracted_at: "1700000000".to_string(),
21602                model: "test".to_string(),
21603                tokens_input: Some(100),
21604                tokens_output: Some(50),
21605            })
21606            .unwrap();
21607
21608        let status = std::process::Command::new("git")
21609            .args(["mv", "src/old.rs", "src/new.rs"])
21610            .current_dir(dir.path())
21611            .status()
21612            .unwrap();
21613        assert!(status.success(), "git mv failed");
21614
21615        cmd_summarize(
21616            None,
21617            None,
21618            Some(PathBuf::from("src")),
21619            true,
21620            false,
21621            dir.path(),
21622            false,
21623            true,
21624            false,
21625            false,
21626            false,
21627        )
21628        .unwrap();
21629
21630        assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
21631    }
21632
21633    #[test]
21634    fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
21635        let dir = tempfile::tempdir().unwrap();
21636        let source_dir = dir.path().join("src");
21637        std::fs::create_dir_all(&source_dir).unwrap();
21638        let deleted_file = source_dir.join("gone.rs");
21639        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21640
21641        let summary_db =
21642            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21643        summary_db
21644            .insert(&summarize::Summary {
21645                id: 0,
21646                symbol_name: "stale".to_string(),
21647                file_path: "src/gone.rs".to_string(),
21648                content_hash: "hash1".to_string(),
21649                summary: "stale summary".to_string(),
21650                entities: None,
21651                relationships: None,
21652                concept_labels: None,
21653                extracted_at: "1700000000".to_string(),
21654                model: "test".to_string(),
21655                tokens_input: Some(100),
21656                tokens_output: Some(50),
21657            })
21658            .unwrap();
21659
21660        std::fs::remove_file(&deleted_file).unwrap();
21661
21662        cmd_summarize(
21663            None,
21664            None,
21665            Some(PathBuf::from("src")),
21666            false,
21667            false,
21668            dir.path(),
21669            false,
21670            true,
21671            false,
21672            false,
21673            false,
21674        )
21675        .unwrap();
21676
21677        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21678    }
21679
21680    #[test]
21681    fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
21682        let dir = tempfile::tempdir().unwrap();
21683        let source_dir = dir.path().join("src");
21684        std::fs::create_dir_all(&source_dir).unwrap();
21685        let file = source_dir.join("lib.rs");
21686        std::fs::write(&file, "fn helper() {}\n").unwrap();
21687
21688        let content = std::fs::read(&file).unwrap();
21689        let summary_db =
21690            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21691        summary_db
21692            .insert(&summarize::Summary {
21693                id: 0,
21694                symbol_name: "lib.rs".to_string(),
21695                file_path: "src/lib.rs".to_string(),
21696                content_hash: summarize::content_hash(&content),
21697                summary: "cached summary".to_string(),
21698                entities: None,
21699                relationships: None,
21700                concept_labels: None,
21701                extracted_at: "1700000000".to_string(),
21702                model: "test".to_string(),
21703                tokens_input: Some(100),
21704                tokens_output: Some(50),
21705            })
21706            .unwrap();
21707        drop(summary_db);
21708
21709        let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
21710        let _lock = hold_writer_lock(&lock_path);
21711
21712        let err = cmd_summarize(
21713            None,
21714            None,
21715            Some(PathBuf::from("src")),
21716            false,
21717            false,
21718            dir.path(),
21719            false,
21720            true,
21721            false,
21722            false,
21723            false,
21724        )
21725        .unwrap_err();
21726        let message = err.to_string();
21727
21728        assert!(message.contains("another tsift summarize extractor is already active"));
21729        assert!(message.contains("tsift summarize --extract"));
21730    }
21731
21732    #[test]
21733    fn summarize_stats_fails_closed_when_cache_missing() {
21734        let dir = tempfile::tempdir().unwrap();
21735        let err = cmd_summarize(
21736            None,
21737            None,
21738            None,
21739            false,
21740            true,
21741            dir.path(),
21742            false,
21743            false,
21744            false,
21745            false,
21746            false,
21747        )
21748        .unwrap_err();
21749
21750        assert!(
21751            err.to_string().contains("no summaries.db found"),
21752            "got: {err}"
21753        );
21754        assert!(!dir.path().join(".tsift/summaries.db").exists());
21755    }
21756
21757    #[test]
21758    fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21759        let dir = tempfile::tempdir().unwrap();
21760        let summary_db =
21761            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21762        summary_db
21763            .insert(&summarize::Summary {
21764                id: 0,
21765                symbol_name: "alpha_helper".to_string(),
21766                file_path: "src/lib.rs".to_string(),
21767                content_hash: "hash1".to_string(),
21768                summary: "cached summary".to_string(),
21769                entities: None,
21770                relationships: None,
21771                concept_labels: None,
21772                extracted_at: "1700000000".to_string(),
21773                model: "claude-haiku-4-5-20251001".to_string(),
21774                tokens_input: Some(100),
21775                tokens_output: Some(40),
21776            })
21777            .unwrap();
21778        drop(summary_db);
21779        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21780
21781        let result = cmd_summarize(
21782            None,
21783            None,
21784            None,
21785            false,
21786            true,
21787            dir.path(),
21788            false,
21789            false,
21790            false,
21791            false,
21792            false,
21793        );
21794
21795        assert!(result.is_ok());
21796    }
21797
21798    #[test]
21799    fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21800        let dir = tempfile::tempdir().unwrap();
21801        let summary_db =
21802            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21803        summary_db
21804            .insert(&summarize::Summary {
21805                id: 0,
21806                symbol_name: "alpha_helper".to_string(),
21807                file_path: "src/lib.rs".to_string(),
21808                content_hash: "hash1".to_string(),
21809                summary: "cached summary".to_string(),
21810                entities: None,
21811                relationships: None,
21812                concept_labels: None,
21813                extracted_at: "1700000000".to_string(),
21814                model: "claude-haiku-4-5-20251001".to_string(),
21815                tokens_input: Some(100),
21816                tokens_output: Some(40),
21817            })
21818            .unwrap();
21819        drop(summary_db);
21820        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21821
21822        let result = cmd_summarize(
21823            Some("alpha_helper".to_string()),
21824            None,
21825            None,
21826            false,
21827            false,
21828            dir.path(),
21829            false,
21830            true,
21831            false,
21832            false,
21833            false,
21834        );
21835
21836        assert!(result.is_ok());
21837    }
21838
21839    #[test]
21840    fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
21841        let dir = tempfile::tempdir().unwrap();
21842        let nested = dir.path().join("src/nested");
21843        std::fs::create_dir_all(&nested).unwrap();
21844
21845        let summary_db =
21846            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21847        summary_db
21848            .insert(&summarize::Summary {
21849                id: 0,
21850                symbol_name: "alpha_helper".to_string(),
21851                file_path: "src/lib.rs".to_string(),
21852                content_hash: "hash1".to_string(),
21853                summary: "cached summary".to_string(),
21854                entities: None,
21855                relationships: None,
21856                concept_labels: None,
21857                extracted_at: "1700000000".to_string(),
21858                model: "claude-haiku-4-5-20251001".to_string(),
21859                tokens_input: Some(100),
21860                tokens_output: Some(40),
21861            })
21862            .unwrap();
21863
21864        let result = cmd_summarize(
21865            Some("alpha_helper".to_string()),
21866            None,
21867            None,
21868            false,
21869            false,
21870            &nested,
21871            false,
21872            true,
21873            false,
21874            false,
21875            false,
21876        );
21877
21878        assert!(result.is_ok());
21879        assert!(!nested.join(".tsift/summaries.db").exists());
21880    }
21881
21882    #[test]
21883    fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
21884        let dir = tempfile::tempdir().unwrap();
21885        std::fs::write(
21886            dir.path().join(".gitmodules"),
21887            r#"[submodule "src/alpha"]
21888	path = src/alpha
21889	url = https://example.com/alpha
21890[submodule "src/beta"]
21891	path = src/beta
21892	url = https://example.com/beta
21893"#,
21894        )
21895        .unwrap();
21896
21897        let alpha_root = dir.path().join("src/alpha");
21898        let beta_root = dir.path().join("src/beta");
21899        std::fs::create_dir_all(alpha_root.join("src")).unwrap();
21900        std::fs::create_dir_all(beta_root.join("src")).unwrap();
21901        std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
21902        std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
21903        std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
21904        let beta_file = beta_root.join("src/lib.rs");
21905        std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
21906        std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
21907        std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
21908
21909        let context = find_symbols_db_for_file(dir.path(), &beta_file)
21910            .unwrap()
21911            .expect("expected matching scoped index");
21912
21913        assert_eq!(
21914            context.db_path,
21915            dir.path().join(".tsift/indexes/beta/index.db")
21916        );
21917        assert_eq!(context.source_root, beta_root);
21918    }
21919
21920    // --- apply_edit_op ---
21921
21922    fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
21923        EditOp {
21924            file: PathBuf::from("dummy.txt"),
21925            old: old.to_string(),
21926            new: new.to_string(),
21927            replace_all,
21928        }
21929    }
21930
21931    #[test]
21932    fn edit_replaces_single_occurrence() {
21933        let content = "hello world";
21934        let op = make_op("world", "rust", false);
21935        let (result, count) = apply_edit_op(content, &op).unwrap();
21936        assert_eq!(result, "hello rust");
21937        assert_eq!(count, 1);
21938    }
21939
21940    #[test]
21941    fn edit_replace_all_replaces_every_occurrence() {
21942        let content = "foo foo foo";
21943        let op = make_op("foo", "bar", true);
21944        let (result, count) = apply_edit_op(content, &op).unwrap();
21945        assert_eq!(result, "bar bar bar");
21946        assert_eq!(count, 3);
21947    }
21948
21949    #[test]
21950    fn edit_fails_when_old_not_found() {
21951        let content = "hello world";
21952        let op = make_op("missing", "x", false);
21953        assert!(apply_edit_op(content, &op).is_err());
21954    }
21955
21956    #[test]
21957    fn edit_fails_when_ambiguous_without_replace_all() {
21958        let content = "foo foo";
21959        let op = make_op("foo", "bar", false);
21960        let err = apply_edit_op(content, &op).unwrap_err();
21961        assert!(err.to_string().contains("2 times"), "got: {}", err);
21962    }
21963
21964    #[test]
21965    fn edit_fails_when_old_equals_new() {
21966        let content = "hello";
21967        let op = make_op("hello", "hello", false);
21968        assert!(apply_edit_op(content, &op).is_err());
21969    }
21970
21971    #[test]
21972    fn edit_batch_rolls_back_when_later_swap_fails() {
21973        let dir = tempfile::tempdir().unwrap();
21974        let alpha = dir.path().join("alpha.txt");
21975        let beta = dir.path().join("beta.txt");
21976        fs::write(&alpha, "alpha old\n").unwrap();
21977        fs::write(&beta, "beta old\n").unwrap();
21978
21979        let batch = EditBatch {
21980            edits: vec![
21981                EditOp {
21982                    file: alpha.clone(),
21983                    old: "old".to_string(),
21984                    new: "new".to_string(),
21985                    replace_all: false,
21986                },
21987                EditOp {
21988                    file: beta.clone(),
21989                    old: "old".to_string(),
21990                    new: "new".to_string(),
21991                    replace_all: false,
21992                },
21993            ],
21994        };
21995
21996        let plan = build_edit_plan(&batch).unwrap();
21997        let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
21998            if commit_index == 1 {
21999                bail!("simulated swap failure");
22000            }
22001            Ok(())
22002        }) {
22003            Ok(_) => panic!("expected simulated swap failure"),
22004            Err(err) => err,
22005        };
22006
22007        assert!(err.to_string().contains("simulated swap failure"));
22008        assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22009        assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22010    }
22011
22012    // --- SQL introspection ---
22013
22014    fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22015        let tmp = tempfile::NamedTempFile::new().unwrap();
22016        let conn = Connection::open(tmp.path()).unwrap();
22017        conn.execute_batch(
22018            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22019             INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22020             INSERT INTO users VALUES (2, 'Bob', NULL);
22021             CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22022                 FOREIGN KEY(user_id) REFERENCES users(id));
22023             INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22024             INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22025             INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22026        ).unwrap();
22027        (tmp, conn)
22028    }
22029
22030    // --- rewrite_command ---
22031
22032    #[test]
22033    fn rewrite_rg_simple_pattern() {
22034        let result = rewrite_command("rg authenticate");
22035        assert_eq!(
22036            result,
22037            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22038        );
22039    }
22040
22041    #[test]
22042    fn rewrite_rg_with_path() {
22043        let result = rewrite_command("rg authenticate src/");
22044        assert_eq!(
22045            result,
22046            Some(
22047                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22048                    .to_string()
22049            )
22050        );
22051    }
22052
22053    #[test]
22054    fn rewrite_rg_with_flags_ignored() {
22055        let result = rewrite_command("rg -i authenticate src/");
22056        assert_eq!(
22057            result,
22058            Some(
22059                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22060                    .to_string()
22061            )
22062        );
22063    }
22064
22065    #[test]
22066    fn rewrite_rg_with_type_flag() {
22067        // -t rs takes a value, should be skipped; pattern is next positional
22068        let result = rewrite_command("rg -t rs authenticate");
22069        assert_eq!(
22070            result,
22071            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
22072        );
22073    }
22074
22075    #[test]
22076    fn rewrite_rg_pipe_passthrough() {
22077        // Pipe chains can't be translated — pass through
22078        let result = rewrite_command("rg authenticate | head -5");
22079        assert_eq!(result, None);
22080    }
22081
22082    #[test]
22083    fn rewrite_rg_files_passthrough() {
22084        let result = rewrite_command("rg --files src/tsift .agent-doc logs");
22085        assert_eq!(result, None);
22086    }
22087
22088    #[test]
22089    fn rewrite_find_passthrough() {
22090        let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
22091        assert_eq!(result, None);
22092    }
22093
22094    #[test]
22095    fn rewrite_grep_recursive() {
22096        let result = rewrite_command("grep -r authenticate src/");
22097        assert_eq!(
22098            result,
22099            Some(
22100                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22101                    .to_string()
22102            )
22103        );
22104    }
22105
22106    #[test]
22107    fn rewrite_grep_non_recursive_passthrough() {
22108        let result = rewrite_command("grep authenticate file.txt");
22109        assert_eq!(result, None);
22110    }
22111
22112    #[test]
22113    fn rewrite_tsift_passthrough() {
22114        let result = rewrite_command("tsift search \"foo\"");
22115        assert_eq!(result, Some("tsift search \"foo\"".to_string()));
22116    }
22117
22118    #[test]
22119    fn rewrite_run_tsift_search_disables_timeout_by_default() {
22120        let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
22121        assert_eq!(
22122            result,
22123            "tsift search hookcaps --exact --path /tmp/x --timeout 0"
22124        );
22125    }
22126
22127    #[test]
22128    fn rewrite_run_preserves_explicit_search_timeout() {
22129        let result = effective_rewrite_run_command(
22130            "tsift search hookcaps --exact --path /tmp/x --timeout 5",
22131        );
22132        assert_eq!(
22133            result,
22134            "tsift search hookcaps --exact --path /tmp/x --timeout 5"
22135        );
22136    }
22137
22138    #[test]
22139    fn rewrite_unrelated_passthrough() {
22140        let result = rewrite_command("echo cargo build");
22141        assert_eq!(result, None);
22142    }
22143
22144    #[test]
22145    fn rewrite_rg_quoted_pattern() {
22146        let result = rewrite_command("rg \"fn main\"");
22147        assert_eq!(
22148            result,
22149            Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
22150        );
22151    }
22152
22153    #[test]
22154    fn rewrite_git_diff_to_diff_digest() {
22155        let result = rewrite_command("git diff");
22156        assert_eq!(result, Some("tsift diff-digest .".to_string()));
22157    }
22158
22159    #[test]
22160    fn rewrite_git_diff_cached_to_diff_digest() {
22161        let result = rewrite_command("git diff --cached");
22162        assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
22163    }
22164
22165    #[test]
22166    fn rewrite_git_diff_with_path_to_diff_digest() {
22167        let result = rewrite_command("git diff -- src/");
22168        assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
22169    }
22170
22171    #[test]
22172    fn rewrite_git_diff_with_revision_passthrough() {
22173        let result = rewrite_command("git diff HEAD~1");
22174        assert_eq!(result, None);
22175    }
22176
22177    #[test]
22178    fn rewrite_git_show_to_revision_diff_digest() {
22179        let result = rewrite_command("git show HEAD~1");
22180        assert_eq!(
22181            result,
22182            Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
22183        );
22184    }
22185
22186    #[test]
22187    fn rewrite_git_log_patch_history_to_revision_diff_digest() {
22188        let result = rewrite_command("git log -p -1 HEAD~2");
22189        assert_eq!(
22190            result,
22191            Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
22192        );
22193    }
22194
22195    #[test]
22196    fn rewrite_cat_long_agent_doc_session_to_session_digest() {
22197        let dir = tempfile::tempdir().unwrap();
22198        let session = dir.path().join("tsift.md");
22199        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22200        for index in 0..90 {
22201            body.push_str(&format!("❯ prompt {index}?\n"));
22202        }
22203        fs::write(&session, body).unwrap();
22204
22205        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22206        assert_eq!(
22207            result,
22208            Some(format!(
22209                "tsift session-digest --path {} --input {} --source markdown",
22210                shell_quote(&resolve_digest_context_path(&session)),
22211                shell_quote(session.to_str().unwrap())
22212            ))
22213        );
22214    }
22215
22216    #[test]
22217    fn rewrite_head_long_claude_jsonl_to_session_digest() {
22218        let dir = tempfile::tempdir().unwrap();
22219        let session = dir.path().join("session.jsonl");
22220        let line =
22221            r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
22222        let body = std::iter::repeat_n(line, 120)
22223            .collect::<Vec<_>>()
22224            .join("\n");
22225        fs::write(&session, format!("{body}\n")).unwrap();
22226
22227        let result = rewrite_command(&format!(
22228            "head -n 120 {}",
22229            shell_quote(session.to_str().unwrap())
22230        ));
22231        assert_eq!(
22232            result,
22233            Some(format!(
22234                "tsift session-digest --path {} --input {} --source claude-jsonl",
22235                shell_quote(&resolve_digest_context_path(&session)),
22236                shell_quote(session.to_str().unwrap())
22237            ))
22238        );
22239    }
22240
22241    #[test]
22242    fn rewrite_head_long_codex_jsonl_to_session_digest() {
22243        let dir = tempfile::tempdir().unwrap();
22244        let session = dir.path().join("codex.jsonl");
22245        let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
22246        let body = std::iter::repeat_n(line, 120)
22247            .collect::<Vec<_>>()
22248            .join("\n");
22249        fs::write(&session, format!("{body}\n")).unwrap();
22250
22251        let result = rewrite_command(&format!(
22252            "head -n 120 {}",
22253            shell_quote(session.to_str().unwrap())
22254        ));
22255        assert_eq!(
22256            result,
22257            Some(format!(
22258                "tsift session-digest --path {} --input {} --source codex-jsonl",
22259                shell_quote(&resolve_digest_context_path(&session)),
22260                shell_quote(session.to_str().unwrap())
22261            ))
22262        );
22263    }
22264
22265    #[test]
22266    fn rewrite_small_transcript_window_passthrough() {
22267        let dir = tempfile::tempdir().unwrap();
22268        let session = dir.path().join("session.jsonl");
22269        let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
22270        let body = std::iter::repeat_n(line, 120)
22271            .collect::<Vec<_>>()
22272            .join("\n");
22273        fs::write(&session, format!("{body}\n")).unwrap();
22274
22275        let result = rewrite_command(&format!(
22276            "tail -n 20 {}",
22277            shell_quote(session.to_str().unwrap())
22278        ));
22279        assert_eq!(result, None);
22280    }
22281
22282    #[test]
22283    fn rewrite_sed_large_agent_doc_range_to_session_digest() {
22284        let dir = tempfile::tempdir().unwrap();
22285        let session = dir.path().join("tsift.md");
22286        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22287        for index in 0..120 {
22288            body.push_str(&format!("### Re: topic {index}\n"));
22289        }
22290        fs::write(&session, body).unwrap();
22291
22292        let result = rewrite_command(&format!(
22293            "sed -n '1,120p' {}",
22294            shell_quote(session.to_str().unwrap())
22295        ));
22296        assert_eq!(
22297            result,
22298            Some(format!(
22299                "tsift session-digest --path {} --input {} --source markdown",
22300                shell_quote(&resolve_digest_context_path(&session)),
22301                shell_quote(session.to_str().unwrap())
22302            ))
22303        );
22304    }
22305
22306    #[test]
22307    fn rewrite_cat_large_agent_doc_log_to_session_digest() {
22308        let dir = tempfile::tempdir().unwrap();
22309        let session = dir.path().join("tsift.log");
22310        let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
22311        let body = std::iter::repeat_n(line, 120)
22312            .collect::<Vec<_>>()
22313            .join("\n");
22314        fs::write(&session, format!("{body}\n")).unwrap();
22315
22316        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22317        assert_eq!(
22318            result,
22319            Some(format!(
22320                "tsift session-digest --path {} --input {} --source agent-doc-log",
22321                shell_quote(&resolve_digest_context_path(&session)),
22322                shell_quote(session.to_str().unwrap())
22323            ))
22324        );
22325    }
22326
22327    #[test]
22328    fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
22329        let dir = tempfile::tempdir().unwrap();
22330        fs::write(
22331            dir.path().join(".gitmodules"),
22332            r#"[submodule "src/tsift"]
22333	path = src/tsift
22334	url = https://example.com/tsift
22335"#,
22336        )
22337        .unwrap();
22338        let submodule = dir.path().join("src/tsift");
22339        fs::create_dir_all(submodule.join("tasks")).unwrap();
22340        fs::write(
22341            submodule.join(".git"),
22342            "gitdir: ../../.git/modules/src/tsift\n",
22343        )
22344        .unwrap();
22345        let session = submodule.join("tasks/plan.md");
22346        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22347        for index in 0..90 {
22348            body.push_str(&format!("❯ prompt {index}?\n"));
22349        }
22350        fs::write(&session, body).unwrap();
22351
22352        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22353
22354        assert_eq!(
22355            result,
22356            Some(format!(
22357                "tsift session-digest --path {} --input {} --source markdown",
22358                shell_quote(submodule.to_str().unwrap()),
22359                shell_quote(session.to_str().unwrap())
22360            ))
22361        );
22362    }
22363
22364    #[test]
22365    fn rewrite_regular_markdown_read_passthrough() {
22366        let dir = tempfile::tempdir().unwrap();
22367        let readme = dir.path().join("README.md");
22368        let body = std::iter::repeat_n("plain markdown", 120)
22369            .collect::<Vec<_>>()
22370            .join("\n");
22371        fs::write(&readme, format!("{body}\n")).unwrap();
22372
22373        let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
22374        assert_eq!(result, None);
22375    }
22376
22377    #[test]
22378    fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
22379        let dir = tempfile::tempdir().unwrap();
22380        write_empty_root_index(dir.path());
22381        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22382
22383        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22384
22385        assert_eq!(
22386            result,
22387            Some(format!(
22388                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
22389                shell_quote(&dir.path().to_string_lossy())
22390            ))
22391        );
22392    }
22393
22394    #[test]
22395    fn rewrite_head_small_source_window_passthrough() {
22396        let dir = tempfile::tempdir().unwrap();
22397        write_empty_root_index(dir.path());
22398        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22399
22400        let result = rewrite_command(&format!(
22401            "head -n 20 {}",
22402            shell_quote(source.to_str().unwrap())
22403        ));
22404
22405        assert_eq!(result, None);
22406    }
22407
22408    #[test]
22409    fn rewrite_sed_large_source_range_to_source_read() {
22410        let dir = tempfile::tempdir().unwrap();
22411        write_empty_root_index(dir.path());
22412        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22413
22414        let result = rewrite_command(&format!(
22415            "sed -n '40,160p' {}",
22416            shell_quote(source.to_str().unwrap())
22417        ));
22418
22419        assert_eq!(
22420            result,
22421            Some(format!(
22422                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
22423                shell_quote(&dir.path().to_string_lossy())
22424            ))
22425        );
22426    }
22427
22428    #[test]
22429    fn rewrite_tail_large_source_window_preserves_tail_anchor() {
22430        let dir = tempfile::tempdir().unwrap();
22431        write_empty_root_index(dir.path());
22432        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22433
22434        let result = rewrite_command(&format!(
22435            "tail -n 120 {}",
22436            shell_quote(source.to_str().unwrap())
22437        ));
22438
22439        assert_eq!(
22440            result,
22441            Some(format!(
22442                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
22443                shell_quote(&dir.path().to_string_lossy())
22444            ))
22445        );
22446    }
22447
22448    #[test]
22449    fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
22450        let dir = tempfile::tempdir().unwrap();
22451        write_empty_root_index(dir.path());
22452        let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
22453
22454        let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
22455
22456        assert_eq!(result, None);
22457    }
22458
22459    #[test]
22460    fn rewrite_large_source_read_passthrough_without_index() {
22461        let dir = tempfile::tempdir().unwrap();
22462        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22463
22464        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22465
22466        assert_eq!(result, None);
22467    }
22468
22469    #[test]
22470    fn rewrite_cargo_test_to_digest_runner() {
22471        let result = rewrite_command("cargo test --lib");
22472        assert_eq!(
22473            result,
22474            Some(
22475                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
22476            )
22477        );
22478    }
22479
22480    #[test]
22481    fn rewrite_pytest_to_digest_runner() {
22482        let result = rewrite_command("pytest -q tests/test_cli.py");
22483        assert_eq!(
22484            result,
22485            Some(
22486                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
22487            )
22488        );
22489    }
22490
22491    #[test]
22492    fn rewrite_python_m_pytest_to_digest_runner() {
22493        let result = rewrite_command("python -m pytest tests/test_cli.py");
22494        assert_eq!(
22495            result,
22496            Some(
22497                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
22498            )
22499        );
22500    }
22501
22502    #[test]
22503    fn rewrite_cargo_build_to_log_digest_runner() {
22504        let result = rewrite_command("cargo build --release");
22505        assert_eq!(
22506            result,
22507            Some(
22508                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
22509            )
22510        );
22511    }
22512
22513    #[test]
22514    fn rewrite_cargo_install_to_log_digest_runner() {
22515        let result = rewrite_command("cargo install --path . --force");
22516        assert_eq!(
22517            result,
22518            Some(
22519                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
22520            )
22521        );
22522    }
22523
22524    #[test]
22525    fn rewrite_metacharacter_command_passthrough() {
22526        let result = rewrite_command("cargo test | head");
22527        assert_eq!(result, None);
22528    }
22529
22530    #[test]
22531    fn rewrite_output_cap_detects_search_even_with_global_flag() {
22532        let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
22533        assert_eq!(cap.max_lines, 50);
22534        assert_eq!(cap.strip_prefix, Some("Strategy:"));
22535    }
22536
22537    #[test]
22538    fn rewrite_output_cap_skips_structured_output() {
22539        assert!(rewrite_output_cap("tsift search foo --json").is_none());
22540        assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
22541        assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
22542    }
22543
22544    #[test]
22545    fn rewrite_output_format_forwards_envelope_to_digest_runner() {
22546        let command = rewrite_command("cargo test --lib").expect("rewrite");
22547        let forwarded = apply_rewrite_output_format(
22548            &command,
22549            OutputFormat {
22550                json_output: true,
22551                compact: false,
22552                pretty: false,
22553                terse: false,
22554                ultra_terse: false,
22555                schema: false,
22556                envelope: true,
22557            },
22558        );
22559        assert_eq!(
22560            forwarded,
22561            "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
22562        );
22563    }
22564
22565    #[test]
22566    fn rewrite_output_format_forwards_json_when_requested() {
22567        let command = rewrite_command("cargo build --release").expect("rewrite");
22568        let forwarded = apply_rewrite_output_format(
22569            &command,
22570            OutputFormat {
22571                json_output: true,
22572                compact: false,
22573                pretty: true,
22574                terse: false,
22575                ultra_terse: false,
22576                schema: false,
22577                envelope: false,
22578            },
22579        );
22580        assert_eq!(
22581            forwarded,
22582            "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
22583        );
22584    }
22585
22586    #[test]
22587    fn output_cap_strips_search_header_and_truncates() {
22588        let capped = apply_output_cap(
22589            b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
22590            OutputCap {
22591                max_lines: 2,
22592                strip_prefix: Some("Strategy:"),
22593            },
22594        );
22595        assert_eq!(
22596            capped,
22597            "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
22598        );
22599    }
22600
22601    #[test]
22602    fn sql_schema_overview_lists_tables() {
22603        let (_tmp, conn) = setup_test_db();
22604        let tables = schema_overview(&conn).unwrap();
22605        let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
22606        assert_eq!(names, &["posts", "users"]);
22607    }
22608
22609    #[test]
22610    fn sql_schema_overview_row_counts() {
22611        let (_tmp, conn) = setup_test_db();
22612        let tables = schema_overview(&conn).unwrap();
22613        let users = tables.iter().find(|t| t.name == "users").unwrap();
22614        let posts = tables.iter().find(|t| t.name == "posts").unwrap();
22615        assert_eq!(users.row_count, 2);
22616        assert_eq!(posts.row_count, 3);
22617    }
22618
22619    #[test]
22620    fn sql_table_columns_metadata() {
22621        let (_tmp, conn) = setup_test_db();
22622        let cols = table_columns(&conn, "users").unwrap();
22623        assert_eq!(cols.len(), 3);
22624        assert_eq!(cols[0].name, "id");
22625        assert!(cols[0].pk);
22626        assert_eq!(cols[1].name, "name");
22627        assert!(cols[1].notnull);
22628        assert_eq!(cols[2].name, "email");
22629        assert!(!cols[2].notnull);
22630    }
22631
22632    #[test]
22633    fn sql_execute_query_returns_rows() {
22634        let (_tmp, conn) = setup_test_db();
22635        let (columns, rows) =
22636            execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
22637        assert_eq!(columns, &["name", "email"]);
22638        assert_eq!(rows.len(), 2);
22639        assert_eq!(rows[0][0], serde_json::json!("Alice"));
22640        assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
22641        assert_eq!(rows[1][1], serde_json::Value::Null);
22642    }
22643
22644    #[test]
22645    fn sql_execute_query_aggregate() {
22646        let (_tmp, conn) = setup_test_db();
22647        let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
22648        assert_eq!(columns, &["cnt"]);
22649        assert_eq!(rows[0][0], serde_json::json!(3));
22650    }
22651
22652    #[test]
22653    fn sql_execute_query_join() {
22654        let (_tmp, conn) = setup_test_db();
22655        let (_cols, rows) = execute_query(
22656            &conn,
22657            "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
22658        )
22659        .unwrap();
22660        assert_eq!(rows.len(), 3);
22661        assert_eq!(rows[0][0], serde_json::json!("Alice"));
22662        assert_eq!(rows[2][0], serde_json::json!("Bob"));
22663    }
22664
22665    #[test]
22666    fn sql_open_db_read_only() {
22667        let (tmp, _conn) = setup_test_db();
22668        drop(_conn);
22669        let ro_conn = open_db(tmp.path()).unwrap();
22670        let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
22671        assert!(result.is_err(), "read-only connection should reject writes");
22672    }
22673
22674    #[test]
22675    fn sql_empty_table_schema() {
22676        let tmp = tempfile::NamedTempFile::new().unwrap();
22677        let conn = Connection::open(tmp.path()).unwrap();
22678        conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
22679            .unwrap();
22680        let tables = schema_overview(&conn).unwrap();
22681        assert_eq!(tables[0].row_count, 0);
22682        assert_eq!(tables[0].columns.len(), 2);
22683    }
22684
22685    // --- graph command ---
22686
22687    fn setup_graph_index() -> tempfile::TempDir {
22688        let dir = tempfile::tempdir().unwrap();
22689        std::fs::write(
22690            dir.path().join("main.rs"),
22691            "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
22692        )
22693        .unwrap();
22694        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22695        db.apply_changes(dir.path()).unwrap();
22696        dir
22697    }
22698
22699    fn setup_traversal_project() -> tempfile::TempDir {
22700        let dir = setup_graph_index();
22701        let task_dir = dir.path().join("tasks/software");
22702        std::fs::create_dir_all(&task_dir).unwrap();
22703        std::fs::write(
22704            task_dir.join("tsift.md"),
22705            r#"---
22706agent_doc_session: tsift-v0.1
22707agent_doc_format: template
22708---
22709
22710## Exchange
22711
22712<!-- agent:exchange patch=append -->
22713❯ do [#kgnv]
22714Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
22715<!-- /agent:exchange -->
22716
22717<!-- agent:queue -->
22718dispatch #spec-test-build-install-commit-push
22719- do [#kgnv]
22720<!-- /agent:queue -->
22721
22722## Backlog
22723
22724<!-- agent:backlog -->
22725- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
22726<!-- /agent:backlog -->
22727"#,
22728        )
22729        .unwrap();
22730        dir
22731    }
22732
22733    fn resolve_ast_span_node<'a>(
22734        graph: &'a TraversalGraphBuild,
22735        label: &str,
22736        symbol_kind: &str,
22737    ) -> &'a TraversalNode {
22738        graph
22739            .nodes
22740            .values()
22741            .find(|node| {
22742                node.kind == "ast_span"
22743                    && node.label == label
22744                    && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
22745            })
22746            .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
22747    }
22748
22749    fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
22750        let dir = tempfile::tempdir().unwrap();
22751        std::fs::write(
22752            dir.path().join("rust.rs"),
22753            r#"mod fixture_nav_rust_mod {
22754    pub fn fixture_nav_rust_helper() {}
22755    pub fn fixture_nav_rust_entry() {
22756        fixture_nav_rust_helper();
22757    }
22758}
22759"#,
22760        )
22761        .unwrap();
22762        std::fs::write(
22763            dir.path().join("python.py"),
22764            r#"def fixture_nav_python_helper():
22765    return 1
22766
22767def fixture_nav_python_entry():
22768    return fixture_nav_python_helper()
22769"#,
22770        )
22771        .unwrap();
22772        std::fs::write(
22773            dir.path().join("typescript.ts"),
22774            r#"export function fixture_nav_typescript_entry(): number {
22775    return fixtureNavTsHelper();
22776}
22777
22778function fixtureNavTsHelper(): number {
22779    return 1;
22780}
22781"#,
22782        )
22783        .unwrap();
22784        std::fs::write(
22785            dir.path().join("javascript.js"),
22786            r#"function fixture_nav_javascript_entry() {
22787    return fixtureNavJsHelper();
22788}
22789
22790function fixtureNavJsHelper() {
22791    return 1;
22792}
22793"#,
22794        )
22795        .unwrap();
22796        std::fs::write(
22797            dir.path().join("kotlin.kt"),
22798            r#"fun fixture_nav_kotlin_entry(): Int {
22799    return fixtureNavKotlinHelper()
22800}
22801
22802fun fixtureNavKotlinHelper(): Int = 1
22803"#,
22804        )
22805        .unwrap();
22806        std::fs::write(
22807            dir.path().join("zig.zig"),
22808            r#"pub fn fixture_nav_zig_entry() i32 {
22809    return fixtureNavZigHelper();
22810}
22811
22812fn fixtureNavZigHelper() i32 {
22813    return 1;
22814}
22815"#,
22816        )
22817        .unwrap();
22818        std::fs::write(
22819            dir.path().join("bash.sh"),
22820            r#"#!/usr/bin/env bash
22821fixture_nav_bash_entry() {
22822    fixture_nav_bash_helper
22823}
22824
22825fixture_nav_bash_helper() {
22826    echo ok
22827}
22828
22829alias fixture_nav_bash_alias='echo alias'
22830"#,
22831        )
22832        .unwrap();
22833        std::fs::write(
22834            dir.path().join("README.md"),
22835            r#"# Fixture Guide
22836
22837## Fixture Section
22838
22839- Fixture step
22840  - Nested fixture step
22841
22842```python
22843def fixture_nav_markdown_embedded():
22844    return 1
22845```
22846"#,
22847        )
22848        .unwrap();
22849
22850        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22851        db.apply_changes(dir.path()).unwrap();
22852        dir
22853    }
22854
22855    fn assert_cli_expand_command_parses(command: &str) {
22856        let args = shell_split(command)
22857            .into_iter()
22858            .map(str::to_string)
22859            .collect::<Vec<_>>();
22860        assert!(
22861            try_parse_cli(args).is_ok(),
22862            "expand command should parse as a tsift CLI command: {command}"
22863        );
22864    }
22865
22866    fn setup_multiplicity_project() -> tempfile::TempDir {
22867        let dir = tempfile::tempdir().unwrap();
22868        std::fs::write(
22869            dir.path().join("Cargo.toml"),
22870            r#"[workspace]
22871members = ["crates/core-lib", "crates/cli-app"]
22872"#,
22873        )
22874        .unwrap();
22875        std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
22876        std::fs::write(
22877            dir.path().join("crates/core-lib/Cargo.toml"),
22878            r#"[package]
22879name = "core-lib"
22880
22881[lib]
22882name = "core_lib"
22883
22884[features]
22885default = []
22886"#,
22887        )
22888        .unwrap();
22889        std::fs::write(
22890            dir.path().join("crates/core-lib/src/lib.rs"),
22891            "pub fn run() {}\n",
22892        )
22893        .unwrap();
22894        std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
22895        std::fs::write(
22896            dir.path().join("crates/cli-app/Cargo.toml"),
22897            r#"[package]
22898name = "cli-app"
22899
22900[[bin]]
22901name = "cli-app"
22902
22903[dependencies]
22904core-lib = { path = "../core-lib" }
22905"#,
22906        )
22907        .unwrap();
22908        std::fs::write(
22909            dir.path().join("crates/cli-app/src/main.rs"),
22910            "use core_lib::run;\nfn main() { run(); }\n",
22911        )
22912        .unwrap();
22913        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22914        db.apply_changes(dir.path()).unwrap();
22915
22916        let task_dir = dir.path().join("tasks/software");
22917        std::fs::create_dir_all(&task_dir).unwrap();
22918        std::fs::write(
22919            task_dir.join("tsift.md"),
22920            r#"---
22921agent_doc_session: tsift-multiplicity
22922agent_doc_format: template
22923---
22924
22925## Backlog
22926
22927<!-- agent:backlog -->
22928- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
22929<!-- /agent:backlog -->
22930"#,
22931        )
22932        .unwrap();
22933        init_git_repo(dir.path());
22934        dir
22935    }
22936
22937    fn setup_dependency_dag_project() -> tempfile::TempDir {
22938        let dir = tempfile::tempdir().unwrap();
22939        std::fs::write(
22940            dir.path().join("main.rs"),
22941            "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
22942        )
22943        .unwrap();
22944        std::fs::write(
22945            dir.path().join("Cargo.toml"),
22946            "[package]\nname = \"dag-fixture\"\n",
22947        )
22948        .unwrap();
22949        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22950        db.apply_changes(dir.path()).unwrap();
22951
22952        let task_dir = dir.path().join("tasks/software");
22953        std::fs::create_dir_all(&task_dir).unwrap();
22954        std::fs::write(
22955            task_dir.join("tsift.md"),
22956            r#"---
22957agent_doc_session: tsift-dag
22958agent_doc_format: template
22959---
22960
22961## Exchange
22962
22963<!-- agent:exchange patch=append -->
22964Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
22965<!-- /agent:exchange -->
22966
22967## Backlog
22968
22969<!-- agent:backlog -->
22970- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
22971- [ ] [#alpha] Update shared_helper in main.rs after #prep.
22972- [ ] [#beta] Refactor shared_helper tests in main.rs.
22973- [ ] [#gamma] Follow-up review for graph navigation.
22974<!-- /agent:backlog -->
22975"#,
22976        )
22977        .unwrap();
22978        dir
22979    }
22980
22981    fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
22982        let dir = setup_graph_index();
22983        let task_dir = dir.path().join("tasks/software");
22984        std::fs::create_dir_all(&task_dir).unwrap();
22985        std::fs::write(
22986            task_dir.join("tsift.md"),
22987            r#"---
22988agent_doc_session: tsift-dag-cycle
22989agent_doc_format: template
22990---
22991
22992## Backlog
22993
22994<!-- agent:backlog -->
22995- [ ] [#left] Left side depends on #right.
22996- [ ] [#right] Right side depends on #left.
22997<!-- /agent:backlog -->
22998"#,
22999        )
23000        .unwrap();
23001        dir
23002    }
23003
23004    fn seed_traversal_semantic_summaries(dir: &Path) {
23005        let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23006        summary_db
23007            .insert(&summarize::Summary {
23008                id: 0,
23009                symbol_name: "helper".to_string(),
23010                file_path: "main.rs".to_string(),
23011                content_hash: "hash-main".to_string(),
23012                summary: "helper builds graph navigation handles for traversal.".to_string(),
23013                entities: Some(vec![
23014                    summarize::Entity {
23015                        name: "helper".to_string(),
23016                        kind: "function".to_string(),
23017                        description: "Builds graph navigation handles.".to_string(),
23018                    },
23019                    summarize::Entity {
23020                        name: "TraversalGraph".to_string(),
23021                        kind: "type".to_string(),
23022                        description: "Carries GraphStore-backed traversal rows.".to_string(),
23023                    },
23024                ]),
23025                relationships: Some(vec![summarize::Relationship {
23026                    from: "helper".to_string(),
23027                    to: "TraversalGraph".to_string(),
23028                    kind: "uses".to_string(),
23029                }]),
23030                concept_labels: Some(vec![
23031                    "graph navigation".to_string(),
23032                    "semantic extraction".to_string(),
23033                ]),
23034                extracted_at: "1700000000".to_string(),
23035                model: "test-model".to_string(),
23036                tokens_input: Some(10),
23037                tokens_output: Some(5),
23038            })
23039            .unwrap();
23040    }
23041
23042    fn seed_tsift_memory_graph_db(dir: &Path) {
23043        let db = dir.join(".tsift").join("memory.db");
23044        let store = MemoryStore::open_or_create(&db).unwrap();
23045        let project = dir.to_string_lossy().to_string();
23046        let observation = MemoryEvent::new(
23047            MemoryEventKind::ImportedObservation,
23048            "claude-mem:observations:1",
23049            [
23050                "Graph memory adapter",
23051                "read-only projection",
23052                "graph-db should retrieve tsift memory observations",
23053                "Project memory is queried from .tsift/memory.db",
23054                "graph memory, tsift memory, semantic query",
23055            ]
23056            .join("\n\n"),
23057        )
23058        .with_session_id("claude-session-a")
23059        .with_observed_at_unix(1_700_000_000)
23060        .with_import("claude-mem", "observations:1")
23061        .with_metadata("project", project.clone())
23062        .with_metadata("observation_type", "fact")
23063        .with_metadata("prompt_number", "7")
23064        .with_metadata("discovery_tokens", "42")
23065        .with_metadata("content_hash", "hash-observation-1");
23066        store.insert_event(&observation).unwrap();
23067
23068        let summary = MemoryEvent::new(
23069            MemoryEventKind::ImportedSessionSummary,
23070            "claude-mem:session_summaries:2",
23071            [
23072                "Query old memory from graph-db",
23073                "Read-only tsift memory SQLite projection",
23074                "Semantic graph rows can point at existing memory",
23075                "Projected source and session nodes",
23076                "Keep capture ownership inside tsift-memory",
23077                "summary note",
23078            ]
23079            .join("\n\n"),
23080        )
23081        .with_session_id("claude-session-a")
23082        .with_observed_at_unix(1_700_000_010)
23083        .with_import("claude-mem", "session_summaries:2")
23084        .with_metadata("project", project)
23085        .with_metadata("prompt_number", "8")
23086        .with_metadata("discovery_tokens", "36");
23087        store.insert_event(&summary).unwrap();
23088
23089        let prompt = MemoryEvent::new(
23090            MemoryEventKind::ImportedUserPrompt,
23091            "claude-mem:user_prompts:3",
23092            "How can graph-db query tsift memory semantic history?",
23093        )
23094        .with_session_id("claude-session-a")
23095        .with_observed_at_unix(1_700_000_020)
23096        .with_import("claude-mem", "user_prompts:3")
23097        .with_metadata("prompt_number", "9");
23098        store.insert_event(&prompt).unwrap();
23099    }
23100
23101    #[test]
23102    fn graph_callers_query() {
23103        let dir = setup_graph_index();
23104        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23105        let callers = db.callers_of("helper").unwrap();
23106        assert_eq!(callers.len(), 1);
23107        assert_eq!(callers[0].caller_name, "main");
23108    }
23109
23110    #[test]
23111    fn graph_callees_query() {
23112        let dir = setup_graph_index();
23113        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23114        let callees = db.callees_of("main").unwrap();
23115        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
23116        assert!(names.contains(&"helper"));
23117        assert!(names.contains(&"new"));
23118    }
23119
23120    #[test]
23121    fn graph_no_callers_returns_empty() {
23122        let dir = setup_graph_index();
23123        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23124        let callers = db.callers_of("nonexistent").unwrap();
23125        assert!(callers.is_empty());
23126    }
23127
23128    #[test]
23129    fn graph_cmd_autoindexes_missing_index_by_default() {
23130        let dir = tempfile::tempdir().unwrap();
23131        std::fs::write(
23132            dir.path().join("main.rs"),
23133            "fn helper() {}\nfn main() { helper(); }\n",
23134        )
23135        .unwrap();
23136        let result = cmd_graph(
23137            "helper",
23138            dir.path(),
23139            true,
23140            false,
23141            None,
23142            20,
23143            false,
23144            true,
23145            false,
23146            false,
23147            false,
23148            false,
23149            false,
23150            TagpathSearchOpts::default(),
23151        );
23152
23153        assert!(result.is_ok());
23154        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
23155        let summary = db.compute_changes(dir.path()).unwrap();
23156        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
23157    }
23158
23159    #[test]
23160    fn traversal_graph_has_stable_typed_handles() {
23161        let dir = setup_traversal_project();
23162        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23163        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23164
23165        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
23166        let symbol = resolve_traversal_node(&graph, "helper").unwrap();
23167        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23168        let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
23169
23170        assert!(file.handle.starts_with("gfil-"));
23171        assert!(symbol.handle.starts_with("gsym-"));
23172        assert!(backlog.handle.starts_with("gbak-"));
23173        assert!(session.handle.starts_with("gses-"));
23174
23175        assert_eq!(
23176            symbol.handle,
23177            resolve_traversal_node(&graph_again, "helper")
23178                .unwrap()
23179                .handle
23180        );
23181        assert_eq!(
23182            backlog.handle,
23183            resolve_traversal_node(&graph_again, "#kgnv")
23184                .unwrap()
23185                .handle
23186        );
23187    }
23188
23189    #[test]
23190    fn traversal_graph_links_backlog_items_to_code_tokens() {
23191        let dir = setup_traversal_project();
23192        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23193        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23194        let helper = resolve_traversal_node(&graph, "helper").unwrap();
23195
23196        assert!(graph.edges.iter().any(|edge| {
23197            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23198        }));
23199    }
23200
23201    #[test]
23202    fn session_hinted_traversal_skips_global_call_edges() {
23203        let dir = setup_traversal_project();
23204        let session = dir.path().join("tasks/software/tsift.md");
23205        let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
23206        let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
23207        let helper = resolve_traversal_node(&bounded, "helper").unwrap();
23208
23209        assert!(bounded.edges.iter().any(|edge| {
23210            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23211        }));
23212        assert!(
23213            !bounded.edges.iter().any(|edge| edge.relation == "calls"),
23214            "session-hinted graph-db projections should not materialize unrelated global call edges"
23215        );
23216
23217        let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
23218        assert!(
23219            full.edges.iter().any(|edge| edge.relation == "calls"),
23220            "root/full projections still carry the complete indexed call graph"
23221        );
23222    }
23223
23224    #[test]
23225    fn agent_doc_task_path_infers_matching_workspace_scope() {
23226        let dir = tempfile::tempdir().unwrap();
23227        std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
23228        std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
23229        std::fs::write(
23230            dir.path().join(".gitmodules"),
23231            "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
23232        )
23233        .unwrap();
23234        let task = dir.path().join("tasks/software/tsift.md");
23235        std::fs::write(&task, "# tsift\n").unwrap();
23236
23237        let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
23238        let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
23239        let cfg = config::Config::load(dir.path()).unwrap();
23240
23241        assert_eq!(targets.len(), 1);
23242        assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
23243        assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
23244        assert!(
23245            targets[0]
23246                .db_path
23247                .ends_with(".tsift/indexes/tsift/index.db")
23248        );
23249        assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
23250    }
23251
23252    #[test]
23253    fn cargo_package_scope_selector_indexes_package_db() {
23254        let dir = setup_multiplicity_project();
23255        let targets =
23256            resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
23257
23258        assert_eq!(targets.len(), 1);
23259        assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
23260        assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
23261        assert!(
23262            targets[0]
23263                .db_path
23264                .ends_with(".tsift/indexes/cargo/core-lib/index.db")
23265        );
23266
23267        cmd_index(
23268            dir.path(),
23269            false,
23270            false,
23271            false,
23272            false,
23273            true,
23274            false,
23275            Some("core_lib"),
23276            false,
23277            true,
23278            false,
23279            false,
23280            false,
23281            false,
23282        )
23283        .unwrap();
23284        assert!(targets[0].db_path.exists());
23285    }
23286
23287    #[test]
23288    fn path_inference_prefers_nested_cargo_package_without_submodule() {
23289        let dir = setup_multiplicity_project();
23290        let source = dir.path().join("crates/cli-app/src/main.rs");
23291        let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
23292
23293        assert_eq!(targets.len(), 1);
23294        assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
23295        assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
23296    }
23297
23298    #[test]
23299    fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
23300        let dir = setup_multiplicity_project();
23301        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23302        let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
23303        let core = resolve_traversal_node(&graph, "core-lib").unwrap();
23304        let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
23305        let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
23306
23307        assert_eq!(workspace.kind, "cargo_workspace");
23308        assert_eq!(core.kind, "cargo_package");
23309        assert_eq!(
23310            core.properties.get("features"),
23311            Some(&"default".to_string())
23312        );
23313        assert!(graph.edges.iter().any(|edge| {
23314            edge.from == workspace.handle
23315                && edge.to == core.handle
23316                && edge.relation == "contains_package"
23317        }));
23318        assert!(graph.edges.iter().any(|edge| {
23319            edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
23320        }));
23321        assert!(graph.edges.iter().any(|edge| {
23322            edge.from == cli.handle
23323                && edge.to == core.handle
23324                && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
23325        }));
23326    }
23327
23328    #[test]
23329    fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
23330        let dir = setup_multiplicity_project();
23331        let session = dir.path().join("tasks/software/tsift.md");
23332        let report =
23333            build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
23334                .unwrap();
23335
23336        assert!(report.per_target_fail_closed.is_empty());
23337        let candidate = report
23338            .candidates
23339            .iter()
23340            .find(|candidate| candidate.target == "corepkg")
23341            .unwrap();
23342        assert!(
23343            candidate
23344                .owned_files
23345                .iter()
23346                .any(|file| file == "crates/core-lib/Cargo.toml"),
23347            "{:?}",
23348            candidate.owned_files
23349        );
23350    }
23351
23352    #[test]
23353    fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
23354        let dir = setup_traversal_project();
23355        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23356        let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
23357        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23358
23359        assert_eq!(job.kind, "job_packet");
23360        assert!(job.handle.starts_with("gjob-"));
23361        assert!(graph.edges.iter().any(|edge| {
23362            edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
23363        }));
23364
23365        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23366        let jobs = store.nodes_by_kind("job_packet").unwrap();
23367        assert!(
23368            jobs.iter()
23369                .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23370            "expected queued job packet in graph store, got {jobs:?}"
23371        );
23372    }
23373
23374    #[test]
23375    fn traversal_graph_includes_routes_and_handler_edges() {
23376        let dir = tempfile::tempdir().unwrap();
23377        std::fs::write(
23378            dir.path().join("api.py"),
23379            r#"@router.get("/items")
23380def list_items():
23381    return []
23382"#,
23383        )
23384        .unwrap();
23385        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23386        db.apply_changes(dir.path()).unwrap();
23387
23388        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23389        let route = resolve_traversal_node(&graph, "/items").unwrap();
23390        let handler = resolve_traversal_node(&graph, "list_items").unwrap();
23391
23392        assert_eq!(route.kind, "route");
23393        assert!(graph.edges.iter().any(|edge| {
23394            edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
23395        }));
23396    }
23397
23398    #[test]
23399    fn traversal_graph_projects_rust_ast_navigation_edges() {
23400        let dir = tempfile::tempdir().unwrap();
23401        std::fs::write(
23402            dir.path().join("main.rs"),
23403            r#"mod api {
23404    pub fn helper() {}
23405    pub fn handler() { helper(); }
23406}
23407
23408fn main() { api::handler(); }
23409"#,
23410        )
23411        .unwrap();
23412        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23413        db.apply_changes(dir.path()).unwrap();
23414
23415        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23416        let api = resolve_ast_span_node(&graph, "api", "mod");
23417        let helper = resolve_ast_span_node(&graph, "helper", "function");
23418        let handler = resolve_ast_span_node(&graph, "handler", "function");
23419
23420        assert_eq!(helper.kind, "ast_span");
23421        assert!(helper.handle.starts_with("span-"));
23422        assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
23423        assert!(graph.edges.iter().any(|edge| {
23424            edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
23425        }));
23426        assert!(graph.edges.iter().any(|edge| {
23427            edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
23428        }));
23429        assert!(graph.edges.iter().any(|edge| {
23430            edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
23431        }));
23432        assert!(graph.edges.iter().any(|edge| {
23433            edge.from == helper.handle
23434                && edge.to == handler.handle
23435                && edge.relation == "next_sibling"
23436        }));
23437        assert!(graph.edges.iter().any(|edge| {
23438            edge.from == handler.handle
23439                && edge.to == helper.handle
23440                && edge.relation == "previous_sibling"
23441        }));
23442        assert!(graph.edges.iter().any(|edge| {
23443            edge.from == helper.handle
23444                && edge.to == api.handle
23445                && edge.relation == "enclosing_module"
23446        }));
23447        assert!(graph.edges.iter().any(|edge| {
23448            edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
23449        }));
23450
23451        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23452        let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
23453        assert!(
23454            ast_nodes.iter().any(|node| node.id == helper.handle
23455                && node.properties.get("symbol_kind") == Some(&"function".to_string())),
23456            "expected helper AST span in graph store, got {ast_nodes:?}"
23457        );
23458        assert!(
23459            store
23460                .outgoing_edges(&helper.handle, Some("parent"))
23461                .unwrap()
23462                .iter()
23463                .any(|edge| edge.to_id == api.handle),
23464            "expected persisted AST parent edge"
23465        );
23466    }
23467
23468    #[test]
23469    fn traversal_graph_projects_markdown_section_block_edges() {
23470        let dir = tempfile::tempdir().unwrap();
23471        std::fs::write(
23472            dir.path().join("README.md"),
23473            "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
23474        )
23475        .unwrap();
23476        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23477        db.apply_changes(dir.path()).unwrap();
23478
23479        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23480        let guide = resolve_ast_span_node(&graph, "Guide", "heading");
23481        let code = resolve_ast_span_node(&graph, "rust", "code_block");
23482        let embedded = resolve_ast_span_node(&graph, "demo", "function");
23483        let list_item = graph
23484            .nodes
23485            .values()
23486            .find(|node| {
23487                node.kind == "ast_span"
23488                    && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
23489                    && node.properties.get("section_handle") == Some(&guide.handle)
23490            })
23491            .expect("missing Markdown list item AST span");
23492
23493        assert_eq!(
23494            code.properties.get("markdown_block_kind"),
23495            Some(&"fenced_code_block".to_string())
23496        );
23497        assert_eq!(
23498            guide.properties.get("heading_level"),
23499            Some(&"1".to_string())
23500        );
23501        assert_eq!(
23502            embedded.properties.get("embedded"),
23503            Some(&"true".to_string())
23504        );
23505        assert_eq!(
23506            embedded.properties.get("language"),
23507            Some(&"rust".to_string())
23508        );
23509        assert_eq!(
23510            embedded.properties.get("markdown_block_handle"),
23511            Some(&code.handle)
23512        );
23513        assert!(graph.edges.iter().any(|edge| {
23514            edge.from == guide.handle
23515                && edge.to == code.handle
23516                && edge.relation == "contains_markdown_block"
23517        }));
23518        assert!(graph.edges.iter().any(|edge| {
23519            edge.from == code.handle
23520                && edge.to == guide.handle
23521                && edge.relation == "enclosing_section"
23522        }));
23523        assert!(graph.edges.iter().any(|edge| {
23524            edge.from == guide.handle
23525                && edge.to == list_item.handle
23526                && edge.relation == "contains_markdown_block"
23527        }));
23528        assert!(graph.edges.iter().any(|edge| {
23529            edge.from == code.handle
23530                && edge.to == embedded.handle
23531                && edge.relation == "contains_embedded_symbol"
23532        }));
23533        assert!(graph.edges.iter().any(|edge| {
23534            edge.from == embedded.handle
23535                && edge.to == code.handle
23536                && edge.relation == "embedded_in_fence"
23537        }));
23538        assert!(graph.edges.iter().any(|edge| {
23539            edge.from == guide.handle
23540                && edge.to == embedded.handle
23541                && edge.relation == "contains_embedded_code"
23542        }));
23543
23544        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23545        assert!(
23546            store
23547                .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
23548                .unwrap()
23549                .iter()
23550                .any(|edge| edge.to_id == code.handle),
23551            "expected persisted Markdown section/block edge"
23552        );
23553        assert!(
23554            store
23555                .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
23556                .unwrap()
23557                .iter()
23558                .any(|edge| edge.to_id == embedded.handle),
23559            "expected persisted Markdown fence/embedded symbol edge"
23560        );
23561    }
23562
23563    #[test]
23564    fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
23565        let dir = setup_multilingual_ast_navigation_project();
23566        let db =
23567            index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
23568        let symbols = db.all_symbols().unwrap();
23569        let expected_symbols = [
23570            ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
23571            (
23572                "python",
23573                "fixture_nav_python_entry",
23574                "function",
23575                "python.py",
23576            ),
23577            (
23578                "typescript",
23579                "fixture_nav_typescript_entry",
23580                "function",
23581                "typescript.ts",
23582            ),
23583            (
23584                "javascript",
23585                "fixture_nav_javascript_entry",
23586                "function",
23587                "javascript.js",
23588            ),
23589            (
23590                "kotlin",
23591                "fixture_nav_kotlin_entry",
23592                "function",
23593                "kotlin.kt",
23594            ),
23595            ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
23596            ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
23597            ("markdown", "Fixture Section", "heading", "README.md"),
23598            ("markdown", "Fixture step", "list_item", "README.md"),
23599            ("markdown", "python", "code_block", "README.md"),
23600        ];
23601
23602        for (language, name, kind, file) in expected_symbols {
23603            let symbol = symbols
23604                .iter()
23605                .find(|symbol| {
23606                    symbol.language == language
23607                        && symbol.name == name
23608                        && symbol.kind == kind
23609                        && symbol.file.ends_with(file)
23610                })
23611                .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
23612            assert!(
23613                symbol.start_byte.is_some() && symbol.end_byte.is_some(),
23614                "{language} {name} should carry AST byte spans"
23615            );
23616        }
23617
23618        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23619        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23620        let expected_ast_nodes = [
23621            ("fixture_nav_rust_entry", "function", "rust"),
23622            ("fixture_nav_python_entry", "function", "python"),
23623            ("fixture_nav_typescript_entry", "function", "typescript"),
23624            ("fixture_nav_javascript_entry", "function", "javascript"),
23625            ("fixture_nav_kotlin_entry", "function", "kotlin"),
23626            ("fixture_nav_zig_entry", "function", "zig"),
23627            ("fixture_nav_bash_entry", "function", "bash"),
23628            ("Fixture Section", "heading", "markdown"),
23629            ("Fixture step", "list_item", "markdown"),
23630            ("python", "code_block", "markdown"),
23631            ("fixture_nav_markdown_embedded", "function", "python"),
23632        ];
23633
23634        for (name, kind, language) in expected_ast_nodes {
23635            let node = resolve_ast_span_node(&graph, name, kind);
23636            let repeated = resolve_ast_span_node(&graph_again, name, kind);
23637            assert!(
23638                node.handle.starts_with("span-"),
23639                "{name} handle: {}",
23640                node.handle
23641            );
23642            assert_eq!(
23643                node.handle, repeated.handle,
23644                "{language} {name} handle drifted"
23645            );
23646            assert_eq!(
23647                node.properties.get("language"),
23648                Some(&language.to_string()),
23649                "{name} should keep its language label"
23650            );
23651        }
23652
23653        let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
23654        let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
23655        let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
23656        assert!(graph.edges.iter().any(|edge| {
23657            edge.from == markdown_section.handle
23658                && edge.to == markdown_code.handle
23659                && edge.relation == "contains_markdown_block"
23660        }));
23661        assert!(graph.edges.iter().any(|edge| {
23662            edge.from == markdown_code.handle
23663                && edge.to == embedded.handle
23664                && edge.relation == "contains_embedded_symbol"
23665        }));
23666        assert!(
23667            graph.nodes.len() <= 80,
23668            "multilingual AST fixture should stay bounded, got {} nodes",
23669            graph.nodes.len()
23670        );
23671        assert!(
23672            graph.edges.len() <= 180,
23673            "multilingual AST fixture should stay bounded, got {} edges",
23674            graph.edges.len()
23675        );
23676
23677        let response = empty_search_response(dir.path(), "lexical");
23678        let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
23679        let report = build_relative_search_budget_report(
23680            "fixture_nav_python_entry",
23681            "lexical",
23682            dir.path(),
23683            &response,
23684            &symbol_hits,
23685            ResponseBudget::new(Some(8), Some(120)),
23686            &SearchFacetFilters::default(),
23687        );
23688        let report_again = build_relative_search_budget_report(
23689            "fixture_nav_python_entry",
23690            "lexical",
23691            dir.path(),
23692            &response,
23693            &symbol_hits,
23694            ResponseBudget::new(Some(8), Some(120)),
23695            &SearchFacetFilters::default(),
23696        );
23697
23698        let top = report
23699            .ranked
23700            .first()
23701            .expect("ranked preview should not be empty");
23702        assert_eq!(top.source, "symbol_span");
23703        assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
23704        assert!(top.handle.starts_with("srnk-"));
23705        assert_eq!(top.handle, report_again.ranked[0].handle);
23706        assert!(
23707            top.reasons.iter().any(|reason| reason == "ast_span"),
23708            "expected AST span ranking reason, got {:?}",
23709            top.reasons
23710        );
23711        assert!(report.ranked.len() <= 8);
23712        assert!(report.symbols.len() <= 8);
23713
23714        let symbol = report
23715            .symbols
23716            .iter()
23717            .find(|symbol| symbol.name == "fixture_nav_python_entry")
23718            .expect("missing search preview symbol");
23719        assert_cli_expand_command_parses(&symbol.expand);
23720        let ast = symbol
23721            .ast
23722            .as_ref()
23723            .expect("search symbol should expose AST");
23724        assert_cli_expand_command_parses(&ast.expand.source_window);
23725        assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
23726        assert_cli_expand_command_parses(&ast.expand.symbol_read);
23727
23728        let markdown_hits = db.symbol_search("python", 20).unwrap();
23729        let markdown_report = build_relative_search_budget_report(
23730            "python",
23731            "lexical",
23732            dir.path(),
23733            &response,
23734            &markdown_hits,
23735            ResponseBudget::new(Some(8), Some(120)),
23736            &SearchFacetFilters::default(),
23737        );
23738        let markdown_symbol = markdown_report
23739            .symbols
23740            .iter()
23741            .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
23742            .expect("missing Markdown code-block symbol");
23743        let markdown_ast = markdown_symbol
23744            .ast
23745            .as_ref()
23746            .expect("Markdown code block should expose AST");
23747        assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
23748        assert_eq!(
23749            markdown_ast
23750                .span
23751                .markdown
23752                .as_ref()
23753                .unwrap()
23754                .embedded_symbols[0]
23755                .name,
23756            "fixture_nav_markdown_embedded"
23757        );
23758    }
23759
23760    #[test]
23761    fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
23762        let edges = vec![
23763            TraversalEdge {
23764                from: "origin".to_string(),
23765                to: "aaa_low".to_string(),
23766                relation: "unknown".to_string(),
23767                label: None,
23768                weight: 1,
23769            },
23770            TraversalEdge {
23771                from: "origin".to_string(),
23772                to: "zzz_high".to_string(),
23773                relation: "mentions".to_string(),
23774                label: None,
23775                weight: 1,
23776            },
23777        ];
23778
23779        let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
23780
23781        assert!(handles.contains("origin"));
23782        assert!(handles.contains("zzz_high"), "{handles:?}");
23783        assert!(!handles.contains("aaa_low"), "{handles:?}");
23784    }
23785
23786    #[test]
23787    fn traversal_materializes_provider_neutral_sqlite_graph() {
23788        let dir = setup_traversal_project();
23789        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23790        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23791
23792        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23793        let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
23794        assert!(
23795            backlog_nodes.iter().any(|node| node.id == backlog.handle
23796                && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23797            "expected materialized backlog node, got {backlog_nodes:?}"
23798        );
23799        assert!(
23800            store
23801                .all_nodes()
23802                .unwrap()
23803                .iter()
23804                .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
23805                    && node.properties.get("projection_version")
23806                        == Some(&GRAPH_PROJECTION_VERSION.to_string())),
23807            "expected projection metadata node"
23808        );
23809        let source_handles = store.nodes_by_kind("source_handle").unwrap();
23810        assert!(
23811            source_handles
23812                .iter()
23813                .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
23814            "expected bounded source_handle rows, got {source_handles:?}"
23815        );
23816        let worker_context = store.nodes_by_kind("worker_context").unwrap();
23817        assert!(
23818            worker_context
23819                .iter()
23820                .any(|node| node.properties.get("target")
23821                    == Some(&"tasks/software/tsift.md".to_string())),
23822            "expected bounded worker_context rows, got {worker_context:?}"
23823        );
23824        let worker_results = store.nodes_by_kind("worker_result").unwrap();
23825        assert!(
23826            worker_results.iter().any(|node| {
23827                node.properties.get("ref_id") == Some(&"kgnv".to_string())
23828                    && node.properties.get("status") == Some(&"completed".to_string())
23829                    && node.properties.get("touched_files") == Some(&"main.rs".to_string())
23830                    && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
23831            }),
23832            "expected worker_result rows, got {worker_results:?}"
23833        );
23834    }
23835
23836    #[test]
23837    fn traversal_projection_materializes_cached_semantic_rows() {
23838        let dir = setup_traversal_project();
23839        seed_traversal_semantic_summaries(dir.path());
23840        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23841        let helper = resolve_traversal_node(&graph, "helper").unwrap();
23842        let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
23843        let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
23844
23845        assert_eq!(concept.kind, "semantic_concept");
23846        assert_eq!(entity.kind, "semantic_entity");
23847        assert!(concept.handle.starts_with("gcon-"));
23848        assert!(entity.handle.starts_with("gent-"));
23849
23850        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23851        assert!(
23852            store
23853                .nodes_by_kind("semantic_concept")
23854                .unwrap()
23855                .iter()
23856                .any(|node| node.label == "semantic extraction"
23857                    && node.properties.contains_key("embedding")),
23858            "expected persisted concept embeddings"
23859        );
23860        assert!(
23861            store
23862                .outgoing_edges(&helper.handle, Some("mentions_concept"))
23863                .unwrap()
23864                .iter()
23865                .any(|edge| edge.to_id == concept.handle),
23866            "expected helper symbol to link to cached summary concept"
23867        );
23868        assert!(
23869            store
23870                .outgoing_edges(
23871                    &semantic_entity_handle("helper", "function"),
23872                    Some("semantic_relation")
23873                )
23874                .unwrap()
23875                .iter()
23876                .any(|edge| edge.to_id == entity.handle
23877                    && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
23878            "expected LLM relationship rows projected into GraphStore"
23879        );
23880    }
23881
23882    #[test]
23883    fn traversal_projection_materializes_tsift_memory_rows() {
23884        let dir = setup_traversal_project();
23885        seed_tsift_memory_graph_db(dir.path());
23886        let memory_db = dir.path().join(".tsift").join("memory.db");
23887        let store = MemoryStore::open_or_create(&memory_db).unwrap();
23888        for summary in ["first closeout", "second closeout"] {
23889            let event = MemoryEvent::new(
23890                MemoryEventKind::ResponseSummary,
23891                "tasks/software/tsift.md",
23892                summary,
23893            )
23894            .with_session_id("tasks/software/tsift.md")
23895            .with_observed_at_unix(1_700_000_100);
23896            store.insert_event(&event).unwrap();
23897        }
23898        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23899        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23900
23901        let native_sources = store
23902            .nodes_by_kind("source_handle")
23903            .unwrap()
23904            .into_iter()
23905            .filter(|node| {
23906                node.properties.get("provider") == Some(&"tsift-memory".to_string())
23907                    && node.properties.get("source_ref")
23908                        == Some(&"tasks/software/tsift.md".to_string())
23909            })
23910            .collect::<Vec<_>>();
23911        assert_eq!(
23912            native_sources.len(),
23913            2,
23914            "same-source native memory events must get distinct source handles"
23915        );
23916
23917        let source = store
23918            .nodes_by_kind("source_handle")
23919            .unwrap()
23920            .into_iter()
23921            .find(|node| {
23922                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23923            })
23924            .expect("expected tsift-memory source handle");
23925        let session = store
23926            .nodes_by_kind("memory_session")
23927            .unwrap()
23928            .into_iter()
23929            .find(|node| {
23930                node.properties.get("provider") == Some(&"tsift-memory".to_string())
23931                    && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
23932            })
23933            .expect("expected tsift-memory session node");
23934        let event = store
23935            .nodes_by_kind("memory_event")
23936            .unwrap()
23937            .into_iter()
23938            .find(|node| {
23939                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23940                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
23941                    && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
23942            })
23943            .expect("expected tsift-memory event node");
23944        let concept = store
23945            .nodes_by_kind("semantic_concept")
23946            .unwrap()
23947            .into_iter()
23948            .find(|node| {
23949                node.properties.get("provider") == Some(&"tsift-memory".to_string())
23950                    && node.label.contains("Graph memory adapter")
23951                    && node.properties.contains_key("embedding")
23952            })
23953            .expect("expected tsift-memory semantic concept");
23954
23955        assert!(
23956            store
23957                .outgoing_edges(&session.id, Some("records_memory_source"))
23958                .unwrap()
23959                .iter()
23960                .any(|edge| edge.to_id == source.id),
23961            "expected session to link to source handle"
23962        );
23963        assert!(
23964            store
23965                .outgoing_edges(&session.id, Some("records_memory_event"))
23966                .unwrap()
23967                .iter()
23968                .any(|edge| edge.to_id == event.id),
23969            "expected session to link to memory event"
23970        );
23971        assert!(
23972            store
23973                .outgoing_edges(&event.id, Some("projects_source"))
23974                .unwrap()
23975                .iter()
23976                .any(|edge| edge.to_id == source.id),
23977            "expected memory event to project source handle"
23978        );
23979        assert!(
23980            store
23981                .outgoing_edges(&source.id, Some("mentions_concept"))
23982                .unwrap()
23983                .iter()
23984                .any(|edge| edge.to_id == concept.id),
23985            "expected source handle to seed semantic concept"
23986        );
23987
23988        let related = semantic_related_report_from_store(
23989            dir.path(),
23990            None,
23991            "tsift memory graph adapter",
23992            5,
23993            SemanticRelatedKind::Concept,
23994            &store,
23995        )
23996        .unwrap();
23997        assert!(
23998            related
23999                .items
24000                .iter()
24001                .any(|item| item.handle == concept.id && item.score > 0.0),
24002            "expected semantic query to retrieve tsift-memory concept, got {:?}",
24003            related.items
24004        );
24005
24006        let graph_related = graph_db_report_from_store(
24007            dir.path(),
24008            None,
24009            "sqlite",
24010            GraphDbQuery::Related {
24011                query: "tsift memory graph adapter".to_string(),
24012                kind: SemanticRelatedKind::Concept,
24013                depth: 1,
24014                seed_limit: 5,
24015                limit: 20,
24016            },
24017            &store,
24018            sqlite_graph_freshness(&store, "root").unwrap(),
24019            Vec::new(),
24020        )
24021        .unwrap();
24022        assert_eq!(
24023            graph_related
24024                .readiness
24025                .as_ref()
24026                .map(|readiness| readiness.status.as_str()),
24027            Some("ready"),
24028            "tsift-memory semantic rows should satisfy graph-db related readiness"
24029        );
24030        assert!(
24031            graph_related.nodes.iter().any(|node| {
24032                node.kind == "semantic_concept"
24033                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24034            }),
24035            "expected related graph output to include tsift-memory semantic rows"
24036        );
24037    }
24038
24039    #[test]
24040    fn semantic_related_query_uses_persisted_graph_embeddings() {
24041        let dir = setup_traversal_project();
24042        seed_traversal_semantic_summaries(dir.path());
24043        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24044        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24045        let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
24046            .unwrap()
24047            .query_row(
24048                "SELECT COUNT(*) FROM graph_node_semantic_vectors",
24049                [],
24050                |row| row.get(0),
24051            )
24052            .unwrap();
24053        assert!(semantic_vector_rows > 0);
24054
24055        let report = semantic_related_report_from_store(
24056            dir.path(),
24057            None,
24058            "graph navigation",
24059            5,
24060            SemanticRelatedKind::Concept,
24061            &store,
24062        )
24063        .unwrap();
24064
24065        assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
24066        assert!(
24067            report
24068                .items
24069                .iter()
24070                .any(|item| item.label == "graph navigation"
24071                    && item.kind == "semantic_concept"
24072                    && item.score > 0.9),
24073            "expected nearest concept match from graph embeddings, got {:?}",
24074            report.items
24075        );
24076    }
24077
24078    #[test]
24079    fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
24080        let dir = setup_traversal_project();
24081        seed_traversal_semantic_summaries(dir.path());
24082        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24083        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24084
24085        let report = graph_db_report_from_store(
24086            dir.path(),
24087            None,
24088            "sqlite",
24089            GraphDbQuery::Related {
24090                query: "graph navigation".to_string(),
24091                kind: SemanticRelatedKind::All,
24092                depth: 1,
24093                seed_limit: 2,
24094                limit: 20,
24095            },
24096            &store,
24097            sqlite_graph_freshness(&store, "root").unwrap(),
24098            Vec::new(),
24099        )
24100        .unwrap();
24101
24102        let knowledge = report.knowledge_retrieval.as_ref().unwrap();
24103        assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
24104        assert_eq!(knowledge.seed_kind, "all");
24105        assert_eq!(knowledge.depth, 1);
24106        assert_eq!(
24107            report
24108                .readiness
24109                .as_ref()
24110                .map(|readiness| readiness.status.as_str()),
24111            Some("ready")
24112        );
24113        assert!(
24114            knowledge
24115                .diagnostics
24116                .iter()
24117                .any(|diagnostic| diagnostic.contains("incident"))
24118        );
24119        assert!(
24120            report
24121                .semantic_related
24122                .iter()
24123                .any(|item| item.label == "graph navigation"
24124                    && item.kind == "semantic_concept"
24125                    && item.score > 0.9),
24126            "expected natural-language query to seed the graph navigation concept, got {:?}",
24127            report.semantic_related
24128        );
24129        assert!(
24130            report
24131                .nodes
24132                .iter()
24133                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
24134        );
24135        assert!(
24136            report
24137                .nodes
24138                .iter()
24139                .any(|node| node.kind == "symbol" && node.label == "helper"),
24140            "incident expansion from semantic seed should recover source symbols, got {:?}",
24141            report
24142                .nodes
24143                .iter()
24144                .map(|node| (&node.kind, &node.label))
24145                .collect::<Vec<_>>()
24146        );
24147        assert!(
24148            report
24149                .edges
24150                .iter()
24151                .any(|edge| edge.kind == "mentions_concept")
24152        );
24153        assert!(
24154            report.output_budget.as_ref().is_some_and(|budget| budget
24155                .diagnostics
24156                .iter()
24157                .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
24158            "expected related output budget diagnostics, got {:?}",
24159            report.output_budget
24160        );
24161    }
24162
24163    #[test]
24164    fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
24165        let dir = setup_graph_index();
24166        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24167        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24168
24169        let report = graph_db_report_from_store(
24170            dir.path(),
24171            None,
24172            "sqlite",
24173            GraphDbQuery::Related {
24174                query: "graph navigation".to_string(),
24175                kind: SemanticRelatedKind::All,
24176                depth: 1,
24177                seed_limit: 2,
24178                limit: 20,
24179            },
24180            &store,
24181            sqlite_graph_freshness(&store, "root").unwrap(),
24182            Vec::new(),
24183        )
24184        .unwrap();
24185
24186        let readiness = report.readiness.as_ref().unwrap();
24187        assert_eq!(readiness.status, "blocked");
24188        assert_eq!(readiness.reason, "summary_cache_empty");
24189        assert!(readiness.fail_closed);
24190        assert_eq!(
24191            readiness.next_commands,
24192            vec![
24193                "tsift summarize --extract .".to_string(),
24194                graph_db_refresh_command(dir.path(), None)
24195            ]
24196        );
24197        assert!(
24198            report
24199                .knowledge_retrieval
24200                .as_ref()
24201                .unwrap()
24202                .diagnostics
24203                .iter()
24204                .any(|diagnostic| diagnostic.contains("summary cache empty")
24205                    && diagnostic.contains("graph-db materialized code/session rows")),
24206            "expected related diagnostics to carry readiness gate, got {:?}",
24207            report.knowledge_retrieval.as_ref().unwrap().diagnostics
24208        );
24209    }
24210
24211    #[test]
24212    fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
24213        let mut nodes = vec![
24214            SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
24215            SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
24216        ];
24217        let mut edges = vec![SubstrateGraphEdge::new(
24218            "zzz_high",
24219            "seed",
24220            "mentions_concept",
24221        )];
24222        for idx in 0..24 {
24223            let id = format!("aaa_low_{idx:02}");
24224            nodes.push(SubstrateGraphNode::new(
24225                id.clone(),
24226                "note",
24227                format!("low {idx}"),
24228            ));
24229            edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
24230        }
24231        let mut store = SqliteGraphStore::in_memory().unwrap();
24232        store
24233            .replace_projection(&GraphProjection { nodes, edges })
24234            .unwrap();
24235
24236        let subgraph =
24237            graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
24238
24239        assert_eq!(subgraph.nodes.len(), 3);
24240        assert_eq!(subgraph.nodes[0].id, "seed");
24241        assert_eq!(
24242            subgraph.nodes[1].id, "zzz_high",
24243            "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
24244            subgraph.nodes
24245        );
24246        assert!(subgraph.truncated);
24247        assert!(
24248            subgraph
24249                .diagnostics
24250                .iter()
24251                .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
24252            "{:?}",
24253            subgraph.diagnostics
24254        );
24255        assert!(
24256            subgraph
24257                .diagnostics
24258                .iter()
24259                .any(|diagnostic| diagnostic.contains("skipped")),
24260            "{:?}",
24261            subgraph.diagnostics
24262        );
24263    }
24264
24265    #[test]
24266    fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
24267        let dir = setup_traversal_project();
24268        seed_traversal_semantic_summaries(dir.path());
24269        init_git_repo(dir.path());
24270        let session = dir.path().join("tasks/software/tsift.md");
24271        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24272        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24273        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24274        let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24275            root: dir.path(),
24276            scope: None,
24277            backend: "sqlite",
24278            target: "kgnv",
24279            preferred_path: None,
24280            depth: 4,
24281            limit: 8,
24282            cursor: None,
24283            store: &store,
24284            freshness,
24285            warnings: Vec::new(),
24286        })
24287        .unwrap();
24288        assert!(
24289            evidence
24290                .semantic_related
24291                .iter()
24292                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
24293            "expected semantic evidence rows, got {:?}",
24294            evidence
24295                .semantic_related
24296                .iter()
24297                .map(|node| (&node.kind, &node.label))
24298                .collect::<Vec<_>>()
24299        );
24300        assert!(
24301            evidence
24302                .output_budget
24303                .as_ref()
24304                .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
24305                    diagnostic.contains("semantic_match")
24306                        && diagnostic.contains("source_handle_coverage")
24307                })),
24308            "expected evidence output budget diagnostics, got {:?}",
24309            evidence.output_budget
24310        );
24311
24312        let cached_diff = diff_digest::compute(
24313            dir.path(),
24314            diff_digest::DiffDigestOptions {
24315                cached: true,
24316                revision: None,
24317                max_parsed_files: None,
24318            },
24319        )
24320        .unwrap();
24321        let impact_report = impact::compute(
24322            dir.path(),
24323            impact::ImpactOptions {
24324                cached: true,
24325                revision: None,
24326                scope: None,
24327                limit: 10,
24328            },
24329        )
24330        .unwrap();
24331        let graph_nodes = store.all_nodes().unwrap();
24332        let graph_index = conflict_matrix_graph_index(&graph_nodes);
24333        let semantic_candidate = conflict_matrix_candidate_from_evidence(
24334            dir.path(),
24335            &evidence,
24336            &graph_index,
24337            &cached_diff,
24338            &impact_report,
24339        );
24340        assert!(semantic_candidate.semantic_dispatch_score > 0);
24341        assert!(
24342            semantic_candidate
24343                .semantic_dispatch_reasons
24344                .iter()
24345                .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
24346            "expected semantic ranking explanations, got {:?}",
24347            semantic_candidate.semantic_dispatch_reasons
24348        );
24349        assert!(
24350            semantic_candidate
24351                .semantic_related
24352                .iter()
24353                .any(|item| item.label == "graph navigation")
24354        );
24355
24356        let mut plain_candidate = semantic_candidate.clone();
24357        plain_candidate.target = "plain".to_string();
24358        plain_candidate.semantic_related.clear();
24359        plain_candidate.semantic_dispatch_score = 0;
24360        plain_candidate.semantic_dispatch_reasons.clear();
24361        let mut ranked = [plain_candidate, semantic_candidate];
24362        ranked.sort_by(|left, right| {
24363            left.risk
24364                .cmp(&right.risk)
24365                .then_with(|| left.risk_score.cmp(&right.risk_score))
24366                .then_with(|| {
24367                    right
24368                        .semantic_dispatch_score
24369                        .cmp(&left.semantic_dispatch_score)
24370                })
24371                .then_with(|| left.target.cmp(&right.target))
24372        });
24373        assert_eq!(ranked[0].target, "kgnv");
24374    }
24375
24376    #[test]
24377    fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
24378        let dir = setup_dependency_dag_project();
24379        let session = dir.path().join("tasks/software/tsift.md");
24380        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24381
24382        assert_eq!(report.contract_version, "dependency-dag-v1");
24383        assert_eq!(
24384            report.targets,
24385            vec![
24386                "prep".to_string(),
24387                "alpha".to_string(),
24388                "beta".to_string(),
24389                "gamma".to_string()
24390            ]
24391        );
24392        assert!(report.edges.iter().any(|edge| {
24393            edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
24394        }));
24395        assert!(report.edges.iter().any(|edge| {
24396            edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
24397        }));
24398        assert!(report.edges.iter().any(|edge| {
24399            edge.from == "alpha"
24400                && edge.to == "beta"
24401                && edge.kind == "shared_resource"
24402                && edge.shared_files.contains(&"main.rs".to_string())
24403                && edge.shared_symbols.contains(&"shared_helper".to_string())
24404        }));
24405        assert!(
24406            !report.cycle_diagnostics.has_cycles,
24407            "{:?}",
24408            report.cycle_diagnostics
24409        );
24410        assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
24411        assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
24412        assert!(
24413            report.replay_commands[0].contains("dependency-dag"),
24414            "{:?}",
24415            report.replay_commands
24416        );
24417
24418        cmd_dependency_dag(
24419            &session,
24420            None,
24421            &["alpha".to_string(), "beta".to_string()],
24422            4,
24423            12,
24424            OutputFormat {
24425                json_output: true,
24426                compact: false,
24427                pretty: false,
24428                terse: false,
24429                ultra_terse: false,
24430                schema: false,
24431                envelope: false,
24432            },
24433        )
24434        .unwrap();
24435    }
24436
24437    #[test]
24438    fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
24439        let dir = setup_dependency_dag_cycle_project();
24440        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24441
24442        assert!(report.cycle_diagnostics.has_cycles);
24443        assert_eq!(
24444            report.cycle_diagnostics.blocked_nodes,
24445            vec!["left".to_string(), "right".to_string()]
24446        );
24447        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24448            edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
24449        }));
24450        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24451            edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
24452        }));
24453    }
24454
24455    #[test]
24456    fn traversal_projection_queries_match_sqlite_and_convex_stores() {
24457        let dir = setup_traversal_project();
24458        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24459        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
24460
24461        let mut sqlite = SqliteGraphStore::in_memory().unwrap();
24462        sqlite.replace_projection(&projection).unwrap();
24463        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24464        projection.upsert_into(&convex).unwrap();
24465
24466        let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
24467        let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
24468        assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
24469        assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
24470
24471        let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
24472        let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
24473        assert!(convex_graph.edges.iter().any(|edge| {
24474            edge.from == sqlite_backlog.handle
24475                && edge.to == convex_helper.handle
24476                && edge.relation == "mentions"
24477        }));
24478    }
24479
24480    #[test]
24481    fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
24482        let dir = setup_traversal_project();
24483        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24484        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24485        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24486        assert_eq!(freshness.status, "current");
24487
24488        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24489        let report = graph_db_report_from_store(
24490            dir.path(),
24491            None,
24492            "sqlite",
24493            GraphDbQuery::Neighborhood {
24494                id: backlog.handle.clone(),
24495                depth: 1,
24496                edge_kind: Some("mentions".to_string()),
24497                cursor: None,
24498                limit: None,
24499                property_filters: Vec::new(),
24500            },
24501            &store,
24502            freshness,
24503            Vec::new(),
24504        )
24505        .unwrap();
24506        assert!(
24507            report
24508                .edges
24509                .iter()
24510                .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
24511            "expected backlog mention edge, got {:?}",
24512            report.edges
24513        );
24514        assert!(
24515            report.ranked_neighbors.iter().any(|neighbor| {
24516                neighbor.depth == Some(1)
24517                    && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
24518                    && neighbor.node_id != backlog.handle
24519                    && neighbor.handle_coverage_pct >= 95.0
24520                    && neighbor.duplicate_name_precision >= 0.99
24521            }),
24522            "expected ranked neighborhood neighbors with quality scores, got {:?}",
24523            report.ranked_neighbors
24524        );
24525        assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
24526        let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
24527        assert!(!ranking_gate.ranked_output_default);
24528        assert_eq!(ranking_gate.default_order, "stable_node_id");
24529        assert!(
24530            ranking_gate
24531                .diagnostics
24532                .iter()
24533                .any(|diagnostic| diagnostic.contains("score-capped")),
24534            "{ranking_gate:?}"
24535        );
24536        assert!(
24537            ranking_gate
24538                .required_metrics
24539                .iter()
24540                .any(|metric| metric == "handle_coverage_pct")
24541        );
24542        assert!(
24543            ranking_gate
24544                .required_metrics
24545                .iter()
24546                .any(|metric| metric == "duplicate_name_precision")
24547        );
24548        assert!(
24549            report
24550                .page
24551                .as_ref()
24552                .unwrap()
24553                .diagnostics
24554                .iter()
24555                .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
24556            "expected SQLite neighborhood query plan diagnostics, got {:?}",
24557            report.page.as_ref().unwrap().diagnostics
24558        );
24559        let edges_report = graph_db_report_from_store(
24560            dir.path(),
24561            None,
24562            "sqlite",
24563            GraphDbQuery::Edges {
24564                edge_kind: Some("mentions".to_string()),
24565                cursor: None,
24566                limit: Some(2),
24567                property_filters: Vec::new(),
24568            },
24569            &store,
24570            sqlite_graph_freshness(&store, "root").unwrap(),
24571            Vec::new(),
24572        )
24573        .unwrap();
24574        let edge_id = edges_report
24575            .edges
24576            .first()
24577            .map(|edge| edge.id.clone())
24578            .expect("expected at least one paged mentions edge");
24579        assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
24580        assert_eq!(
24581            edges_report.page.as_ref().unwrap().returned_edges,
24582            edges_report.edges.len()
24583        );
24584
24585        let edge_report = graph_db_report_from_store(
24586            dir.path(),
24587            None,
24588            "sqlite",
24589            GraphDbQuery::Edge {
24590                id: edge_id.clone(),
24591            },
24592            &store,
24593            sqlite_graph_freshness(&store, "root").unwrap(),
24594            Vec::new(),
24595        )
24596        .unwrap();
24597        assert_eq!(
24598            edge_report
24599                .edge
24600                .as_ref()
24601                .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
24602            Some(edge_id.clone())
24603        );
24604
24605        let incident_report = graph_db_report_from_store(
24606            dir.path(),
24607            None,
24608            "sqlite",
24609            GraphDbQuery::Incident {
24610                id: backlog.handle.clone(),
24611                edge_kind: Some("mentions".to_string()),
24612                cursor: None,
24613                limit: Some(1),
24614                property_filters: Vec::new(),
24615            },
24616            &store,
24617            sqlite_graph_freshness(&store, "root").unwrap(),
24618            Vec::new(),
24619        )
24620        .unwrap();
24621        assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
24622        assert!(
24623            incident_report
24624                .edges
24625                .iter()
24626                .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
24627            "{:?}",
24628            incident_report.edges
24629        );
24630
24631        let schema_report = graph_db_report_from_store(
24632            dir.path(),
24633            None,
24634            "sqlite",
24635            GraphDbQuery::Schema,
24636            &store,
24637            sqlite_graph_freshness(&store, "root").unwrap(),
24638            Vec::new(),
24639        )
24640        .unwrap();
24641        assert!(
24642            schema_report
24643                .schema
24644                .unwrap()
24645                .operations
24646                .iter()
24647                .any(|operation| operation.command.starts_with("neighborhood"))
24648        );
24649    }
24650
24651    #[test]
24652    fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
24653        let mut nodes = vec![SubstrateGraphNode::new(
24654            "origin",
24655            "backlog",
24656            "#budgeted-neighborhood",
24657        )];
24658        let mut edges = Vec::new();
24659        for idx in 0..32 {
24660            let id = format!("src-{idx:02}");
24661            nodes.push(
24662                SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
24663                    .with_property("source_ref", format!("fixture:{idx}"))
24664                    .with_property("detail", "x".repeat(600)),
24665            );
24666            edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
24667        }
24668        let store = SqliteGraphStore::in_memory().unwrap();
24669        GraphProjection { nodes, edges }
24670            .upsert_into(&store)
24671            .unwrap();
24672
24673        let report = graph_db_report_from_store(
24674            Path::new("."),
24675            None,
24676            "fixture",
24677            GraphDbQuery::Neighborhood {
24678                id: "origin".to_string(),
24679                depth: 1,
24680                edge_kind: None,
24681                cursor: None,
24682                limit: None,
24683                property_filters: Vec::new(),
24684            },
24685            &store,
24686            current_graph_db_freshness(),
24687            Vec::new(),
24688        )
24689        .unwrap();
24690        let budget = report.output_budget.as_ref().unwrap();
24691        assert!(budget.selected_nodes < budget.candidate_nodes);
24692        assert!(
24693            budget.dropped_by_budget.iter().any(|drop| {
24694                drop.item == "node"
24695                    && drop.kind == "source_handle"
24696                    && drop.reason == "per_kind_quota"
24697            }),
24698            "expected source_handle budget drops, got {:?}",
24699            budget.dropped_by_budget
24700        );
24701        assert!(report.page.as_ref().unwrap().truncated);
24702        assert!(
24703            report
24704                .page
24705                .as_ref()
24706                .unwrap()
24707                .diagnostics
24708                .iter()
24709                .any(|diagnostic| diagnostic.contains("budget ranking signals")),
24710            "{:?}",
24711            report.page
24712        );
24713    }
24714
24715    #[test]
24716    fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
24717        let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
24718        let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
24719        for idx in 0..8 {
24720            let id = format!("far-{idx:02}");
24721            nodes.push(SubstrateGraphNode::new(
24722                id.clone(),
24723                "note",
24724                format!("aaa deeper row {idx}"),
24725            ));
24726            depth_by_id.insert(id, 6);
24727        }
24728
24729        let origin_ids = vec!["target".to_string()];
24730        let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
24731            &origin_ids,
24732            &BTreeMap::new(),
24733            nodes,
24734            Vec::new(),
24735            Some(3),
24736            Some(&depth_by_id),
24737            None,
24738        );
24739
24740        assert!(
24741            budgeted.nodes.iter().any(|node| node.id == "near"),
24742            "expected the shallow evidence row to outrank deeper rows, got {:?}",
24743            budgeted
24744                .nodes
24745                .iter()
24746                .map(|node| (&node.id, &node.label))
24747                .collect::<Vec<_>>()
24748        );
24749        assert!(
24750            budgeted.report.dropped_by_budget.iter().any(|drop| {
24751                drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
24752            }),
24753            "expected node quota drops, got {:?}",
24754            budgeted.report.dropped_by_budget
24755        );
24756        assert!(
24757            budgeted
24758                .report
24759                .diagnostics
24760                .iter()
24761                .any(|diagnostic| diagnostic.contains("depth")),
24762            "{:?}",
24763            budgeted.report.diagnostics
24764        );
24765    }
24766
24767    #[test]
24768    fn evidence_pagination_returns_next_cursor_when_truncated() {
24769        let mut nodes = vec![SubstrateGraphNode::new(
24770            "target".to_string(),
24771            "backlog_item",
24772            "target item".to_string(),
24773        )];
24774        let mut depth_by_id = BTreeMap::new();
24775        depth_by_id.insert("target".to_string(), 0);
24776        for idx in 0..20 {
24777            let id = format!("ev-{idx}");
24778            nodes.push(
24779                SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
24780                    .with_property("detail", "x".repeat(400)),
24781            );
24782            depth_by_id.insert(id, 1);
24783        }
24784        let origin_ids = vec!["target".to_string()];
24785        let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
24786            &origin_ids,
24787            &BTreeMap::new(),
24788            nodes.clone(),
24789            Vec::new(),
24790            Some(3),
24791            Some(&depth_by_id),
24792            None,
24793        );
24794        assert!(
24795            first_page.truncated,
24796            "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
24797            first_page.nodes.len(),
24798            first_page.report.candidate_nodes
24799        );
24800        assert!(
24801            first_page.next_cursor.is_some(),
24802            "expected next_cursor when truncated"
24803        );
24804        let cursor = first_page.next_cursor.unwrap();
24805        assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
24806        let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
24807        let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
24808            &origin_ids,
24809            &BTreeMap::new(),
24810            nodes.clone(),
24811            Vec::new(),
24812            Some(3),
24813            Some(&depth_by_id),
24814            Some(&cursor),
24815        );
24816        let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
24817        let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
24818        assert!(
24819            overlap.is_empty(),
24820            "pages should not overlap, but found shared ids: {overlap:?}"
24821        );
24822        assert!(
24823            second_page
24824                .report
24825                .diagnostics
24826                .iter()
24827                .any(|d| d.contains("cursor skipped")),
24828            "expected cursor skip diagnostic, got {:?}",
24829            second_page.report.diagnostics
24830        );
24831    }
24832
24833    #[test]
24834    fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
24835        let mut nodes = vec![SubstrateGraphNode::new(
24836            "target".to_string(),
24837            "backlog_item",
24838            "target item".to_string(),
24839        )];
24840        let mut depth_by_id = BTreeMap::new();
24841        depth_by_id.insert("target".to_string(), 0);
24842        for idx in 0..3 {
24843            let id = format!("ev-{idx}");
24844            nodes.push(SubstrateGraphNode::new(
24845                id.clone(),
24846                "source_handle",
24847                format!("evidence row {idx}"),
24848            ));
24849            depth_by_id.insert(id, 1);
24850        }
24851        let origin_ids = vec!["target".to_string()];
24852        let result = graph_db_apply_output_budget_with_depths_and_cursor(
24853            &origin_ids,
24854            &BTreeMap::new(),
24855            nodes,
24856            Vec::new(),
24857            None,
24858            Some(&depth_by_id),
24859            None,
24860        );
24861        assert!(
24862            !result.truncated,
24863            "expected no truncation with small candidate set and default budget"
24864        );
24865        assert!(
24866            result.next_cursor.is_none(),
24867            "expected no next_cursor when not truncated"
24868        );
24869    }
24870
24871    #[test]
24872    fn evidence_pagination_invalid_cursor_returns_first_page() {
24873        let mut nodes = vec![SubstrateGraphNode::new(
24874            "target".to_string(),
24875            "backlog_item",
24876            "target item".to_string(),
24877        )];
24878        let mut depth_by_id = BTreeMap::new();
24879        depth_by_id.insert("target".to_string(), 0);
24880        for idx in 0..5 {
24881            let id = format!("ev-{idx}");
24882            nodes.push(SubstrateGraphNode::new(
24883                id.clone(),
24884                "source_handle",
24885                format!("evidence row {idx}"),
24886            ));
24887            depth_by_id.insert(id, 1);
24888        }
24889        let origin_ids = vec!["target".to_string()];
24890        let result = graph_db_apply_output_budget_with_depths_and_cursor(
24891            &origin_ids,
24892            &BTreeMap::new(),
24893            nodes.clone(),
24894            Vec::new(),
24895            None,
24896            Some(&depth_by_id),
24897            Some("nonexistent-id"),
24898        );
24899        assert!(
24900            result
24901                .report
24902                .diagnostics
24903                .iter()
24904                .any(|d| d.contains("cursor skipped 0")),
24905            "invalid cursor should skip 0 candidates, got {:?}",
24906            result.report.diagnostics
24907        );
24908    }
24909
24910    #[test]
24911    fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
24912        let dir = setup_traversal_project();
24913        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24914        let graph_db = dir.path().join(".tsift/graph.db");
24915        let _lock = hold_rollback_journal_lock(&graph_db);
24916
24917        let report =
24918            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24919                .unwrap();
24920
24921        assert_eq!(report.status, "current");
24922        assert_eq!(
24923            report.recovery,
24924            Some(index::ReadOnlyRecovery::SnapshotFallback)
24925        );
24926        assert!(
24927            report
24928                .warnings
24929                .iter()
24930                .any(|warning| warning.contains("rollback-journal lock")),
24931            "expected rollback-journal recovery warning, got {:?}",
24932            report.warnings
24933        );
24934    }
24935
24936    #[test]
24937    fn graph_db_status_copies_wal_sidecars_when_locked() {
24938        let dir = setup_traversal_project();
24939        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24940        let graph_db = dir.path().join(".tsift/graph.db");
24941        let _lock = hold_wal_database_lock(&graph_db);
24942
24943        let report =
24944            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24945                .unwrap();
24946
24947        assert_eq!(report.status, "current");
24948        assert_eq!(
24949            report.recovery,
24950            Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
24951        );
24952        assert!(
24953            report
24954                .warnings
24955                .iter()
24956                .any(|warning| warning.contains("WAL-aware snapshot fallback")),
24957            "expected WAL recovery warning, got {:?}",
24958            report.warnings
24959        );
24960    }
24961
24962    #[test]
24963    fn graph_db_doctor_reports_snapshot_fallback_when_rollback_journal_is_locked() {
24964        let dir = setup_traversal_project();
24965        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24966        let graph_db = dir.path().join(".tsift/graph.db");
24967        let _lock = hold_rollback_journal_lock(&graph_db);
24968
24969        let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
24970        append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
24971        report.finalize();
24972
24973        assert_eq!(report.status, "ok");
24974        assert!(!report.fail_closed);
24975        let recovery_check = report
24976            .checks
24977            .iter()
24978            .find(|check| check.name == "sqlite_graph_db_read_recovery")
24979            .expect("doctor should include read recovery diagnostic");
24980        assert_eq!(recovery_check.status, "recovered");
24981        assert!(
24982            recovery_check
24983                .diagnostics
24984                .iter()
24985                .any(|diagnostic| diagnostic.contains("rollback-journal lock")),
24986            "expected rollback-journal recovery diagnostic, got {:?}",
24987            recovery_check.diagnostics
24988        );
24989    }
24990
24991    #[test]
24992    fn graph_db_doctor_reports_wal_snapshot_fallback_when_locked() {
24993        let dir = setup_traversal_project();
24994        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24995        let graph_db = dir.path().join(".tsift/graph.db");
24996        let _lock = hold_wal_database_lock(&graph_db);
24997
24998        let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
24999        append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25000        report.finalize();
25001
25002        assert_eq!(report.status, "ok");
25003        assert!(!report.fail_closed);
25004        let recovery_check = report
25005            .checks
25006            .iter()
25007            .find(|check| check.name == "sqlite_graph_db_read_recovery")
25008            .expect("doctor should include WAL read recovery diagnostic");
25009        assert_eq!(recovery_check.status, "recovered");
25010        assert!(
25011            recovery_check
25012                .diagnostics
25013                .iter()
25014                .any(|diagnostic| diagnostic.contains("WAL-aware snapshot fallback")),
25015            "expected WAL recovery diagnostic, got {:?}",
25016            recovery_check.diagnostics
25017        );
25018    }
25019
25020    #[test]
25021    fn graph_db_snapshot_export_import_round_trip_preserves_projection_metadata() {
25022        let dir = setup_traversal_project();
25023        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25024        let artifact = dir.path().join("graph.db.gz");
25025
25026        let exported =
25027            commands::infra::graph_db_snapshot_export_report(dir.path(), None, &artifact, false)
25028                .unwrap();
25029        let exported_projection_version = exported.freshness.projection_version.clone();
25030        let exported_content_hash = exported.freshness.content_hash.clone();
25031        let exported_source_watermark = exported.freshness.source_watermark.clone();
25032        let exported_nodes = exported.counts.nodes;
25033        let exported_edges = exported.counts.edges;
25034        assert_eq!(exported.operation, "snapshot-export");
25035        assert!(exported.status.starts_with("exported"));
25036        assert!(artifact.exists());
25037        assert!(exported.artifact_bytes > 0);
25038        assert_eq!(exported.compression, "gzip");
25039
25040        fs::remove_file(dir.path().join(".tsift/graph.db")).unwrap();
25041
25042        let imported =
25043            commands::infra::graph_db_snapshot_import_report(dir.path(), None, &artifact, false)
25044                .unwrap();
25045        assert_eq!(imported.operation, "snapshot-import");
25046        assert!(imported.status.starts_with("imported"));
25047        assert_eq!(
25048            imported.freshness.projection_version,
25049            exported_projection_version
25050        );
25051        assert_eq!(imported.freshness.content_hash, exported_content_hash);
25052        assert_eq!(
25053            imported.freshness.source_watermark,
25054            exported_source_watermark
25055        );
25056        assert_eq!(imported.counts.nodes, exported_nodes);
25057        assert_eq!(imported.counts.edges, exported_edges);
25058        assert!(dir.path().join(".tsift/graph.db").exists());
25059    }
25060
25061    #[test]
25062    fn graph_db_snapshot_export_fails_closed_when_wal_lock_requires_recovery() {
25063        let dir = setup_traversal_project();
25064        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25065        let graph_db = dir.path().join(".tsift/graph.db");
25066        let _lock = hold_wal_database_lock(&graph_db);
25067
25068        let err = match commands::infra::graph_db_snapshot_export_report(
25069            dir.path(),
25070            None,
25071            &dir.path().join("graph.db.gz"),
25072            false,
25073        ) {
25074            Ok(report) => panic!("expected snapshot export to fail, got {}", report.status),
25075            Err(err) => err,
25076        };
25077
25078        assert!(
25079            err.to_string().contains("recovered read path"),
25080            "expected recovered read path diagnostic, got {err:#}"
25081        );
25082    }
25083
25084    #[test]
25085    fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
25086        let dir = setup_traversal_project();
25087        let session = dir.path().join("tasks/software/tsift.md");
25088        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25089        let graph_db = dir.path().join(".tsift/graph.db");
25090        let _lock = hold_rollback_journal_lock(&graph_db);
25091
25092        let result = cmd_graph_db(
25093            &session,
25094            None,
25095            GraphDbBackend::Sqlite,
25096            None,
25097            GraphDbQuery::Evidence {
25098                target: "kgnv".to_string(),
25099                depth: 3,
25100                limit: 8,
25101                cursor: None,
25102            },
25103            OutputFormat {
25104                json_output: false,
25105                compact: true,
25106                pretty: false,
25107                terse: false,
25108                ultra_terse: false,
25109                schema: false,
25110                envelope: false,
25111            },
25112        );
25113
25114        assert!(result.is_ok());
25115    }
25116
25117    fn current_graph_db_freshness() -> GraphDbFreshnessReport {
25118        GraphDbFreshnessReport {
25119            status: "current".to_string(),
25120            fail_closed: false,
25121            projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
25122            content_hash: Some("fixture".to_string()),
25123            source_watermark: None,
25124            diagnostics: Vec::new(),
25125        }
25126    }
25127
25128    #[test]
25129    fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
25130        let dir = setup_traversal_project();
25131        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25132        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25133        let stale = GraphDbFreshnessReport {
25134            status: "stale".to_string(),
25135            fail_closed: true,
25136            projection_version: Some("old-v0".to_string()),
25137            content_hash: None,
25138            source_watermark: None,
25139            diagnostics: vec!["projection content hash is missing".to_string()],
25140        };
25141
25142        let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25143            root: dir.path(),
25144            scope: None,
25145            backend: "sqlite",
25146            target: "kgnv",
25147            preferred_path: None,
25148            depth: 3,
25149            limit: 8,
25150            cursor: None,
25151            store: &store,
25152            freshness: stale,
25153            warnings: Vec::new(),
25154        }) {
25155            Ok(_) => panic!("stale graph freshness should fail closed"),
25156            Err(err) => err,
25157        };
25158        let message = err.to_string();
25159        assert!(message.contains("failed closed"), "{message}");
25160        assert!(message.contains("graph-db --path"), "{message}");
25161        assert!(message.contains("refresh --json"), "{message}");
25162    }
25163
25164    fn paged_graph_ids(
25165        store: &impl GraphStore,
25166        cursor: Option<&str>,
25167    ) -> (Vec<String>, GraphDbPageReport) {
25168        let report = graph_db_report_from_store(
25169            Path::new("."),
25170            None,
25171            "fixture",
25172            GraphDbQuery::Kind {
25173                kind: "backlog".to_string(),
25174                cursor: cursor.map(str::to_string),
25175                limit: Some(2),
25176                property_filters: vec!["phase=open".to_string()],
25177            },
25178            store,
25179            current_graph_db_freshness(),
25180            Vec::new(),
25181        )
25182        .unwrap();
25183        (
25184            report.nodes.iter().map(|node| node.id.clone()).collect(),
25185            report.page.unwrap(),
25186        )
25187    }
25188
25189    #[test]
25190    fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
25191        let nodes = (0..5)
25192            .map(|idx| {
25193                let phase = if idx == 1 { "closed" } else { "open" };
25194                SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
25195                    .with_property("phase", phase)
25196            })
25197            .collect::<Vec<_>>();
25198        let projection = GraphProjection {
25199            nodes,
25200            edges: Vec::new(),
25201        };
25202        let sqlite = SqliteGraphStore::in_memory().unwrap();
25203        projection.upsert_into(&sqlite).unwrap();
25204        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25205        projection.upsert_into(&convex).unwrap();
25206
25207        let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
25208        let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
25209        assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
25210        assert_eq!(sqlite_first_ids, convex_first_ids);
25211        assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
25212        assert!(sqlite_first_page.truncated);
25213        assert_eq!(
25214            sqlite_first_page.returned_nodes,
25215            convex_first_page.returned_nodes
25216        );
25217        assert_eq!(
25218            sqlite_first_page.property_filters,
25219            convex_first_page.property_filters
25220        );
25221        assert!(
25222            sqlite_first_page
25223                .diagnostics
25224                .iter()
25225                .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
25226            "expected SQLite kind query plan diagnostics, got {:?}",
25227            sqlite_first_page.diagnostics
25228        );
25229
25230        let cursor = sqlite_first_page.next_cursor.as_deref();
25231        let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
25232        let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
25233        assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
25234        assert_eq!(sqlite_next_ids, convex_next_ids);
25235        assert_eq!(sqlite_next_page.next_cursor, None);
25236        assert!(!sqlite_next_page.truncated);
25237        assert_eq!(
25238            sqlite_next_page.returned_nodes,
25239            convex_next_page.returned_nodes
25240        );
25241        assert_eq!(
25242            sqlite_next_page.property_filters,
25243            convex_next_page.property_filters
25244        );
25245    }
25246
25247    #[test]
25248    fn traversal_shortest_path_crosses_artifacts_and_symbols() {
25249        let dir = setup_traversal_project();
25250        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25251        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25252        let main = resolve_traversal_node(&graph, "main").unwrap();
25253
25254        let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
25255        assert_eq!(path.first(), Some(&backlog.handle));
25256        assert_eq!(path.last(), Some(&main.handle));
25257        assert!(
25258            path.len() >= 3,
25259            "expected backlog -> symbol -> main, got {path:?}"
25260        );
25261    }
25262
25263    #[test]
25264    fn traversal_report_recommends_next_bugfix_nodes() {
25265        let dir = setup_traversal_project();
25266        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25267        let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
25268
25269        assert_eq!(report.mode, "neighborhood");
25270        assert!(
25271            report
25272                .recommendations
25273                .iter()
25274                .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
25275            "expected helper recommendation, got {:?}",
25276            report.recommendations
25277        );
25278        assert!(
25279            !report.exploration.source_windows.is_empty(),
25280            "expected exploration source windows"
25281        );
25282        assert!(
25283            report
25284                .exploration
25285                .no_reread_guidance
25286                .contains("avoid whole-file reads")
25287        );
25288    }
25289
25290    #[test]
25291    fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
25292        let dir = setup_traversal_project();
25293        std::thread::sleep(std::time::Duration::from_millis(50));
25294        std::fs::write(
25295            dir.path().join("main.rs"),
25296            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25297        )
25298        .unwrap();
25299
25300        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25301
25302        assert!(
25303            graph
25304                .warnings
25305                .iter()
25306                .any(|warning| warning.contains("index refreshed")
25307                    && warning.contains("graph traversal packet")),
25308            "expected refresh diagnostic, got {:?}",
25309            graph.warnings
25310        );
25311        assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
25312
25313        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
25314        let summary = db.compute_changes(dir.path()).unwrap();
25315        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
25316    }
25317
25318    #[test]
25319    fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
25320        let dir = setup_traversal_project();
25321        let db_path = dir.path().join(".tsift/index.db");
25322        let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
25323        std::thread::sleep(std::time::Duration::from_millis(50));
25324        std::fs::write(
25325            dir.path().join("main.rs"),
25326            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25327        )
25328        .unwrap();
25329
25330        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25331        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
25332
25333        assert!(
25334            graph
25335                .warnings
25336                .iter()
25337                .any(|warning| warning.contains("falling back to raw source file nodes")),
25338            "expected raw-source fallback diagnostic, got {:?}",
25339            graph.warnings
25340        );
25341        assert!(
25342            file.detail
25343                .as_deref()
25344                .is_some_and(|detail| detail.contains("raw source fallback")),
25345            "expected raw-source detail, got {:?}",
25346            file.detail
25347        );
25348        assert!(
25349            file.expand.contains("source-read"),
25350            "expected source-read fallback command, got {}",
25351            file.expand
25352        );
25353        assert!(
25354            resolve_traversal_node(&graph, "helper").is_none(),
25355            "stale symbol evidence should be skipped when refresh is blocked"
25356        );
25357    }
25358
25359    #[test]
25360    fn traversal_cmd_supports_json_and_html_outputs() {
25361        let dir = setup_traversal_project();
25362        cmd_traverse(
25363            Some("#kgnv"),
25364            Some("main"),
25365            dir.path(),
25366            None,
25367            1,
25368            50,
25369            TraverseFormat::Json,
25370            false,
25371            false,
25372            false,
25373            None,
25374        )
25375        .unwrap();
25376        cmd_traverse(
25377            None,
25378            None,
25379            dir.path(),
25380            None,
25381            1,
25382            50,
25383            TraverseFormat::Html,
25384            false,
25385            false,
25386            false,
25387            None,
25388        )
25389        .unwrap();
25390    }
25391
25392    #[test]
25393    fn traversal_html_renders_inline_graph_visualization() {
25394        let dir = setup_traversal_project();
25395        seed_traversal_semantic_summaries(dir.path());
25396        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25397        let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
25398        let html = traversal_report_html(&report).unwrap();
25399
25400        assert!(html.contains("id=\"graph-canvas\""));
25401        assert!(html.contains("semantic_concept"));
25402        assert!(html.contains("graph navigation"));
25403        assert!(html.contains("JSON.parse"));
25404    }
25405
25406    #[test]
25407    fn compact_helpers_trim_scores_and_snippets() {
25408        assert_eq!(format_score(0.12345, true), "0.12");
25409        assert_eq!(format_score(0.12345, false), "0.1235");
25410        let snippet = compact_snippet("    first line with useful context\nsecond");
25411        assert_eq!(snippet.as_deref(), Some("first line with useful context"));
25412    }
25413
25414    #[test]
25415    fn compact_members_caps_list() {
25416        let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
25417            .iter()
25418            .map(|n| graph::CommunityMember::new(*n))
25419            .collect();
25420        assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
25421    }
25422
25423    #[test]
25424    fn abbreviate_kind_maps_common_kinds() {
25425        assert_eq!(abbreviate_kind("function"), "fn");
25426        assert_eq!(abbreviate_kind("method"), "meth");
25427        assert_eq!(abbreviate_kind("class"), "cls");
25428        assert_eq!(abbreviate_kind("interface"), "iface");
25429        assert_eq!(abbreviate_kind("type_alias"), "type");
25430        assert_eq!(abbreviate_kind("data_class"), "data_cls");
25431        assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
25432        assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
25433        assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
25434        assert_eq!(abbreviate_kind("object"), "obj");
25435        assert_eq!(abbreviate_kind("heading"), "h");
25436        assert_eq!(abbreviate_kind("code_block"), "code");
25437        // short kinds pass through
25438        assert_eq!(abbreviate_kind("struct"), "struct");
25439        assert_eq!(abbreviate_kind("trait"), "trait");
25440        assert_eq!(abbreviate_kind("enum"), "enum");
25441        assert_eq!(abbreviate_kind("const"), "const");
25442        assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
25443    }
25444
25445    #[test]
25446    fn abbreviate_match_type_maps_search_types() {
25447        assert_eq!(abbreviate_match_type("exact_name"), "exact");
25448        assert_eq!(abbreviate_match_type("partial_tags"), "partial");
25449        assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
25450        assert_eq!(abbreviate_match_type("other_type"), "other_type");
25451    }
25452
25453    #[test]
25454    fn explain_compact_groups_edges_by_file() {
25455        let edges = vec![
25456            index::StoredEdge {
25457                caller_file: "src/main.rs".to_string(),
25458                caller_name: "main".to_string(),
25459                caller_line: 1,
25460                callee_name: "helper".to_string(),
25461                call_site_line: 2,
25462                tagpath_handle: None,
25463            },
25464            index::StoredEdge {
25465                caller_file: "src/main.rs".to_string(),
25466                caller_name: "main".to_string(),
25467                caller_line: 1,
25468                callee_name: "render".to_string(),
25469                call_site_line: 3,
25470                tagpath_handle: None,
25471            },
25472        ];
25473        let lines = format_edge_groups(&edges, false);
25474        assert_eq!(lines, vec!["  src/main.rs (2): helper, render"]);
25475    }
25476
25477    #[test]
25478    fn search_hit_groups_preserve_file_counts_and_samples() {
25479        let dir = tempfile::tempdir().unwrap();
25480        let root = dir.path();
25481        let main_rs = root.join("src/main.rs");
25482        fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
25483        fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
25484        let freshness = exact_search_file_timestamp(&main_rs);
25485        let hits = vec![
25486            sift::SearchHit {
25487                artifact_id: "a".to_string(),
25488                artifact_kind: sift::ContextArtifactKind::File,
25489                path: main_rs.display().to_string(),
25490                rank: 1,
25491                score: 10.0,
25492                confidence: sift::ScoreConfidence::High,
25493                location: Some("line 3".to_string()),
25494                snippet: "claudescore-3 anchor".to_string(),
25495                provenance: sift::ArtifactProvenance {
25496                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25497                    source: "ripgrep -F".to_string(),
25498                    synthetic: false,
25499                },
25500                freshness: freshness.clone(),
25501                budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
25502            },
25503            sift::SearchHit {
25504                artifact_id: "b".to_string(),
25505                artifact_kind: sift::ContextArtifactKind::File,
25506                path: main_rs.display().to_string(),
25507                rank: 2,
25508                score: 9.0,
25509                confidence: sift::ScoreConfidence::High,
25510                location: Some("line 7".to_string()),
25511                snippet: "claudescore-3 follow-up".to_string(),
25512                provenance: sift::ArtifactProvenance {
25513                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25514                    source: "ripgrep -F".to_string(),
25515                    synthetic: false,
25516                },
25517                freshness: freshness.clone(),
25518                budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
25519            },
25520            sift::SearchHit {
25521                artifact_id: "c".to_string(),
25522                artifact_kind: sift::ContextArtifactKind::File,
25523                path: main_rs.display().to_string(),
25524                rank: 3,
25525                score: 8.0,
25526                confidence: sift::ScoreConfidence::High,
25527                location: Some("line 9".to_string()),
25528                snippet: "claudescore-3 tail".to_string(),
25529                provenance: sift::ArtifactProvenance {
25530                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25531                    source: "ripgrep -F".to_string(),
25532                    synthetic: false,
25533                },
25534                freshness,
25535                budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
25536            },
25537        ];
25538
25539        let groups = group_search_hits(&hits, root, false);
25540        assert_eq!(groups.len(), 1);
25541        assert_eq!(groups[0].path, "src/main.rs");
25542        assert_eq!(groups[0].hits, 3);
25543        assert_eq!(
25544            groups[0].samples,
25545            vec![
25546                "line 3: claudescore-3 anchor".to_string(),
25547                "line 7: claudescore-3 follow-up".to_string()
25548            ]
25549        );
25550        assert!(should_collapse_search_hits(&hits, root, false));
25551    }
25552
25553    #[test]
25554    fn dense_edge_groups_trigger_collapse() {
25555        let edges = vec![
25556            index::StoredEdge {
25557                caller_file: "src/main.rs".to_string(),
25558                caller_name: "main".to_string(),
25559                caller_line: 1,
25560                callee_name: "helper".to_string(),
25561                call_site_line: 2,
25562                tagpath_handle: None,
25563            },
25564            index::StoredEdge {
25565                caller_file: "src/main.rs".to_string(),
25566                caller_name: "beta".to_string(),
25567                caller_line: 5,
25568                callee_name: "helper".to_string(),
25569                call_site_line: 6,
25570                tagpath_handle: None,
25571            },
25572            index::StoredEdge {
25573                caller_file: "src/main.rs".to_string(),
25574                caller_name: "gamma".to_string(),
25575                caller_line: 9,
25576                callee_name: "helper".to_string(),
25577                call_site_line: 10,
25578                tagpath_handle: None,
25579            },
25580        ];
25581        assert!(should_collapse_edge_groups(&edges));
25582    }
25583
25584    // --- workspace indexing ---
25585
25586    fn setup_workspace() -> tempfile::TempDir {
25587        let dir = tempfile::tempdir().unwrap();
25588        let root = dir.path();
25589        std::fs::write(
25590            root.join(".gitmodules"),
25591            r#"[submodule "src/alpha"]
25592	path = src/alpha
25593	url = https://example.com/alpha
25594[submodule "src/beta"]
25595	path = src/beta
25596	url = https://example.com/beta
25597"#,
25598        )
25599        .unwrap();
25600        let alpha = root.join("src/alpha");
25601        let beta = root.join("src/beta");
25602        std::fs::create_dir_all(&alpha).unwrap();
25603        std::fs::create_dir_all(&beta).unwrap();
25604        std::fs::write(
25605            alpha.join("lib.rs"),
25606            "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
25607        )
25608        .unwrap();
25609        std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
25610        dir
25611    }
25612
25613    fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
25614        let dir = tempfile::tempdir().unwrap();
25615        let root = dir.path();
25616        std::fs::write(
25617            root.join(".gitmodules"),
25618            r#"[submodule "pkg/app/foo"]
25619	path = pkg/app/foo
25620	url = https://example.com/pkg-app-foo
25621[submodule "vendor/foo"]
25622	path = vendor/foo
25623	url = https://example.com/vendor-foo
25624"#,
25625        )
25626        .unwrap();
25627        let pkg_foo = root.join("pkg/app/foo");
25628        let vendor_foo = root.join("vendor/foo");
25629        std::fs::create_dir_all(&pkg_foo).unwrap();
25630        std::fs::create_dir_all(&vendor_foo).unwrap();
25631        std::fs::write(
25632            pkg_foo.join("lib.rs"),
25633            "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
25634        )
25635        .unwrap();
25636        std::fs::write(
25637            vendor_foo.join("lib.rs"),
25638            "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
25639        )
25640        .unwrap();
25641        dir
25642    }
25643
25644    #[test]
25645    fn workspace_index_creates_per_submodule_dbs() {
25646        let dir = setup_workspace();
25647        cmd_index(
25648            dir.path(),
25649            false,
25650            false,
25651            false,
25652            false,
25653            false,
25654            true,
25655            None,
25656            false,
25657            false,
25658            false,
25659            false,
25660            false,
25661            false,
25662        )
25663        .unwrap();
25664        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25665        assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
25666    }
25667
25668    #[test]
25669    fn workspace_index_single_submodule() {
25670        let dir = setup_workspace();
25671        cmd_index(
25672            dir.path(),
25673            false,
25674            false,
25675            false,
25676            false,
25677            false,
25678            false,
25679            Some("alpha"),
25680            false,
25681            false,
25682            false,
25683            false,
25684            false,
25685            false,
25686        )
25687        .unwrap();
25688        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25689        assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
25690    }
25691
25692    #[test]
25693    fn workspace_index_single_submodule_errors_on_unknown_scope() {
25694        let dir = setup_workspace();
25695
25696        let err = cmd_index(
25697            dir.path(),
25698            false,
25699            false,
25700            false,
25701            false,
25702            false,
25703            false,
25704            Some("missing"),
25705            false,
25706            false,
25707            false,
25708            false,
25709            false,
25710            false,
25711        )
25712        .unwrap_err();
25713
25714        let msg = err.to_string();
25715        assert!(msg.contains("unknown scope `missing`"));
25716        assert!(msg.contains("Available scopes: alpha, beta"));
25717        assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
25718    }
25719
25720    #[test]
25721    fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
25722        let dir = setup_workspace_with_duplicate_leaf_names();
25723        cmd_index(
25724            dir.path(),
25725            false,
25726            false,
25727            false,
25728            false,
25729            false,
25730            true,
25731            None,
25732            false,
25733            false,
25734            false,
25735            false,
25736            false,
25737            false,
25738        )
25739        .unwrap();
25740
25741        assert!(
25742            dir.path()
25743                .join(".tsift/indexes/pkg/app/foo/index.db")
25744                .exists()
25745        );
25746        assert!(
25747            dir.path()
25748                .join(".tsift/indexes/vendor/foo/index.db")
25749                .exists()
25750        );
25751    }
25752
25753    #[test]
25754    fn federated_search_across_submodules() {
25755        let dir = setup_workspace();
25756        cmd_index(
25757            dir.path(),
25758            false,
25759            false,
25760            false,
25761            false,
25762            false,
25763            true,
25764            None,
25765            false,
25766            false,
25767            false,
25768            false,
25769            false,
25770            false,
25771        )
25772        .unwrap();
25773        let (hits, _diag) = federated_symbol_search(
25774            dir.path(),
25775            "alpha_helper",
25776            10,
25777            &TagpathSearchOpts {
25778                no_tagpath: true,
25779                strict: false,
25780            },
25781        )
25782        .unwrap();
25783        assert!(
25784            !hits.is_empty(),
25785            "should find alpha_helper via federated search"
25786        );
25787    }
25788
25789    #[test]
25790    fn federated_search_respects_isolation() {
25791        let dir = setup_workspace();
25792        let tsift_dir = dir.path().join(".tsift");
25793        std::fs::create_dir_all(&tsift_dir).unwrap();
25794        std::fs::write(
25795            tsift_dir.join("config.toml"),
25796            r#"
25797[overrides.alpha]
25798tier = "isolated"
25799"#,
25800        )
25801        .unwrap();
25802        cmd_index(
25803            dir.path(),
25804            false,
25805            false,
25806            false,
25807            false,
25808            false,
25809            true,
25810            None,
25811            false,
25812            false,
25813            false,
25814            false,
25815            false,
25816            false,
25817        )
25818        .unwrap();
25819        let (hits, _diag) = federated_symbol_search(
25820            dir.path(),
25821            "alpha_helper",
25822            10,
25823            &TagpathSearchOpts {
25824                no_tagpath: true,
25825                strict: false,
25826            },
25827        )
25828        .unwrap();
25829        assert!(
25830            hits.is_empty(),
25831            "isolated submodule should not appear in federated search"
25832        );
25833    }
25834
25835    #[test]
25836    fn federated_lexical_search_respects_isolation() {
25837        let dir = setup_workspace();
25838        let tsift_dir = dir.path().join(".tsift");
25839        std::fs::create_dir_all(&tsift_dir).unwrap();
25840        std::fs::write(
25841            tsift_dir.join("config.toml"),
25842            r#"
25843[overrides.alpha]
25844tier = "isolated"
25845"#,
25846        )
25847        .unwrap();
25848        cmd_index(
25849            dir.path(),
25850            false,
25851            false,
25852            false,
25853            false,
25854            false,
25855            true,
25856            None,
25857            false,
25858            false,
25859            false,
25860            false,
25861            false,
25862            false,
25863        )
25864        .unwrap();
25865
25866        let response = federated_sift_search(
25867            dir.path(),
25868            &dir.path().join(".tsift/search-cache"),
25869            "fn",
25870            10,
25871            0,
25872            "lexical",
25873        )
25874        .unwrap();
25875
25876        assert!(
25877            !response.hits.is_empty(),
25878            "shared scopes should still contribute lexical hits"
25879        );
25880        assert!(
25881            response
25882                .hits
25883                .iter()
25884                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25885            "isolated scope should not leak lexical hits: {:?}",
25886            response.hits
25887        );
25888    }
25889
25890    #[test]
25891    fn federated_lexical_search_respects_private_tier() {
25892        let dir = setup_workspace();
25893        let tsift_dir = dir.path().join(".tsift");
25894        std::fs::create_dir_all(&tsift_dir).unwrap();
25895        std::fs::write(
25896            tsift_dir.join("config.toml"),
25897            r#"
25898[overrides.alpha]
25899tier = "private"
25900"#,
25901        )
25902        .unwrap();
25903        cmd_index(
25904            dir.path(),
25905            false,
25906            false,
25907            false,
25908            false,
25909            false,
25910            true,
25911            None,
25912            false,
25913            false,
25914            false,
25915            false,
25916            false,
25917            false,
25918        )
25919        .unwrap();
25920
25921        let response = federated_sift_search(
25922            dir.path(),
25923            &dir.path().join(".tsift/search-cache"),
25924            "fn",
25925            10,
25926            0,
25927            "lexical",
25928        )
25929        .unwrap();
25930
25931        assert!(
25932            !response.hits.is_empty(),
25933            "shared scopes should still contribute lexical hits"
25934        );
25935        assert!(
25936            response
25937                .hits
25938                .iter()
25939                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25940            "private scope should not leak lexical hits: {:?}",
25941            response.hits
25942        );
25943    }
25944
25945    #[test]
25946    fn scoped_search_finds_submodule_symbols() {
25947        let dir = setup_workspace();
25948        cmd_index(
25949            dir.path(),
25950            false,
25951            false,
25952            false,
25953            false,
25954            false,
25955            true,
25956            None,
25957            false,
25958            false,
25959            false,
25960            false,
25961            false,
25962            false,
25963        )
25964        .unwrap();
25965        let cfg = config::Config::load(dir.path()).unwrap();
25966        let db_path = cfg.db_path_for(dir.path(), "alpha");
25967        let db = index::IndexDb::open(&db_path).unwrap();
25968        let hits = db.symbol_search("alpha_main", 10).unwrap();
25969        assert!(!hits.is_empty());
25970        assert_eq!(hits[0].name, "alpha_main");
25971    }
25972
25973    #[test]
25974    fn scoped_search_cmd_errors_on_unknown_scope() {
25975        let dir = setup_workspace();
25976
25977        let err = cmd_search(
25978            "alpha_main".to_string(),
25979            Some(dir.path().to_path_buf()),
25980            5,
25981            Some("lexical".to_string()),
25982            Some("missing".to_string()),
25983            false,
25984            false,
25985            false,
25986            0,
25987            false,
25988            false,
25989            false,
25990            false,
25991            false,
25992            false,
25993            false,
25994        )
25995        .unwrap_err();
25996
25997        let msg = err.to_string();
25998        assert!(msg.contains("unknown scope `missing`"));
25999        assert!(msg.contains("Available scopes: alpha, beta"));
26000    }
26001
26002    #[test]
26003    fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
26004        let dir = setup_workspace_with_duplicate_leaf_names();
26005        cmd_index(
26006            dir.path(),
26007            false,
26008            false,
26009            false,
26010            false,
26011            false,
26012            true,
26013            None,
26014            false,
26015            false,
26016            false,
26017            false,
26018            false,
26019            false,
26020        )
26021        .unwrap();
26022
26023        let err = cmd_search(
26024            "vendor_only".to_string(),
26025            Some(dir.path().to_path_buf()),
26026            5,
26027            Some("lexical".to_string()),
26028            Some("foo".to_string()),
26029            false,
26030            false,
26031            false,
26032            0,
26033            false,
26034            false,
26035            false,
26036            false,
26037            false,
26038            false,
26039            false,
26040        )
26041        .unwrap_err();
26042
26043        let msg = err.to_string();
26044        assert!(msg.contains("ambiguous scope `foo`"));
26045        assert!(msg.contains("pkg/app/foo"));
26046        assert!(msg.contains("vendor/foo"));
26047    }
26048
26049    #[test]
26050    fn scoped_graph_query() {
26051        let dir = setup_workspace();
26052        cmd_index(
26053            dir.path(),
26054            false,
26055            false,
26056            false,
26057            false,
26058            false,
26059            true,
26060            None,
26061            false,
26062            false,
26063            false,
26064            false,
26065            false,
26066            false,
26067        )
26068        .unwrap();
26069        let cfg = config::Config::load(dir.path()).unwrap();
26070        let db_path = cfg.db_path_for(dir.path(), "alpha");
26071        let db = index::IndexDb::open(&db_path).unwrap();
26072        let callees = db.callees_of("alpha_main").unwrap();
26073        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
26074        assert!(names.contains(&"alpha_helper"));
26075    }
26076
26077    fn assert_workspace_query_requires_scope(err: anyhow::Error) {
26078        let msg = err.to_string();
26079        assert!(msg.contains("require `--scope <scope>`"), "{msg}");
26080        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26081        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26082        assert!(
26083            !msg.contains("no index found at"),
26084            "workspace query should fail with scope guidance, got: {msg}"
26085        );
26086    }
26087
26088    fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
26089        let msg = err.to_string();
26090        assert!(
26091            msg.contains("requires `--scope <scope>` or `--federated`"),
26092            "{msg}"
26093        );
26094        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26095        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26096        assert!(
26097            !msg.contains("autoindexing index"),
26098            "workspace search should fail before creating a shared root index: {msg}"
26099        );
26100    }
26101
26102    #[test]
26103    fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
26104        let dir = setup_workspace();
26105        cmd_index(
26106            dir.path(),
26107            false,
26108            false,
26109            false,
26110            false,
26111            false,
26112            true,
26113            None,
26114            false,
26115            false,
26116            false,
26117            false,
26118            false,
26119            false,
26120        )
26121        .unwrap();
26122
26123        let err = cmd_graph(
26124            "alpha_main",
26125            dir.path(),
26126            false,
26127            false,
26128            None,
26129            20,
26130            false,
26131            false,
26132            false,
26133            false,
26134            false,
26135            false,
26136            false,
26137            TagpathSearchOpts::default(),
26138        )
26139        .unwrap_err();
26140
26141        assert_workspace_query_requires_scope(err);
26142    }
26143
26144    #[test]
26145    fn graph_cmd_infers_scope_from_nested_workspace_path() {
26146        let dir = setup_workspace();
26147        cmd_index(
26148            dir.path(),
26149            false,
26150            false,
26151            false,
26152            false,
26153            false,
26154            true,
26155            None,
26156            false,
26157            false,
26158            false,
26159            false,
26160            false,
26161            false,
26162        )
26163        .unwrap();
26164        let nested = dir.path().join("src/alpha/nested");
26165        std::fs::create_dir_all(&nested).unwrap();
26166
26167        let result = cmd_graph(
26168            "alpha_main",
26169            &nested,
26170            false,
26171            false,
26172            None,
26173            20,
26174            false,
26175            false,
26176            false,
26177            false,
26178            false,
26179            false,
26180            false,
26181            TagpathSearchOpts::default(),
26182        );
26183
26184        assert!(result.is_ok());
26185    }
26186
26187    #[test]
26188    fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
26189        let dir = setup_workspace();
26190        cmd_index(
26191            dir.path(),
26192            false,
26193            false,
26194            false,
26195            false,
26196            false,
26197            true,
26198            None,
26199            false,
26200            false,
26201            false,
26202            false,
26203            false,
26204            false,
26205        )
26206        .unwrap();
26207
26208        let err = cmd_communities(
26209            dir.path(),
26210            None,
26211            1,
26212            10,
26213            false,
26214            false,
26215            false,
26216            false,
26217            false,
26218            false,
26219            TagpathSearchOpts::default(),
26220        )
26221        .unwrap_err();
26222
26223        assert_workspace_query_requires_scope(err);
26224    }
26225
26226    #[test]
26227    fn communities_cmd_infers_scope_from_nested_workspace_path() {
26228        let dir = setup_workspace();
26229        cmd_index(
26230            dir.path(),
26231            false,
26232            false,
26233            false,
26234            false,
26235            false,
26236            true,
26237            None,
26238            false,
26239            false,
26240            false,
26241            false,
26242            false,
26243            false,
26244        )
26245        .unwrap();
26246        let nested = dir.path().join("src/alpha/nested");
26247        std::fs::create_dir_all(&nested).unwrap();
26248
26249        let result = cmd_communities(
26250            &nested,
26251            None,
26252            1,
26253            10,
26254            false,
26255            false,
26256            false,
26257            false,
26258            false,
26259            false,
26260            TagpathSearchOpts::default(),
26261        );
26262
26263        assert!(result.is_ok());
26264    }
26265
26266    #[test]
26267    fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
26268        let dir = setup_workspace();
26269        cmd_index(
26270            dir.path(),
26271            false,
26272            false,
26273            false,
26274            false,
26275            false,
26276            true,
26277            None,
26278            false,
26279            false,
26280            false,
26281            false,
26282            false,
26283            false,
26284        )
26285        .unwrap();
26286
26287        let err = cmd_path(
26288            "alpha_main",
26289            "alpha_helper",
26290            dir.path(),
26291            None,
26292            false,
26293            false,
26294            false,
26295            false,
26296            false,
26297            TagpathSearchOpts::default(),
26298        )
26299        .unwrap_err();
26300
26301        assert_workspace_query_requires_scope(err);
26302    }
26303
26304    #[test]
26305    fn path_cmd_infers_scope_from_nested_workspace_path() {
26306        let dir = setup_workspace();
26307        cmd_index(
26308            dir.path(),
26309            false,
26310            false,
26311            false,
26312            false,
26313            false,
26314            true,
26315            None,
26316            false,
26317            false,
26318            false,
26319            false,
26320            false,
26321            false,
26322        )
26323        .unwrap();
26324        let nested = dir.path().join("src/alpha/nested");
26325        std::fs::create_dir_all(&nested).unwrap();
26326
26327        let result = cmd_path(
26328            "alpha_main",
26329            "alpha_helper",
26330            &nested,
26331            None,
26332            false,
26333            false,
26334            false,
26335            false,
26336            false,
26337            TagpathSearchOpts::default(),
26338        );
26339
26340        assert!(result.is_ok());
26341    }
26342
26343    #[test]
26344    fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26345        let dir = setup_graph_index();
26346        let db_path = dir.path().join(".tsift/index.db");
26347        let _lock = hold_rollback_journal_lock(&db_path);
26348
26349        let result = cmd_path(
26350            "main",
26351            "helper",
26352            dir.path(),
26353            None,
26354            false,
26355            false,
26356            false,
26357            false,
26358            false,
26359            TagpathSearchOpts::default(),
26360        );
26361
26362        assert!(result.is_ok());
26363    }
26364
26365    #[test]
26366    fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
26367        let dir = setup_workspace();
26368        cmd_index(
26369            dir.path(),
26370            false,
26371            false,
26372            false,
26373            false,
26374            false,
26375            true,
26376            None,
26377            false,
26378            false,
26379            false,
26380            false,
26381            false,
26382            false,
26383        )
26384        .unwrap();
26385
26386        let err = cmd_explain(
26387            "alpha_main",
26388            dir.path(),
26389            None,
26390            15,
26391            false,
26392            false,
26393            false,
26394            false,
26395            false,
26396            false,
26397            false,
26398            false,
26399        )
26400        .unwrap_err();
26401
26402        assert_workspace_query_requires_scope(err);
26403    }
26404
26405    #[test]
26406    fn explain_cmd_infers_scope_from_nested_workspace_path() {
26407        let dir = setup_workspace();
26408        cmd_index(
26409            dir.path(),
26410            false,
26411            false,
26412            false,
26413            false,
26414            false,
26415            true,
26416            None,
26417            false,
26418            false,
26419            false,
26420            false,
26421            false,
26422            false,
26423        )
26424        .unwrap();
26425        let nested = dir.path().join("src/alpha/nested");
26426        std::fs::create_dir_all(&nested).unwrap();
26427
26428        let result = cmd_explain(
26429            "alpha_main",
26430            &nested,
26431            None,
26432            15,
26433            false,
26434            false,
26435            false,
26436            false,
26437            false,
26438            false,
26439            false,
26440            false,
26441        );
26442
26443        assert!(result.is_ok());
26444    }
26445
26446    #[test]
26447    fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26448        let dir = setup_graph_index();
26449        let db_path = dir.path().join(".tsift/index.db");
26450        let _lock = hold_rollback_journal_lock(&db_path);
26451
26452        let result = cmd_explain(
26453            "main",
26454            dir.path(),
26455            None,
26456            15,
26457            false,
26458            false,
26459            false,
26460            false,
26461            false,
26462            false,
26463            false,
26464            false,
26465        );
26466
26467        assert!(result.is_ok());
26468    }
26469
26470    // --- community detection ---
26471
26472    #[test]
26473    fn community_detection_groups_related() {
26474        let dir = setup_graph_index();
26475        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26476        let edges = db.all_edges().unwrap();
26477        let result = graph::detect_communities(&edges);
26478        assert!(result.node_count > 0);
26479        assert!(!result.communities.is_empty());
26480    }
26481
26482    #[test]
26483    fn community_cmd_autoindexes_missing_index_by_default() {
26484        let dir = tempfile::tempdir().unwrap();
26485        let result = cmd_communities(
26486            dir.path(),
26487            None,
26488            2,
26489            10,
26490            false,
26491            false,
26492            false,
26493            false,
26494            false,
26495            false,
26496            TagpathSearchOpts::default(),
26497        );
26498
26499        assert!(result.is_ok());
26500        assert!(dir.path().join(".tsift/index.db").exists());
26501    }
26502
26503    // --- path ---
26504
26505    #[test]
26506    fn path_finds_connected_symbols() {
26507        let dir = setup_graph_index();
26508        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26509        let edges = db.all_edges().unwrap();
26510        let result = graph::shortest_path(&edges, "main", "helper");
26511        assert!(result.is_some());
26512        let path = result.unwrap();
26513        assert_eq!(path.hops, 1);
26514    }
26515
26516    #[test]
26517    fn path_returns_none_for_unknown() {
26518        let dir = setup_graph_index();
26519        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26520        let edges = db.all_edges().unwrap();
26521        assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
26522    }
26523
26524    #[test]
26525    fn path_cmd_autoindexes_missing_index_by_default() {
26526        let dir = tempfile::tempdir().unwrap();
26527        let result = cmd_path(
26528            "a",
26529            "b",
26530            dir.path(),
26531            None,
26532            false,
26533            false,
26534            false,
26535            false,
26536            false,
26537            TagpathSearchOpts::default(),
26538        );
26539
26540        assert!(result.is_ok());
26541        assert!(dir.path().join(".tsift/index.db").exists());
26542    }
26543
26544    // --- explain ---
26545
26546    #[test]
26547    fn explain_shows_symbol_info() {
26548        let dir = setup_graph_index();
26549        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26550        let symbols = db.symbol_info("main").unwrap();
26551        assert!(!symbols.is_empty());
26552        assert_eq!(symbols[0].name, "main");
26553        assert_eq!(symbols[0].kind, "function");
26554    }
26555
26556    #[test]
26557    fn explain_cmd_autoindexes_missing_index_by_default() {
26558        let dir = tempfile::tempdir().unwrap();
26559        let result = cmd_explain(
26560            "main",
26561            dir.path(),
26562            None,
26563            15,
26564            false,
26565            false,
26566            false,
26567            false,
26568            false,
26569            false,
26570            false,
26571            false,
26572        );
26573
26574        assert!(result.is_ok());
26575        assert!(dir.path().join(".tsift/index.db").exists());
26576    }
26577
26578    fn hold_write_lock(db_path: &std::path::Path) -> Connection {
26579        let conn = Connection::open(db_path).unwrap();
26580        conn.execute_batch("BEGIN IMMEDIATE").unwrap();
26581        conn
26582    }
26583
26584    fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
26585        use fs4::fs_std::FileExt;
26586        use std::io::Write;
26587
26588        let mut file = std::fs::OpenOptions::new()
26589            .read(true)
26590            .write(true)
26591            .create(true)
26592            .truncate(false)
26593            .open(lock_path)
26594            .unwrap();
26595        assert!(file.try_lock_exclusive().unwrap());
26596        writeln!(file, "{}", std::process::id()).unwrap();
26597        file
26598    }
26599
26600    fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
26601        let conn = Connection::open(db_path).unwrap();
26602        conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26603            .unwrap();
26604        std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
26605        conn
26606    }
26607
26608    fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
26609        let conn = Connection::open(db_path).unwrap();
26610        conn.execute_batch(
26611            "PRAGMA journal_mode=WAL;
26612             PRAGMA wal_autocheckpoint=0;
26613             CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
26614             INSERT INTO wal_lock_probe DEFAULT VALUES;
26615             PRAGMA locking_mode=EXCLUSIVE;
26616             BEGIN EXCLUSIVE;",
26617        )
26618        .unwrap();
26619        assert!(substrate::wal_sidecar_path(db_path).exists());
26620        conn
26621    }
26622
26623    #[test]
26624    fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
26625        let dir = setup_graph_index();
26626        let db_path = dir.path().join(".tsift/index.db");
26627        let _lock = hold_wal_database_lock(&db_path);
26628
26629        let err = cmd_index(
26630            dir.path(),
26631            false,
26632            false,
26633            false,
26634            false,
26635            false,
26636            false,
26637            None,
26638            false,
26639            false,
26640            false,
26641            false,
26642            false,
26643            false,
26644        )
26645        .unwrap_err();
26646
26647        let msg = err.to_string();
26648        assert!(msg.contains("indexing"));
26649        assert!(msg.contains("lock diagnostics:"));
26650        assert!(msg.contains("lock: absent"));
26651        assert!(msg.contains("wal: present") || msg.contains("shm: present"));
26652        assert!(msg.contains("wedged writer holding live WAL sidecars"));
26653        assert!(msg.contains("snapshot fallback"));
26654    }
26655
26656    #[test]
26657    fn search_cmd_succeeds_while_writer_lock_is_held() {
26658        let dir = setup_graph_index();
26659        let db_path = dir.path().join(".tsift/index.db");
26660        let _lock = hold_write_lock(&db_path);
26661
26662        let result = cmd_search(
26663            "main".to_string(),
26664            Some(dir.path().to_path_buf()),
26665            5,
26666            Some("lexical".to_string()),
26667            None,
26668            false,
26669            false,
26670            false,
26671            0,
26672            true,
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 search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
26686        let dir = setup_graph_index();
26687        let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
26688
26689        let result = cmd_search(
26690            "main".to_string(),
26691            Some(dir.path().to_path_buf()),
26692            5,
26693            Some("lexical".to_string()),
26694            None,
26695            false,
26696            false,
26697            false,
26698            0,
26699            true,
26700            false,
26701            false,
26702            false,
26703            false,
26704            false,
26705            false,
26706        );
26707
26708        assert!(result.is_ok());
26709    }
26710
26711    #[test]
26712    fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
26713        let dir = setup_graph_index();
26714        let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
26715
26716        let result = cmd_search(
26717            "main".to_string(),
26718            Some(dir.path().to_path_buf()),
26719            5,
26720            Some("lexical".to_string()),
26721            None,
26722            false,
26723            false,
26724            false,
26725            0,
26726            true,
26727            false,
26728            false,
26729            false,
26730            false,
26731            false,
26732            false,
26733        );
26734
26735        assert!(result.is_ok());
26736    }
26737
26738    #[test]
26739    fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
26740        let dir = setup_graph_index();
26741        std::thread::sleep(std::time::Duration::from_millis(50));
26742        std::fs::write(
26743            dir.path().join("main.rs"),
26744            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26745        )
26746        .unwrap();
26747
26748        let err = cmd_search(
26749            "helper".to_string(),
26750            Some(dir.path().to_path_buf()),
26751            5,
26752            Some("lexical".to_string()),
26753            None,
26754            false,
26755            false,
26756            false,
26757            0,
26758            false,
26759            false,
26760            false,
26761            false,
26762            false,
26763            false,
26764            false,
26765        )
26766        .unwrap_err();
26767
26768        assert!(err.to_string().contains("search aborted"));
26769        assert!(err.to_string().contains("index is stale"));
26770        assert!(err.to_string().contains("--no-autoindex"));
26771    }
26772
26773    #[test]
26774    fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
26775        let dir = setup_graph_index();
26776        std::thread::sleep(std::time::Duration::from_millis(50));
26777        std::fs::write(
26778            dir.path().join("main.rs"),
26779            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26780        )
26781        .unwrap();
26782        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26783
26784        let err = cmd_search(
26785            "helper".to_string(),
26786            Some(dir.path().to_path_buf()),
26787            5,
26788            Some("lexical".to_string()),
26789            None,
26790            false,
26791            false,
26792            false,
26793            0,
26794            false,
26795            false,
26796            false,
26797            false,
26798            false,
26799            false,
26800            false,
26801        )
26802        .unwrap_err();
26803
26804        assert!(err.to_string().contains("search aborted"));
26805        assert!(err.to_string().contains("index is stale"));
26806        assert!(!err.to_string().contains("database is locked"));
26807    }
26808
26809    #[test]
26810    fn search_cmd_autoindexes_stale_index_by_default() {
26811        let dir = setup_graph_index();
26812        std::thread::sleep(std::time::Duration::from_millis(50));
26813        std::fs::write(
26814            dir.path().join("main.rs"),
26815            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26816        )
26817        .unwrap();
26818
26819        let result = cmd_search(
26820            "helper".to_string(),
26821            Some(dir.path().to_path_buf()),
26822            5,
26823            Some("lexical".to_string()),
26824            None,
26825            false,
26826            false,
26827            true,
26828            0,
26829            false,
26830            false,
26831            false,
26832            false,
26833            false,
26834            false,
26835            false,
26836        );
26837
26838        assert!(result.is_ok());
26839
26840        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26841        let summary = db.compute_changes(dir.path()).unwrap();
26842        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26843    }
26844
26845    #[test]
26846    fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
26847        let dir = setup_graph_index();
26848        std::thread::sleep(std::time::Duration::from_millis(50));
26849        std::fs::write(
26850            dir.path().join("main.rs"),
26851            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26852        )
26853        .unwrap();
26854        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
26855
26856        let result = cmd_search(
26857            "helper".to_string(),
26858            Some(dir.path().to_path_buf()),
26859            5,
26860            Some("lexical".to_string()),
26861            None,
26862            false,
26863            false,
26864            true,
26865            0,
26866            false,
26867            false,
26868            false,
26869            false,
26870            false,
26871            false,
26872            false,
26873        );
26874
26875        assert!(result.is_ok());
26876
26877        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26878        let summary = db.compute_changes(dir.path()).unwrap();
26879        assert_eq!(summary.modified, 1);
26880    }
26881
26882    #[test]
26883    fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
26884        let dir = setup_graph_index();
26885        std::thread::sleep(std::time::Duration::from_millis(50));
26886        std::fs::write(
26887            dir.path().join("main.rs"),
26888            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26889        )
26890        .unwrap();
26891        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26892
26893        let err = cmd_search(
26894            "helper".to_string(),
26895            Some(dir.path().to_path_buf()),
26896            5,
26897            Some("lexical".to_string()),
26898            None,
26899            false,
26900            false,
26901            true,
26902            0,
26903            false,
26904            false,
26905            false,
26906            false,
26907            false,
26908            false,
26909            false,
26910        )
26911        .unwrap_err();
26912
26913        let msg = err.to_string();
26914        assert!(msg.contains("autoindexing index"));
26915        assert!(msg.contains("lock diagnostics:"));
26916        assert!(msg.contains("journal: present"));
26917        assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
26918    }
26919
26920    #[test]
26921    fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
26922        let dir = setup_graph_index();
26923        let nested = dir.path().join("src/nested");
26924        std::fs::create_dir_all(&nested).unwrap();
26925
26926        let result = cmd_search(
26927            "helper".to_string(),
26928            Some(nested.clone()),
26929            5,
26930            Some("lexical".to_string()),
26931            None,
26932            false,
26933            false,
26934            true,
26935            0,
26936            false,
26937            false,
26938            false,
26939            false,
26940            false,
26941            false,
26942            false,
26943        );
26944
26945        assert!(result.is_ok());
26946        assert!(!nested.join(".tsift/index.db").exists());
26947    }
26948
26949    #[test]
26950    fn exact_search_returns_literal_matches() {
26951        let dir = tempfile::tempdir().unwrap();
26952        std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
26953
26954        let response = run_exact_search_with_timeout(dir.path(), "claudescore-3", 5, 0).unwrap();
26955
26956        assert_eq!(response.strategy, "exact");
26957        assert_eq!(response.hits.len(), 1);
26958        assert!(response.hits[0].path.ends_with("notes.txt"));
26959        assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
26960        assert!(response.hits[0].snippet.contains("claudescore-3"));
26961    }
26962
26963    #[test]
26964    fn exact_search_skips_stale_index_precheck() {
26965        let dir = setup_graph_index();
26966        std::thread::sleep(std::time::Duration::from_millis(50));
26967        std::fs::write(
26968            dir.path().join("main.rs"),
26969            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
26970        )
26971        .unwrap();
26972
26973        let result = cmd_search(
26974            "println!(\"updated\")".to_string(),
26975            Some(dir.path().to_path_buf()),
26976            5,
26977            Some("exact".to_string()),
26978            None,
26979            false,
26980            false,
26981            false,
26982            0,
26983            false,
26984            false,
26985            false,
26986            false,
26987            false,
26988            false,
26989            false,
26990        );
26991
26992        assert!(result.is_ok());
26993    }
26994
26995    #[test]
26996    fn workspace_exact_search_does_not_require_shared_root_index() {
26997        let dir = setup_workspace();
26998        cmd_index(
26999            dir.path(),
27000            false,
27001            false,
27002            false,
27003            false,
27004            false,
27005            true,
27006            None,
27007            false,
27008            false,
27009            false,
27010            false,
27011            false,
27012            false,
27013        )
27014        .unwrap();
27015
27016        let result = cmd_search(
27017            "alpha_helper".to_string(),
27018            Some(dir.path().to_path_buf()),
27019            5,
27020            Some("exact".to_string()),
27021            None,
27022            false,
27023            false,
27024            false,
27025            0,
27026            false,
27027            false,
27028            false,
27029            false,
27030            false,
27031            false,
27032            false,
27033        );
27034
27035        assert!(result.is_ok());
27036        assert!(!dir.path().join(".tsift/index.db").exists());
27037    }
27038
27039    #[test]
27040    fn identifier_like_query_prefers_exact_search() {
27041        assert!(query_prefers_exact_search("claudescore-3"));
27042        assert!(query_prefers_exact_search("alpha_helper"));
27043        assert!(query_prefers_exact_search("src/main.rs"));
27044        assert!(query_prefers_exact_search("crate::module"));
27045        assert!(!query_prefers_exact_search("authenticate"));
27046        assert!(!query_prefers_exact_search("fn main"));
27047        assert!(!query_prefers_exact_search("."));
27048    }
27049
27050    #[test]
27051    fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
27052        assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
27053        assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
27054        assert_eq!(
27055            resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
27056            "hybrid"
27057        );
27058    }
27059
27060    #[test]
27061    fn workspace_identifier_like_search_auto_uses_exact_backend() {
27062        let dir = setup_workspace();
27063        cmd_index(
27064            dir.path(),
27065            false,
27066            false,
27067            false,
27068            false,
27069            false,
27070            true,
27071            None,
27072            false,
27073            false,
27074            false,
27075            false,
27076            false,
27077            false,
27078        )
27079        .unwrap();
27080
27081        let result = cmd_search(
27082            "alpha_helper".to_string(),
27083            Some(dir.path().to_path_buf()),
27084            5,
27085            None,
27086            None,
27087            false,
27088            false,
27089            false,
27090            0,
27091            false,
27092            false,
27093            false,
27094            false,
27095            false,
27096            false,
27097            false,
27098        );
27099
27100        assert!(result.is_ok());
27101        assert!(!dir.path().join(".tsift/index.db").exists());
27102    }
27103
27104    #[test]
27105    fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
27106        let dir = setup_graph_index();
27107        let nested = dir.path().join("src/nested");
27108        std::fs::create_dir_all(&nested).unwrap();
27109        std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
27110
27111        let result = cmd_index(
27112            &nested, false, false, false, false, false, false, None, false, false, false, false,
27113            false, false,
27114        );
27115
27116        assert!(result.is_ok());
27117        assert!(dir.path().join(".tsift/index.db").exists());
27118        assert!(!nested.join(".tsift/index.db").exists());
27119    }
27120
27121    #[test]
27122    fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
27123        let dir = setup_workspace();
27124        let nested = dir.path().join("docs/nested");
27125        std::fs::create_dir_all(&nested).unwrap();
27126
27127        let result = cmd_index(
27128            &nested, false, false, false, false, false, true, None, false, false, false, false,
27129            false, false,
27130        );
27131
27132        let cfg = config::Config::load(dir.path()).unwrap();
27133
27134        assert!(result.is_ok());
27135        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
27136        assert!(cfg.db_path_for(dir.path(), "beta").exists());
27137    }
27138
27139    #[test]
27140    fn status_cmd_autoindexes_missing_workspace_scopes() {
27141        let dir = setup_workspace();
27142        let cfg = config::Config::load(dir.path()).unwrap();
27143        let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
27144        let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
27145        let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
27146        alpha_db.apply_changes(&alpha.source_root).unwrap();
27147
27148        let beta_db_path = cfg.db_path_for(dir.path(), "beta");
27149        assert!(!beta_db_path.exists());
27150
27151        cmd_status(
27152            dir.path(),
27153            StatusCommandOptions {
27154                fix: false,
27155                no_fix: false,
27156                json_output: true,
27157                compact: false,
27158                pretty: false,
27159                terse: false,
27160                schema: false,
27161            },
27162        )
27163        .unwrap();
27164
27165        assert!(beta_db_path.exists());
27166        let report = status::check_status(dir.path()).unwrap();
27167        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27168    }
27169
27170    #[test]
27171    fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
27172        let dir = setup_workspace();
27173        let cfg = config::Config::load(dir.path()).unwrap();
27174
27175        cmd_status(
27176            dir.path(),
27177            StatusCommandOptions {
27178                fix: false,
27179                no_fix: false,
27180                json_output: true,
27181                compact: false,
27182                pretty: false,
27183                terse: false,
27184                schema: false,
27185            },
27186        )
27187        .unwrap();
27188
27189        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
27190        assert!(cfg.db_path_for(dir.path(), "beta").exists());
27191        let report = status::check_status(dir.path()).unwrap();
27192        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27193    }
27194
27195    #[test]
27196    fn status_cmd_fix_refreshes_stale_index() {
27197        let dir = setup_graph_index();
27198        std::thread::sleep(std::time::Duration::from_millis(50));
27199        std::fs::write(
27200            dir.path().join("main.rs"),
27201            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27202        )
27203        .unwrap();
27204
27205        let report = status::check_status(dir.path()).unwrap();
27206        assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
27207
27208        cmd_status(
27209            dir.path(),
27210            StatusCommandOptions {
27211                fix: false,
27212                no_fix: false,
27213                json_output: true,
27214                compact: false,
27215                pretty: false,
27216                terse: false,
27217                schema: false,
27218            },
27219        )
27220        .unwrap();
27221
27222        let report = status::check_status(dir.path()).unwrap();
27223        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27224    }
27225
27226    #[test]
27227    fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
27228        let dir = setup_graph_index();
27229        let db_path = dir.path().join(".tsift/index.db");
27230        let _lock = hold_wal_database_lock(&db_path);
27231
27232        cmd_status(
27233            dir.path(),
27234            StatusCommandOptions {
27235                fix: false,
27236                no_fix: false,
27237                json_output: true,
27238                compact: false,
27239                pretty: false,
27240                terse: false,
27241                schema: false,
27242            },
27243        )
27244        .unwrap();
27245
27246        let report = status::check_status(dir.path()).unwrap();
27247        assert!(matches!(
27248            report.index,
27249            status::IndexStatus::Fresh {
27250                recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
27251                ..
27252            }
27253        ));
27254        let locks = status::check_locks(dir.path(), None, None).unwrap();
27255        assert!(matches!(
27256            locks.writer_lock,
27257            status::WriterLockStatus::Absent { .. }
27258        ));
27259        assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
27260        assert!(
27261            locks
27262                .recommended_action
27263                .contains("wedged writer holding live WAL sidecars")
27264        );
27265    }
27266
27267    #[test]
27268    fn locks_report_uses_ancestor_project_root_for_nested_paths() {
27269        let dir = setup_graph_index();
27270        let nested = dir.path().join("src/nested");
27271        std::fs::create_dir_all(&nested).unwrap();
27272
27273        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27274        let report = status::check_locks(&root, Some(&nested), None).unwrap();
27275
27276        assert_eq!(report.source_root, dir.path());
27277        assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
27278    }
27279
27280    #[test]
27281    fn workspace_locks_report_infers_scope_from_nested_path() {
27282        let dir = setup_workspace();
27283        cmd_index(
27284            dir.path(),
27285            false,
27286            false,
27287            false,
27288            false,
27289            false,
27290            true,
27291            None,
27292            false,
27293            false,
27294            false,
27295            false,
27296            false,
27297            false,
27298        )
27299        .unwrap();
27300        let nested = dir.path().join("src/alpha/nested");
27301        std::fs::create_dir_all(&nested).unwrap();
27302
27303        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27304        let report = status::check_locks(&root, Some(&nested), None).unwrap();
27305        let cfg = config::Config::load(dir.path()).unwrap();
27306
27307        assert_eq!(report.label, "submodule `alpha` index");
27308        assert_eq!(report.source_root, dir.path().join("src/alpha"));
27309        assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
27310        assert_eq!(
27311            report.reindex_command,
27312            format!("tsift index --submodule alpha {}", dir.path().display())
27313        );
27314    }
27315
27316    #[test]
27317    fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
27318        let dir = setup_workspace();
27319        cmd_index(
27320            dir.path(),
27321            false,
27322            false,
27323            false,
27324            false,
27325            false,
27326            true,
27327            None,
27328            false,
27329            false,
27330            false,
27331            false,
27332            false,
27333            false,
27334        )
27335        .unwrap();
27336
27337        let alpha = dir.path().join("src/alpha/lib.rs");
27338        std::thread::sleep(std::time::Duration::from_millis(50));
27339        std::fs::write(
27340            &alpha,
27341            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27342        )
27343        .unwrap();
27344
27345        let result = cmd_search(
27346            "alpha_helper".to_string(),
27347            Some(dir.path().to_path_buf()),
27348            5,
27349            Some("lexical".to_string()),
27350            Some("alpha".to_string()),
27351            false,
27352            false,
27353            true,
27354            0,
27355            false,
27356            false,
27357            false,
27358            false,
27359            false,
27360            false,
27361            false,
27362        );
27363
27364        assert!(result.is_ok());
27365
27366        let cfg = config::Config::load(dir.path()).unwrap();
27367        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27368        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27369        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27370    }
27371
27372    #[test]
27373    fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27374        let dir = setup_workspace();
27375        cmd_index(
27376            dir.path(),
27377            false,
27378            false,
27379            false,
27380            false,
27381            false,
27382            true,
27383            None,
27384            false,
27385            false,
27386            false,
27387            false,
27388            false,
27389            false,
27390        )
27391        .unwrap();
27392
27393        let alpha = dir.path().join("src/alpha/lib.rs");
27394        std::thread::sleep(std::time::Duration::from_millis(50));
27395        std::fs::write(
27396            &alpha,
27397            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27398        )
27399        .unwrap();
27400
27401        let cfg = config::Config::load(dir.path()).unwrap();
27402        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27403
27404        let err = cmd_search(
27405            "alpha_helper".to_string(),
27406            Some(dir.path().to_path_buf()),
27407            5,
27408            Some("lexical".to_string()),
27409            Some("alpha".to_string()),
27410            false,
27411            false,
27412            false,
27413            0,
27414            false,
27415            false,
27416            false,
27417            false,
27418            false,
27419            false,
27420            false,
27421        )
27422        .unwrap_err();
27423
27424        assert!(err.to_string().contains("search aborted"));
27425        assert!(err.to_string().contains("submodule `alpha` index"));
27426        assert!(!err.to_string().contains("database is locked"));
27427    }
27428
27429    #[test]
27430    fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
27431        let dir = setup_workspace();
27432        cmd_index(
27433            dir.path(),
27434            false,
27435            false,
27436            false,
27437            false,
27438            false,
27439            true,
27440            None,
27441            false,
27442            false,
27443            false,
27444            false,
27445            false,
27446            false,
27447        )
27448        .unwrap();
27449
27450        let alpha = dir.path().join("src/alpha/lib.rs");
27451        std::thread::sleep(std::time::Duration::from_millis(50));
27452        std::fs::write(
27453            &alpha,
27454            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27455        )
27456        .unwrap();
27457
27458        let result = cmd_search(
27459            "alpha_helper".to_string(),
27460            Some(dir.path().to_path_buf()),
27461            5,
27462            Some("lexical".to_string()),
27463            None,
27464            true,
27465            false,
27466            true,
27467            0,
27468            false,
27469            false,
27470            false,
27471            false,
27472            false,
27473            false,
27474            false,
27475        );
27476
27477        assert!(result.is_ok());
27478
27479        let cfg = config::Config::load(dir.path()).unwrap();
27480        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27481        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27482        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27483    }
27484
27485    #[test]
27486    fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27487        let dir = setup_workspace();
27488        cmd_index(
27489            dir.path(),
27490            false,
27491            false,
27492            false,
27493            false,
27494            false,
27495            true,
27496            None,
27497            false,
27498            false,
27499            false,
27500            false,
27501            false,
27502            false,
27503        )
27504        .unwrap();
27505
27506        let alpha = dir.path().join("src/alpha/lib.rs");
27507        std::thread::sleep(std::time::Duration::from_millis(50));
27508        std::fs::write(
27509            &alpha,
27510            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27511        )
27512        .unwrap();
27513
27514        let cfg = config::Config::load(dir.path()).unwrap();
27515        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27516
27517        let err = cmd_search(
27518            "alpha_helper".to_string(),
27519            Some(dir.path().to_path_buf()),
27520            5,
27521            Some("lexical".to_string()),
27522            None,
27523            true,
27524            false,
27525            false,
27526            30,
27527            false,
27528            false,
27529            false,
27530            false,
27531            false,
27532            false,
27533            false,
27534        )
27535        .unwrap_err();
27536
27537        assert!(err.to_string().contains("stale"));
27538        assert!(err.to_string().contains("submodule `alpha` index"));
27539        assert!(!err.to_string().contains("database is locked"));
27540    }
27541
27542    #[test]
27543    fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
27544        let dir = setup_workspace();
27545        cmd_index(
27546            dir.path(),
27547            false,
27548            false,
27549            false,
27550            false,
27551            false,
27552            true,
27553            None,
27554            false,
27555            false,
27556            false,
27557            false,
27558            false,
27559            false,
27560        )
27561        .unwrap();
27562
27563        let err = cmd_search(
27564            "alpha_helper".to_string(),
27565            Some(dir.path().to_path_buf()),
27566            5,
27567            Some("lexical".to_string()),
27568            None,
27569            false,
27570            false,
27571            true,
27572            0,
27573            false,
27574            false,
27575            false,
27576            false,
27577            false,
27578            false,
27579            false,
27580        )
27581        .unwrap_err();
27582
27583        assert_workspace_search_requires_explicit_target(err);
27584        assert!(!dir.path().join(".tsift/index.db").exists());
27585    }
27586
27587    #[test]
27588    fn workspace_search_cmd_infers_scope_from_nested_path() {
27589        let dir = setup_workspace();
27590        cmd_index(
27591            dir.path(),
27592            false,
27593            false,
27594            false,
27595            false,
27596            false,
27597            true,
27598            None,
27599            false,
27600            false,
27601            false,
27602            false,
27603            false,
27604            false,
27605        )
27606        .unwrap();
27607        let nested = dir.path().join("src/alpha/nested");
27608        std::fs::create_dir_all(&nested).unwrap();
27609
27610        let result = cmd_search(
27611            "alpha_helper".to_string(),
27612            Some(nested),
27613            5,
27614            Some("lexical".to_string()),
27615            None,
27616            false,
27617            false,
27618            false,
27619            0,
27620            false,
27621            false,
27622            false,
27623            false,
27624            false,
27625            false,
27626            false,
27627        );
27628
27629        assert!(result.is_ok());
27630    }
27631
27632    #[test]
27633    fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
27634        let dir = setup_workspace_with_duplicate_leaf_names();
27635        cmd_index(
27636            dir.path(),
27637            false,
27638            false,
27639            false,
27640            false,
27641            false,
27642            true,
27643            None,
27644            false,
27645            false,
27646            false,
27647            false,
27648            false,
27649            false,
27650        )
27651        .unwrap();
27652        let nested = dir.path().join("vendor/foo/nested");
27653        std::fs::create_dir_all(&nested).unwrap();
27654
27655        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27656        let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
27657        let cfg = config::Config::load(dir.path()).unwrap();
27658
27659        assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
27660    }
27661
27662    #[test]
27663    fn graph_cmd_succeeds_while_writer_lock_is_held() {
27664        let dir = setup_graph_index();
27665        let db_path = dir.path().join(".tsift/index.db");
27666        let _lock = hold_write_lock(&db_path);
27667
27668        let result = cmd_graph(
27669            "main",
27670            dir.path(),
27671            false,
27672            false,
27673            None,
27674            20,
27675            false,
27676            true,
27677            false,
27678            false,
27679            false,
27680            false,
27681            false,
27682            TagpathSearchOpts::default(),
27683        );
27684
27685        assert!(result.is_ok());
27686    }
27687
27688    #[test]
27689    fn graph_cmd_autoindexes_stale_index_by_default() {
27690        let dir = setup_graph_index();
27691        std::thread::sleep(std::time::Duration::from_millis(50));
27692        std::fs::write(
27693            dir.path().join("main.rs"),
27694            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27695        )
27696        .unwrap();
27697
27698        let result = cmd_graph(
27699            "helper",
27700            dir.path(),
27701            true,
27702            false,
27703            None,
27704            20,
27705            false,
27706            true,
27707            false,
27708            false,
27709            false,
27710            false,
27711            false,
27712            TagpathSearchOpts::default(),
27713        );
27714
27715        assert!(result.is_ok());
27716        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27717        let summary = db.compute_changes(dir.path()).unwrap();
27718        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27719    }
27720
27721    #[test]
27722    fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27723        let dir = setup_graph_index();
27724        let db_path = dir.path().join(".tsift/index.db");
27725        let _lock = hold_rollback_journal_lock(&db_path);
27726
27727        let result = cmd_graph(
27728            "main",
27729            dir.path(),
27730            false,
27731            false,
27732            None,
27733            20,
27734            false,
27735            true,
27736            false,
27737            false,
27738            false,
27739            false,
27740            false,
27741            TagpathSearchOpts::default(),
27742        );
27743
27744        assert!(result.is_ok());
27745    }
27746
27747    #[test]
27748    fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
27749        let dir = setup_graph_index();
27750        let nested = dir.path().join("src/nested");
27751        std::fs::create_dir_all(&nested).unwrap();
27752
27753        let result = cmd_graph(
27754            "helper",
27755            &nested,
27756            true,
27757            false,
27758            None,
27759            20,
27760            false,
27761            false,
27762            false,
27763            false,
27764            false,
27765            false,
27766            false,
27767            TagpathSearchOpts::default(),
27768        );
27769
27770        assert!(result.is_ok());
27771    }
27772
27773    #[test]
27774    fn communities_cmd_succeeds_while_writer_lock_is_held() {
27775        let dir = setup_graph_index();
27776        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27777
27778        let result = cmd_communities(
27779            dir.path(),
27780            None,
27781            1,
27782            10,
27783            false,
27784            false,
27785            false,
27786            false,
27787            false,
27788            false,
27789            TagpathSearchOpts::default(),
27790        );
27791
27792        assert!(result.is_ok());
27793    }
27794
27795    #[test]
27796    fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27797        let dir = setup_graph_index();
27798        let db_path = dir.path().join(".tsift/index.db");
27799        let _lock = hold_rollback_journal_lock(&db_path);
27800
27801        let result = cmd_communities(
27802            dir.path(),
27803            None,
27804            1,
27805            10,
27806            false,
27807            false,
27808            false,
27809            false,
27810            false,
27811            false,
27812            TagpathSearchOpts::default(),
27813        );
27814
27815        assert!(result.is_ok());
27816    }
27817
27818    #[test]
27819    fn lint_finds_entities_from_project_root_index_db() {
27820        let dir = tempfile::tempdir().unwrap();
27821        std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
27822        std::fs::write(
27823            dir.path().join("README.md"),
27824            "alpha_helper should be backticked.\n",
27825        )
27826        .unwrap();
27827        cmd_index(
27828            dir.path(),
27829            false,
27830            false,
27831            false,
27832            false,
27833            false,
27834            false,
27835            None,
27836            false,
27837            false,
27838            false,
27839            false,
27840            false,
27841            false,
27842        )
27843        .unwrap();
27844
27845        let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
27846            .unwrap()
27847            .unwrap();
27848        let entities = lint::collect_entities_from_index_path(&root).unwrap();
27849        let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
27850
27851        assert!(
27852            result
27853                .annotations
27854                .iter()
27855                .any(|ann| ann.text == "alpha_helper")
27856        );
27857    }
27858
27859    // --- search timeout ---
27860
27861    #[test]
27862    fn search_direct_runs_ok() {
27863        let dir = tempfile::tempdir().unwrap();
27864        let search_dir = dir.path().to_path_buf();
27865        let cache_dir = search_dir.join(".tsift/search-cache");
27866        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27867        let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical");
27868        assert!(result.is_ok(), "direct search should succeed");
27869        assert!(
27870            cache_dir.exists(),
27871            "search should create the configured cache dir"
27872        );
27873    }
27874
27875    #[test]
27876    fn search_timeout_zero_disables_timeout() {
27877        let dir = tempfile::tempdir().unwrap();
27878        let search_dir = dir.path().to_path_buf();
27879        let cache_dir = search_dir.join(".tsift/search-cache");
27880        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27881        let result = run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[]);
27882        assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
27883        assert!(
27884            cache_dir.exists(),
27885            "timeout=0 should keep using the stable search cache dir"
27886        );
27887    }
27888
27889    #[test]
27890    fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
27891        let dir = tempfile::tempdir().unwrap();
27892        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
27893        cmd_index(
27894            dir.path(),
27895            false,
27896            false,
27897            false,
27898            false,
27899            false,
27900            false,
27901            None,
27902            false,
27903            false,
27904            false,
27905            false,
27906            false,
27907            false,
27908        )
27909        .unwrap();
27910        let db_path = dir.path().join(".tsift/index.db");
27911        std::fs::remove_file(&db_path).unwrap();
27912        let search_target = SearchIndexTarget {
27913            label: "index".to_string(),
27914            db_path,
27915            source_root: dir.path().to_path_buf(),
27916            scope_name: None,
27917            reindex_cmd: format!("tsift index {}", dir.path().display()),
27918        };
27919
27920        let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
27921
27922        assert!(message.contains("timed out after 1s"));
27923        assert!(message.contains("index is missing"));
27924        assert!(message.contains("Run `tsift index"));
27925        assert!(!message.contains("search root looks fresh"));
27926    }
27927
27928    #[test]
27929    fn search_worker_output_path_uses_json_suffix() {
27930        let path = next_search_worker_output_path();
27931        assert!(path.extension().is_some_and(|ext| ext == "json"));
27932    }
27933
27934    // --- index quiet mode ---
27935
27936    #[test]
27937    fn index_quiet_suppresses_file_list() {
27938        let dir = setup_graph_index();
27939        let result = cmd_index(
27940            dir.path(),
27941            false,
27942            true,
27943            false,
27944            false,
27945            true,
27946            false,
27947            None,
27948            false,
27949            false,
27950            false,
27951            false,
27952            false,
27953            false,
27954        );
27955        assert!(result.is_ok());
27956    }
27957
27958    #[test]
27959    fn index_exit_code_implies_quiet() {
27960        let dir = setup_graph_index();
27961        let result = cmd_index(
27962            dir.path(),
27963            false,
27964            true,
27965            false,
27966            false,
27967            false,
27968            false,
27969            None,
27970            false,
27971            false,
27972            false,
27973            false,
27974            false,
27975            false,
27976        );
27977        assert!(result.is_ok());
27978    }
27979
27980    #[test]
27981    fn index_quiet_json_omits_changes() {
27982        let dir = setup_graph_index();
27983        let result = cmd_index(
27984            dir.path(),
27985            false,
27986            true,
27987            false,
27988            false,
27989            true,
27990            false,
27991            None,
27992            true,
27993            false,
27994            false,
27995            false,
27996            false,
27997            false,
27998        );
27999        assert!(result.is_ok());
28000    }
28001
28002    #[test]
28003    fn cli_workflow_defaults_to_search_topic() {
28004        let cli = parse_cli(["tsift", "workflow"]);
28005        match cli.command {
28006            Some(Commands::Workflow { topic, json }) => {
28007                assert_eq!(topic, "search");
28008                assert!(!json);
28009            }
28010            _ => panic!("expected Workflow command"),
28011        }
28012    }
28013
28014    #[test]
28015    fn search_workflow_recipe_preserves_handles_across_expansions() {
28016        let recipe = workflow::search_workflow_recipe();
28017        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
28018        assert_eq!(
28019            step_names,
28020            vec![
28021                "exact-anchor",
28022                "semantic-search",
28023                "explain-symbol",
28024                "summarize-selection",
28025                "digest-expansion"
28026            ]
28027        );
28028        assert!(
28029            recipe
28030                .handle_contract
28031                .iter()
28032                .any(|item| item.contains("originating command"))
28033        );
28034        assert!(
28035            recipe.steps[1]
28036                .preserves
28037                .iter()
28038                .any(|item| item.contains("sfam-*"))
28039        );
28040        assert!(
28041            recipe.steps[2]
28042                .preserves
28043                .iter()
28044                .any(|item| item.contains("ecall-*"))
28045        );
28046        assert!(
28047            recipe.steps[4]
28048                .preserves
28049                .iter()
28050                .any(|item| item.contains("artifact handles"))
28051        );
28052    }
28053
28054    // --- JSON compact vs pretty ---
28055
28056    #[test]
28057    fn to_json_compact_default() {
28058        let val = serde_json::json!({"a": 1, "b": [2, 3]});
28059        let compact = to_json(&val, false, false).unwrap();
28060        assert!(!compact.contains('\n'));
28061        assert!(
28062            compact.contains("\"a\":1")
28063                || compact.contains("\"a\": 1")
28064                || compact.contains("\"a\":")
28065        );
28066    }
28067
28068    #[test]
28069    fn to_json_pretty_indents() {
28070        let val = serde_json::json!({"a": 1, "b": [2, 3]});
28071        let pretty = to_json(&val, true, false).unwrap();
28072        assert!(pretty.contains('\n'));
28073        assert!(pretty.contains("  "));
28074    }
28075
28076    #[test]
28077    fn to_json_compact_is_shorter() {
28078        let val =
28079            serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
28080        let compact = to_json(&val, false, false).unwrap();
28081        let pretty = to_json(&val, true, false).unwrap();
28082        assert!(compact.len() < pretty.len());
28083    }
28084
28085    #[test]
28086    fn terse_renames_keys() {
28087        let val =
28088            serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
28089        let result = to_json(&val, false, true).unwrap();
28090        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28091        assert!(parsed["_s"].is_object());
28092        let d = &parsed["d"];
28093        assert_eq!(d["cf"], "a.rs");
28094        assert_eq!(d["cn"], "main");
28095        assert_eq!(d["csl"], 10);
28096    }
28097
28098    #[test]
28099    fn terse_schema_only_includes_used_keys() {
28100        let val = serde_json::json!({"name": "test", "score": 0.5});
28101        let result = to_json(&val, false, true).unwrap();
28102        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28103        let schema = parsed["_s"].as_object().unwrap();
28104        assert_eq!(schema["n"], "name");
28105        assert_eq!(schema["sc"], "score");
28106        assert!(!schema.contains_key("cf"));
28107    }
28108
28109    #[test]
28110    fn terse_nested_arrays() {
28111        let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
28112        let result = to_json(&val, false, true).unwrap();
28113        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28114        let d = &parsed["d"];
28115        assert_eq!(d["crs"][0]["cn"], "a");
28116        assert_eq!(d["crs"][0]["cf"], "b.rs");
28117    }
28118
28119    #[test]
28120    fn terse_preserves_unknown_keys() {
28121        let val = serde_json::json!({"custom_field": "value", "name": "test"});
28122        let result = to_json(&val, false, true).unwrap();
28123        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28124        let d = &parsed["d"];
28125        assert_eq!(d["custom_field"], "value");
28126        assert_eq!(d["n"], "test");
28127    }
28128
28129    // --- ultra-terse ---
28130
28131    #[test]
28132    fn ultra_terse_strips_properties_from_graph_nodes() {
28133        let val = serde_json::json!({
28134            "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
28135        });
28136        let result = to_json_schema(&val, false, true, true, false).unwrap();
28137        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28138        let node = &parsed["d"]["nodes"][0];
28139        assert_eq!(node["id"], "fn:main");
28140        assert_eq!(node["k"], "fn");
28141        assert_eq!(node["n"], "main");
28142        assert!(node.get("properties").is_none());
28143    }
28144
28145    #[test]
28146    fn ultra_terse_strips_properties_from_graph_edges() {
28147        let val = serde_json::json!({
28148            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
28149        });
28150        let result = to_json_schema(&val, false, true, true, false).unwrap();
28151        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28152        let edge = &parsed["d"]["edges"][0];
28153        assert_eq!(edge["from_id"], "a");
28154        assert_eq!(edge["to_id"], "b");
28155        assert_eq!(edge["k"], "c");
28156        assert!(edge.get("properties").is_none());
28157    }
28158
28159    #[test]
28160    fn ultra_terse_abbreviates_edge_kinds() {
28161        let val = serde_json::json!({
28162            "edges": [
28163                {"from_id": "a", "to_id": "b", "kind": "defines"},
28164                {"from_id": "a", "to_id": "c", "kind": "contains"},
28165                {"from_id": "a", "to_id": "d", "kind": "imports"},
28166                {"from_id": "a", "to_id": "e", "kind": "mentions"},
28167                {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
28168                {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
28169                {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
28170                {"from_id": "a", "to_id": "i", "kind": "uses"},
28171                {"from_id": "a", "to_id": "j", "kind": "parent"},
28172                {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
28173            ]
28174        });
28175        let result = to_json_schema(&val, false, true, true, false).unwrap();
28176        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28177        let edges = &parsed["d"]["edges"].as_array().unwrap();
28178        assert_eq!(edges[0]["k"], "d");
28179        assert_eq!(edges[1]["k"], "ct");
28180        assert_eq!(edges[2]["k"], "i");
28181        assert_eq!(edges[3]["k"], "m");
28182        assert_eq!(edges[4]["k"], "sr");
28183        assert_eq!(edges[5]["k"], "bt");
28184        assert_eq!(edges[6]["k"], "sctx");
28185        assert_eq!(edges[7]["k"], "u");
28186        assert_eq!(edges[8]["k"], "p");
28187        assert_eq!(edges[9]["k"], "unknown_edge");
28188    }
28189
28190    #[test]
28191    fn ultra_terse_strips_provenance_freshness_from_edges() {
28192        let val = serde_json::json!({
28193            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
28194        });
28195        let result = to_json_schema(&val, false, true, true, false).unwrap();
28196        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28197        let edge = &parsed["d"]["edges"][0];
28198        assert!(edge.get("provenance").is_none());
28199        assert!(edge.get("freshness").is_none());
28200        assert_eq!(edge["k"], "c");
28201    }
28202
28203    #[test]
28204    fn ultra_terse_truncates_snippets() {
28205        let long_snippet = "x".repeat(120);
28206        let val = serde_json::json!({"snippet": long_snippet});
28207        let result = to_json_schema(&val, false, true, true, false).unwrap();
28208        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28209        let snipped = parsed["d"]["sn"].as_str().unwrap();
28210        assert_eq!(snipped.len(), 80);
28211        assert!(snipped.ends_with("..."));
28212    }
28213
28214    #[test]
28215    fn ultra_terse_truncates_abbreviated_snippet_key() {
28216        let long_snippet = "y".repeat(100);
28217        let val = serde_json::json!({"snippet": long_snippet});
28218        let result = to_json_schema(&val, false, true, true, false).unwrap();
28219        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28220        let snipped = parsed["d"]["sn"].as_str().unwrap();
28221        assert_eq!(snipped.len(), 80);
28222        assert!(snipped.ends_with("..."));
28223    }
28224
28225    #[test]
28226    fn ultra_terse_compacts_coverage_snapshot() {
28227        let val = serde_json::json!({
28228            "mode": "incremental",
28229            "total_sector_count": 10,
28230            "dirty_sector_count": 2,
28231            "active_rebuild": Some("rebuild-1"),
28232            "completed_dirty_sector_count": 1,
28233            "mounted_sector_count": 8,
28234            "rebuilding_sector_count": 1,
28235            "resumed_sector_count": 3,
28236            "reused_sector_count": 5
28237        });
28238        let result = to_json_schema(&val, false, true, true, false).unwrap();
28239        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28240        let d = &parsed["d"];
28241        assert_eq!(d["mode"], "incremental");
28242        assert_eq!(d["total_sector_count"], 10);
28243        assert_eq!(d["dirty_sector_count"], 2);
28244        assert!(d.get("active_rebuild").is_none());
28245        assert!(d.get("completed_dirty_sector_count").is_none());
28246        assert!(d.get("mounted_sector_count").is_none());
28247        assert!(d.get("rebuilding_sector_count").is_none());
28248        assert!(d.get("resumed_sector_count").is_none());
28249        assert!(d.get("reused_sector_count").is_none());
28250    }
28251
28252    #[test]
28253    fn ultra_terse_short_snippet_unchanged() {
28254        let val = serde_json::json!({"snippet": "short text"});
28255        let result = to_json_schema(&val, false, true, true, false).unwrap();
28256        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28257        assert_eq!(parsed["d"]["sn"], "short text");
28258    }
28259
28260    #[test]
28261    fn ultra_terse_non_graph_object_properties_preserved() {
28262        let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
28263        let result = to_json_schema(&val, false, true, true, false).unwrap();
28264        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28265        assert!(parsed["d"]["config"]["properties"].is_object());
28266    }
28267
28268    // --- schema-then-values ---
28269
28270    #[test]
28271    fn schema_converts_homogeneous_arrays() {
28272        let val = serde_json::json!({"symbols": [
28273            {"name": "foo", "kind": "fn", "line": 10},
28274            {"name": "bar", "kind": "fn", "line": 20}
28275        ]});
28276        let result = to_json_schema(&val, false, false, false, true).unwrap();
28277        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28278        let syms = &parsed["symbols"];
28279        let columns = syms["_c"]
28280            .as_array()
28281            .unwrap()
28282            .iter()
28283            .map(|value| value.as_str().unwrap())
28284            .collect::<Vec<_>>();
28285        let row0 = syms["_r"][0].as_array().unwrap();
28286        let row1 = syms["_r"][1].as_array().unwrap();
28287        let name_index = columns.iter().position(|column| *column == "name").unwrap();
28288        let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
28289        let line_index = columns.iter().position(|column| *column == "line").unwrap();
28290        assert_eq!(row0[name_index], "foo");
28291        assert_eq!(row0[kind_index], "fn");
28292        assert_eq!(row0[line_index], 10);
28293        assert_eq!(row1[name_index], "bar");
28294        assert_eq!(row1[kind_index], "fn");
28295        assert_eq!(row1[line_index], 20);
28296    }
28297
28298    #[test]
28299    fn schema_skips_short_arrays() {
28300        let val = serde_json::json!({"items": [{"name": "only"}]});
28301        let result = to_json_schema(&val, false, false, false, true).unwrap();
28302        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28303        assert!(parsed["items"].is_array());
28304        assert_eq!(parsed["items"][0]["name"], "only");
28305    }
28306
28307    #[test]
28308    fn schema_skips_heterogeneous_arrays() {
28309        let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
28310        let result = to_json_schema(&val, false, false, false, true).unwrap();
28311        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28312        assert!(parsed["items"].is_array());
28313        assert_eq!(parsed["items"][0]["a"], 1);
28314    }
28315
28316    #[test]
28317    fn schema_with_terse_combines() {
28318        let val = serde_json::json!({"callers": [
28319            {"caller_name": "a", "caller_file": "x.rs"},
28320            {"caller_name": "b", "caller_file": "y.rs"}
28321        ]});
28322        let result = to_json_schema(&val, false, true, false, true).unwrap();
28323        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28324        assert!(parsed["_s"].is_object());
28325        let d = &parsed["d"];
28326        let crs = &d["crs"];
28327        assert!(crs["_c"].is_array());
28328        assert!(crs["_r"].is_array());
28329        let columns = crs["_c"]
28330            .as_array()
28331            .unwrap()
28332            .iter()
28333            .map(|value| value.as_str().unwrap())
28334            .collect::<Vec<_>>();
28335        let row = crs["_r"][0].as_array().unwrap();
28336        let name_index = columns.iter().position(|column| *column == "cn").unwrap();
28337        let file_index = columns.iter().position(|column| *column == "cf").unwrap();
28338        assert_eq!(row[name_index], "a");
28339        assert_eq!(row[file_index], "x.rs");
28340    }
28341
28342    #[test]
28343    fn schema_preserves_non_object_arrays() {
28344        let val = serde_json::json!({"tags": ["a", "b", "c"]});
28345        let result = to_json_schema(&val, false, false, false, true).unwrap();
28346        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28347        assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
28348    }
28349
28350    #[test]
28351    fn cli_accepts_global_schema_flag() {
28352        let cli = parse_cli(["tsift", "--schema", "search", "test"]);
28353        assert!(cli.schema);
28354        assert!(matches!(cli.command, Some(Commands::Search { .. })));
28355    }
28356
28357    #[test]
28358    fn cli_accepts_global_envelope_flag() {
28359        let cli = parse_cli([
28360            "tsift",
28361            "--envelope",
28362            "context-pack",
28363            "tasks/software/tsift.md",
28364        ]);
28365        assert!(cli.envelope);
28366        assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
28367    }
28368
28369    #[test]
28370    fn cli_accepts_locks_command() {
28371        let cli = parse_cli(["tsift", "locks"]);
28372        assert!(matches!(cli.command, Some(Commands::Locks { .. })));
28373    }
28374
28375    #[test]
28376    fn cli_parses_memory_budget_guard_command() {
28377        let cli = parse_cli([
28378            "tsift",
28379            "memory",
28380            "budget-guard",
28381            "--file",
28382            "tool.log",
28383            "--budget-tokens",
28384            "1000",
28385            "--json",
28386        ]);
28387        match cli.command {
28388            Some(Commands::Memory {
28389                command:
28390                    crate::cli::MemoryCommand::BudgetGuard {
28391                        file,
28392                        budget_tokens,
28393                        json,
28394                        ..
28395                    },
28396            }) => {
28397                assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
28398                assert_eq!(budget_tokens, 1000);
28399                assert!(json);
28400            }
28401            _ => panic!("expected memory budget-guard command"),
28402        }
28403    }
28404
28405    #[test]
28406    fn cli_parses_memory_capture_agent_doc_closeout_command() {
28407        let cli = parse_cli([
28408            "tsift",
28409            "memory",
28410            "capture-agent-doc-closeout",
28411            ".",
28412            "--session-path",
28413            "tasks/software/tsift.md",
28414            "--prompt-target",
28415            "do [#tsiftmemhooks]",
28416            "--response-summary",
28417            "wired closeout capture",
28418            "--commit-hash",
28419            "abc123",
28420            "--session-check-status",
28421            "clean",
28422            "--json",
28423        ]);
28424        match cli.command {
28425            Some(Commands::Memory {
28426                command:
28427                    crate::cli::MemoryCommand::CaptureAgentDocCloseout {
28428                        path,
28429                        session_path,
28430                        prompt_target,
28431                        response_summary,
28432                        commit_hash,
28433                        session_check_status,
28434                        json,
28435                    },
28436            }) => {
28437                assert_eq!(path, std::path::PathBuf::from("."));
28438                assert_eq!(
28439                    session_path,
28440                    std::path::PathBuf::from("tasks/software/tsift.md")
28441                );
28442                assert_eq!(prompt_target, "do [#tsiftmemhooks]");
28443                assert_eq!(response_summary, "wired closeout capture");
28444                assert_eq!(commit_hash.as_deref(), Some("abc123"));
28445                assert_eq!(session_check_status, "clean");
28446                assert!(json);
28447            }
28448            _ => panic!("expected memory capture-agent-doc-closeout command"),
28449        }
28450    }
28451
28452    #[test]
28453    fn cli_parses_memory_project_graph_read_policy() {
28454        let cli = parse_cli([
28455            "tsift",
28456            "memory",
28457            "project-graph",
28458            ".",
28459            "--read-policy",
28460            "query-relevant",
28461            "--query",
28462            "semantic memory",
28463            "--limit",
28464            "7",
28465            "--json",
28466        ]);
28467        match cli.command {
28468            Some(Commands::Memory {
28469                command:
28470                    crate::cli::MemoryCommand::ProjectGraph {
28471                        read_policy,
28472                        query,
28473                        limit,
28474                        json,
28475                        ..
28476                    },
28477            }) => {
28478                assert_eq!(
28479                    read_policy,
28480                    crate::cli::MemoryProjectReadPolicy::QueryRelevant
28481                );
28482                assert_eq!(query.as_deref(), Some("semantic memory"));
28483                assert_eq!(limit, 7);
28484                assert!(json);
28485            }
28486            _ => panic!("expected memory project-graph command"),
28487        }
28488    }
28489
28490    #[test]
28491    fn cli_locks_accepts_scope_flag() {
28492        let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
28493        match cli.command {
28494            Some(Commands::Locks { scope, .. }) => {
28495                assert_eq!(scope.as_deref(), Some("alpha"));
28496            }
28497            _ => panic!("expected Locks command"),
28498        }
28499    }
28500
28501    #[test]
28502    fn cli_search_accepts_autoindex_flag() {
28503        let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
28504        match cli.command {
28505            Some(Commands::Search {
28506                autoindex,
28507                no_autoindex,
28508                ..
28509            }) => {
28510                assert!(autoindex);
28511                assert!(!no_autoindex);
28512            }
28513            _ => panic!("expected Search command"),
28514        }
28515    }
28516
28517    #[test]
28518    fn cli_search_accepts_exact_flag() {
28519        let cli = parse_cli(["tsift", "search", "test", "--exact"]);
28520        match cli.command {
28521            Some(Commands::Search {
28522                exact, strategy, ..
28523            }) => {
28524                assert!(exact);
28525                assert!(strategy.is_none());
28526            }
28527            _ => panic!("expected Search command"),
28528        }
28529    }
28530
28531    #[test]
28532    fn cli_parses_diff_digest_command() {
28533        let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
28534        match cli.command {
28535            Some(Commands::DiffDigest {
28536                json,
28537                path,
28538                cached,
28539                revision,
28540                max_parsed_files,
28541            }) => {
28542                assert!(json);
28543                assert_eq!(path, PathBuf::from("."));
28544                assert!(!cached);
28545                assert!(revision.is_none());
28546                assert_eq!(max_parsed_files, 25);
28547            }
28548            _ => panic!("expected DiffDigest command"),
28549        }
28550    }
28551
28552    #[test]
28553    fn cli_rejects_conflicting_diff_digest_modes() {
28554        match try_parse_cli([
28555            "tsift",
28556            "diff-digest",
28557            "--cached",
28558            "--revision",
28559            "HEAD",
28560            ".",
28561        ]) {
28562            Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
28563            Err(err) => {
28564                assert!(err.to_string().contains("--cached"));
28565                assert!(err.to_string().contains("--revision"));
28566            }
28567        }
28568    }
28569
28570    #[test]
28571    fn cli_parses_test_digest_command() {
28572        let cli = parse_cli([
28573            "tsift",
28574            "test-digest",
28575            "--path",
28576            ".",
28577            "--input",
28578            "target/test.log",
28579            "--runner",
28580            "cargo",
28581            "--json",
28582        ]);
28583        match cli.command {
28584            Some(Commands::TestDigest {
28585                json,
28586                path,
28587                input,
28588                runner,
28589            }) => {
28590                assert!(json);
28591                assert_eq!(path, PathBuf::from("."));
28592                assert_eq!(input, Some(PathBuf::from("target/test.log")));
28593                assert_eq!(runner.as_deref(), Some("cargo"));
28594            }
28595            _ => panic!("expected TestDigest command"),
28596        }
28597    }
28598
28599    #[test]
28600    fn cli_parses_log_digest_command() {
28601        let cli = parse_cli([
28602            "tsift",
28603            "log-digest",
28604            "--path",
28605            ".",
28606            "--input",
28607            "target/build.log",
28608            "--json",
28609        ]);
28610        match cli.command {
28611            Some(Commands::LogDigest {
28612                json,
28613                path,
28614                input,
28615                fixture,
28616                fail_under,
28617            }) => {
28618                assert!(json);
28619                assert_eq!(path, PathBuf::from("."));
28620                assert_eq!(input, Some(PathBuf::from("target/build.log")));
28621                assert!(fixture.is_none());
28622                assert!(!fail_under);
28623            }
28624            _ => panic!("expected LogDigest command"),
28625        }
28626    }
28627
28628    #[test]
28629    fn cli_parses_metric_digest_command() {
28630        let cli = parse_cli([
28631            "tsift",
28632            "metric-digest",
28633            "--input",
28634            "target/runs.json",
28635            "--baseline",
28636            "target/prior.json",
28637            "--metric",
28638            "session_mae",
28639            "--lower-is-better",
28640            "session_mae",
28641            "--history",
28642            "4",
28643            "--top",
28644            "2",
28645            "--json",
28646        ]);
28647        match cli.command {
28648            Some(Commands::MetricDigest {
28649                input,
28650                baseline,
28651                metrics,
28652                lower_is_better,
28653                history,
28654                top,
28655                json,
28656                ..
28657            }) => {
28658                assert!(json);
28659                assert_eq!(input, Some(PathBuf::from("target/runs.json")));
28660                assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
28661                assert_eq!(metrics, vec!["session_mae"]);
28662                assert_eq!(lower_is_better, vec!["session_mae"]);
28663                assert_eq!(history, 4);
28664                assert_eq!(top, 2);
28665            }
28666            _ => panic!("expected MetricDigest command"),
28667        }
28668    }
28669
28670    #[test]
28671    fn cli_parses_dci_benchmark_command() {
28672        let cli = parse_cli([
28673            "tsift",
28674            "dci-benchmark",
28675            "--fixture",
28676            "fixtures/dci-search-benchmark.json",
28677            "--json",
28678        ]);
28679        match cli.command {
28680            Some(Commands::DciBenchmark { fixture, json }) => {
28681                assert!(json);
28682                assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
28683            }
28684            _ => panic!("expected DciBenchmark command"),
28685        }
28686    }
28687
28688    #[test]
28689    fn cli_parses_session_digest_command() {
28690        let cli = parse_cli([
28691            "tsift",
28692            "session-digest",
28693            "--path",
28694            ".",
28695            "--input",
28696            "target/session.md",
28697            "--source",
28698            "markdown",
28699            "--json",
28700        ]);
28701        match cli.command {
28702            Some(Commands::SessionDigest {
28703                json,
28704                path,
28705                input,
28706                source,
28707            }) => {
28708                assert!(json);
28709                assert_eq!(path, PathBuf::from("."));
28710                assert_eq!(input, Some(PathBuf::from("target/session.md")));
28711                assert_eq!(source.as_deref(), Some("markdown"));
28712            }
28713            _ => panic!("expected SessionDigest command"),
28714        }
28715    }
28716
28717    #[test]
28718    fn cli_parses_session_cost_command() {
28719        let cli = parse_cli([
28720            "tsift",
28721            "session-cost",
28722            "--input",
28723            "target/session.jsonl",
28724            "--source",
28725            "codex-jsonl",
28726            "--json",
28727        ]);
28728        match cli.command {
28729            Some(Commands::SessionCost {
28730                json,
28731                input,
28732                fixture,
28733                fail_under,
28734                source,
28735            }) => {
28736                assert!(json);
28737                assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
28738                assert_eq!(fixture, None);
28739                assert!(!fail_under);
28740                assert_eq!(source.as_deref(), Some("codex-jsonl"));
28741            }
28742            _ => panic!("expected SessionCost command"),
28743        }
28744
28745        let cli = parse_cli([
28746            "tsift",
28747            "session-cost",
28748            "--fixture",
28749            "fixtures/real-session-prompt-cache-effectiveness.json",
28750            "--fail-under",
28751            "--json",
28752        ]);
28753        match cli.command {
28754            Some(Commands::SessionCost {
28755                json,
28756                input,
28757                fixture,
28758                fail_under,
28759                source,
28760            }) => {
28761                assert!(json);
28762                assert_eq!(input, None);
28763                assert_eq!(
28764                    fixture,
28765                    Some(PathBuf::from(
28766                        "fixtures/real-session-prompt-cache-effectiveness.json"
28767                    ))
28768                );
28769                assert!(fail_under);
28770                assert_eq!(source, None);
28771            }
28772            _ => panic!("expected SessionCost command"),
28773        }
28774    }
28775
28776    #[test]
28777    fn cli_parses_session_review_command() {
28778        let cli = parse_cli([
28779            "tsift",
28780            "session-review",
28781            "tasks/software/tsift.md",
28782            "--next-context",
28783            "--json",
28784        ]);
28785        match cli.command {
28786            Some(Commands::SessionReview {
28787                json,
28788                next_context,
28789                path,
28790                ..
28791            }) => {
28792                assert!(json);
28793                assert!(next_context);
28794                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28795            }
28796            _ => panic!("expected SessionReview command"),
28797        }
28798    }
28799
28800    #[test]
28801    fn cli_search_accepts_budget_flags() {
28802        let cli = parse_cli([
28803            "tsift",
28804            "search",
28805            "alpha_helper",
28806            "--max-items",
28807            "3",
28808            "--max-bytes",
28809            "96",
28810        ]);
28811        match cli.command {
28812            Some(Commands::Search {
28813                max_items,
28814                max_bytes,
28815                ..
28816            }) => {
28817                assert_eq!(max_items, Some(3));
28818                assert_eq!(max_bytes, Some(96));
28819            }
28820            _ => panic!("expected Search command"),
28821        }
28822    }
28823
28824    #[test]
28825    fn cli_search_accepts_budget_preset() {
28826        let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
28827        match cli.command {
28828            Some(Commands::Search { budget, .. }) => {
28829                assert_eq!(budget, Some(ResponseBudgetPreset::Small));
28830            }
28831            _ => panic!("expected Search command"),
28832        }
28833    }
28834
28835    #[test]
28836    fn cli_search_accepts_ast_facet_filters() {
28837        let cli = parse_cli([
28838            "tsift",
28839            "search",
28840            "setup",
28841            "--lang",
28842            "markdown",
28843            "--kind",
28844            "list_item",
28845            "--node-kind",
28846            "list_item",
28847            "--section",
28848            "Install",
28849            "--parent",
28850            "Run setup.",
28851            "--child",
28852            "Confirm setup.",
28853            "--fence-language",
28854            "rust",
28855            "--list-depth",
28856            "1",
28857            "--heading-level",
28858            "2",
28859        ]);
28860        match cli.command {
28861            Some(Commands::Search {
28862                lang,
28863                kind,
28864                node_kind,
28865                section,
28866                parent,
28867                child,
28868                fence_language,
28869                list_depth,
28870                heading_level,
28871                ..
28872            }) => {
28873                assert_eq!(lang, vec!["markdown"]);
28874                assert_eq!(kind, vec!["list_item"]);
28875                assert_eq!(node_kind, vec!["list_item"]);
28876                assert_eq!(section, vec!["Install"]);
28877                assert_eq!(parent, vec!["Run setup."]);
28878                assert_eq!(child, vec!["Confirm setup."]);
28879                assert_eq!(fence_language, vec!["rust"]);
28880                assert_eq!(list_depth, vec![1]);
28881                assert_eq!(heading_level, vec![2]);
28882            }
28883            _ => panic!("expected Search command"),
28884        }
28885    }
28886
28887    #[test]
28888    fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
28889        let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
28890        assert_eq!(small.preview_items(), 3);
28891        assert_eq!(small.preview_bytes(), 120);
28892        assert_eq!(small.follow_up_items(), 4);
28893
28894        let overridden =
28895            ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
28896        assert_eq!(overridden.preview_items(), 7);
28897        assert_eq!(overridden.preview_bytes(), 120);
28898        assert_eq!(overridden.follow_up_items(), 7);
28899
28900        let envelope_default = ResponseBudget::from_cli(None, None, None, true);
28901        assert!(envelope_default.is_active());
28902    }
28903
28904    #[test]
28905    fn cli_explain_accepts_budget_flags() {
28906        let cli = parse_cli([
28907            "tsift",
28908            "explain",
28909            "alpha_helper",
28910            "--max-items",
28911            "2",
28912            "--max-bytes",
28913            "80",
28914        ]);
28915        match cli.command {
28916            Some(Commands::Explain {
28917                max_items,
28918                max_bytes,
28919                ..
28920            }) => {
28921                assert_eq!(max_items, Some(2));
28922                assert_eq!(max_bytes, Some(80));
28923            }
28924            _ => panic!("expected Explain command"),
28925        }
28926    }
28927
28928    #[test]
28929    fn cli_session_review_accepts_budget_flags() {
28930        let cli = parse_cli([
28931            "tsift",
28932            "session-review",
28933            "tasks/software/tsift.md",
28934            "--max-items",
28935            "4",
28936            "--max-bytes",
28937            "120",
28938        ]);
28939        match cli.command {
28940            Some(Commands::SessionReview {
28941                max_items,
28942                max_bytes,
28943                ..
28944            }) => {
28945                assert_eq!(max_items, Some(4));
28946                assert_eq!(max_bytes, Some(120));
28947            }
28948            _ => panic!("expected SessionReview command"),
28949        }
28950    }
28951
28952    #[test]
28953    fn cli_parses_context_pack_command() {
28954        let cli = parse_cli([
28955            "tsift",
28956            "context-pack",
28957            "tasks/software/tsift.md",
28958            "--test-input",
28959            "target/test.log",
28960            "--runner",
28961            "cargo",
28962            "--log-input",
28963            "target/build.log",
28964            "--max-items",
28965            "3",
28966            "--max-bytes",
28967            "96",
28968            "--json",
28969        ]);
28970        match cli.command {
28971            Some(Commands::ContextPack {
28972                path,
28973                test_input,
28974                runner,
28975                log_input,
28976                json,
28977                max_items,
28978                max_bytes,
28979                budget,
28980                convex_snapshot,
28981            }) => {
28982                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28983                assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
28984                assert_eq!(runner.as_deref(), Some("cargo"));
28985                assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
28986                assert!(json);
28987                assert_eq!(max_items, Some(3));
28988                assert_eq!(max_bytes, Some(96));
28989                assert!(budget.is_none());
28990                assert!(convex_snapshot.is_none());
28991            }
28992            _ => panic!("expected ContextPack command"),
28993        }
28994    }
28995
28996    #[test]
28997    fn cli_parses_token_savings_command() {
28998        let cli = parse_cli([
28999            "tsift",
29000            "token-savings",
29001            "--fixture",
29002            "fixtures/tsift-token-savings.json",
29003            "--fail-under",
29004            "--json",
29005        ]);
29006        match cli.command {
29007            Some(Commands::TokenSavings {
29008                fixture,
29009                fail_under,
29010                json,
29011            }) => {
29012                assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
29013                assert!(fail_under);
29014                assert!(json);
29015            }
29016            _ => panic!("expected TokenSavings command"),
29017        }
29018    }
29019
29020    #[test]
29021    fn token_savings_report_records_fixture_thresholds() {
29022        let raw_symbols = [
29023            "validate_user",
29024            "validateUser",
29025            "ValidateUser",
29026            "validate-user",
29027            "VALIDATE_USER",
29028            "Validate_User",
29029            "raw_symbol",
29030            "rawSymbol",
29031            "RawSymbol",
29032            "raw-symbol",
29033            "RAW_SYMBOL",
29034            "Raw_Symbol",
29035        ]
29036        .iter()
29037        .enumerate()
29038        .map(|(idx, identifier)| TokenSavingsRawSymbol {
29039            identifier: (*identifier).to_string(),
29040            file: format!("src/example_{idx}.rs"),
29041            line: (idx + 1) as u64,
29042            context: "function".to_string(),
29043        })
29044        .collect();
29045        let fixture = TokenSavingsFixture {
29046            schema_version: 1,
29047            description: "fixture".to_string(),
29048            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29049            cases: vec![TokenSavingsFixtureCase {
29050                name: "search-preview".to_string(),
29051                surface: "search".to_string(),
29052                minimum_savings_percent: 40.0,
29053                raw_symbols,
29054                tagpath_families: vec![
29055                    TokenSavingsFamily {
29056                        canonical: "validate_user".to_string(),
29057                        count: 6,
29058                        aliases: BTreeMap::new(),
29059                    },
29060                    TokenSavingsFamily {
29061                        canonical: "raw_symbol".to_string(),
29062                        count: 6,
29063                        aliases: BTreeMap::new(),
29064                    },
29065                ],
29066                context_pack_inputs: None,
29067                session_review_inputs: None,
29068                source_read_inputs: None,
29069                markdown_projection_inputs: None,
29070            }],
29071        };
29072
29073        let report = build_token_savings_report(&fixture).unwrap();
29074
29075        assert!(report.pass);
29076        assert_eq!(report.cases[0].raw_symbol_count, 12);
29077        assert_eq!(report.cases[0].family_count, 2);
29078        assert_eq!(report.cases[0].status, "pass");
29079        assert!(report.cases[0].byte_delta > 0);
29080        assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
29081        assert!(report.cases[0].savings_percent >= 40.0);
29082    }
29083
29084    #[test]
29085    fn token_savings_source_read_inputs_preserve_required_anchors() {
29086        let fixture = TokenSavingsFixture {
29087            schema_version: 1,
29088            description: "fixture".to_string(),
29089            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29090            cases: vec![TokenSavingsFixtureCase {
29091                name: "source-read".to_string(),
29092                surface: "source-read".to_string(),
29093                minimum_savings_percent: 40.0,
29094                raw_symbols: Vec::new(),
29095                tagpath_families: Vec::new(),
29096                context_pack_inputs: None,
29097                session_review_inputs: None,
29098                source_read_inputs: Some(TokenSavingsSourceReadInputs {
29099                    reads: vec![TokenSavingsSourceReadInput {
29100                        command: "sed -n '40,160p' src/main.rs".to_string(),
29101                        file: "src/main.rs".to_string(),
29102                        raw_start: 40,
29103                        raw_lines: 121,
29104                        raw_excerpt: "line 40\n".repeat(121),
29105                        envelope_start: 40,
29106                        envelope_lines: 121,
29107                        required_line_anchors: vec![40, 120, 160],
29108                    }],
29109                }),
29110                markdown_projection_inputs: None,
29111            }],
29112        };
29113
29114        let report = build_token_savings_report(&fixture).unwrap();
29115
29116        assert!(report.pass);
29117        assert_eq!(report.cases[0].surface, "source-read");
29118        assert!(report.cases[0].savings_percent >= 40.0);
29119    }
29120
29121    #[test]
29122    fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
29123        let fixture = TokenSavingsFixture {
29124            schema_version: 1,
29125            description: "fixture".to_string(),
29126            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29127            cases: vec![TokenSavingsFixtureCase {
29128                name: "source-read".to_string(),
29129                surface: "source-read".to_string(),
29130                minimum_savings_percent: 40.0,
29131                raw_symbols: Vec::new(),
29132                tagpath_families: Vec::new(),
29133                context_pack_inputs: None,
29134                session_review_inputs: None,
29135                source_read_inputs: Some(TokenSavingsSourceReadInputs {
29136                    reads: vec![TokenSavingsSourceReadInput {
29137                        command: "cat src/main.rs".to_string(),
29138                        file: "src/main.rs".to_string(),
29139                        raw_start: 1,
29140                        raw_lines: 200,
29141                        raw_excerpt: "line\n".repeat(200),
29142                        envelope_start: 1,
29143                        envelope_lines: 80,
29144                        required_line_anchors: vec![120],
29145                    }],
29146                }),
29147                markdown_projection_inputs: None,
29148            }],
29149        };
29150
29151        let err = match build_token_savings_report(&fixture) {
29152            Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
29153            Err(err) => err,
29154        };
29155
29156        assert!(err.to_string().contains("hides required line anchor 120"));
29157    }
29158
29159    #[test]
29160    fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
29161        let fixture = TokenSavingsFixture {
29162            schema_version: 1,
29163            description: "fixture".to_string(),
29164            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29165            cases: vec![TokenSavingsFixtureCase {
29166                name: "markdown-projection".to_string(),
29167                surface: "context-pack".to_string(),
29168                minimum_savings_percent: 40.0,
29169                raw_symbols: Vec::new(),
29170                tagpath_families: Vec::new(),
29171                context_pack_inputs: None,
29172                session_review_inputs: None,
29173                source_read_inputs: None,
29174                markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
29175                    documents: vec![TokenSavingsMarkdownProjectionInput {
29176                        command: "context-pack markdown body".to_string(),
29177                        file: "tasks/software/tsift.md".to_string(),
29178                        raw_markdown: "# Heading\n\n".repeat(120),
29179                        outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
29180                        selected_nodes: vec!["mdast-selected".to_string()],
29181                        expand:
29182                            "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
29183                                .to_string(),
29184                    }],
29185                }),
29186            }],
29187        };
29188
29189        let report = build_token_savings_report(&fixture).unwrap();
29190
29191        assert!(report.pass);
29192        assert_eq!(report.cases[0].surface, "context-pack");
29193        assert!(report.cases[0].savings_percent >= 40.0);
29194    }
29195
29196    #[test]
29197    fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
29198        let mut content = String::from("# Cache Root\n\n");
29199        for idx in 0..96 {
29200            content.push_str(&format!(
29201                "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
29202            ));
29203        }
29204
29205        let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
29206        assert!(!first.cache_hit);
29207        assert!(first.nodes.len() > 200);
29208
29209        let sections = markdown_section_spans(&content).unwrap();
29210        let list_items = markdown_block_spans(&content, "list_item").unwrap();
29211        let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
29212        let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
29213
29214        assert!(second.cache_hit);
29215        assert_eq!(second.nodes.len(), first.nodes.len());
29216        assert_eq!(sections.len(), 97);
29217        assert_eq!(list_items.len(), 96);
29218        assert_eq!(code_blocks.len(), 96);
29219        let first_code = first
29220            .nodes
29221            .iter()
29222            .find(|node| node.kind == "code_block")
29223            .expect("expected a Markdown code block");
29224        let first_code_node = markdown_ast_node(
29225            Path::new("/repo"),
29226            "semantic-edit",
29227            first_code,
29228            content.as_bytes(),
29229            &first.nodes,
29230            8,
29231        );
29232        assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
29233        assert_eq!(
29234            first_code_node.metadata.embedded_symbols[0].name,
29235            "sample_0"
29236        );
29237        assert_eq!(
29238            first_code_node.metadata.embedded_symbols[0].language,
29239            "rust"
29240        );
29241    }
29242
29243    #[test]
29244    fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
29245        let response = empty_search_response(Path::new("/repo"), "lexical");
29246        let symbol_hits = vec![index::SymbolHit {
29247            name: "alpha_helper_with_a_long_name".to_string(),
29248            kind: "function".to_string(),
29249            language: "rust".to_string(),
29250            file: "/repo/src/lib.rs".to_string(),
29251            line: 12,
29252            end_line: None,
29253            node_kind: None,
29254            start_byte: None,
29255            end_byte: None,
29256            body_start_byte: None,
29257            body_end_byte: None,
29258            tags: None,
29259            score: 0.98,
29260            match_type: "exact_name".to_string(),
29261            tagpath_handle: None,
29262        }];
29263
29264        let report = build_relative_search_budget_report(
29265            "alpha_helper_with_a_long_name",
29266            "lexical",
29267            Path::new("/repo"),
29268            &response,
29269            &symbol_hits,
29270            ResponseBudget::new(Some(1), Some(12)),
29271            &SearchFacetFilters::default(),
29272        );
29273
29274        assert_eq!(report.symbols.len(), 1);
29275        assert!(report.symbols[0].handle.starts_with("sfam-"));
29276        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
29277        assert_eq!(report.symbols[0].name, "alpha_hel...");
29278        assert_eq!(report.symbols[0].file, "src/lib.rs");
29279        assert!(report.symbols[0].expand.contains("tsift search"));
29280    }
29281
29282    #[test]
29283    fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
29284        let dir = tempfile::tempdir().unwrap();
29285        let src_dir = dir.path().join("src");
29286        fs::create_dir_all(&src_dir).unwrap();
29287        let source = "fn alpha_helper() {\n    beta();\n}\n";
29288        let file = src_dir.join("lib.rs");
29289        fs::write(&file, source).unwrap();
29290        let body_start = source.find("{\n").unwrap() + 1;
29291        let body_end = source.rfind("\n}").unwrap() + 1;
29292
29293        let response = empty_search_response(dir.path(), "lexical");
29294        let symbol_hits = vec![index::SymbolHit {
29295            name: "alpha_helper".to_string(),
29296            kind: "function".to_string(),
29297            language: "rust".to_string(),
29298            file: file.to_string_lossy().to_string(),
29299            line: 0,
29300            end_line: Some(2),
29301            node_kind: Some("function_item".to_string()),
29302            start_byte: Some(0),
29303            end_byte: Some(i64::try_from(source.len()).unwrap()),
29304            body_start_byte: Some(i64::try_from(body_start).unwrap()),
29305            body_end_byte: Some(i64::try_from(body_end).unwrap()),
29306            tags: Some("alpha,helper".to_string()),
29307            score: 0.98,
29308            match_type: "exact_name".to_string(),
29309            tagpath_handle: None,
29310        }];
29311
29312        let report = build_relative_search_budget_report(
29313            "alpha helper",
29314            "lexical",
29315            dir.path(),
29316            &response,
29317            &symbol_hits,
29318            ResponseBudget::new(Some(5), Some(96)),
29319            &SearchFacetFilters::default(),
29320        );
29321
29322        let symbol = &report.symbols[0];
29323        assert_eq!(symbol.language, "rust");
29324        assert_eq!(symbol.end_line, Some(2));
29325        let ast = symbol
29326            .ast
29327            .as_ref()
29328            .expect("search symbol preview should expose an AST span artifact");
29329        assert_eq!(ast.artifact_kind, "ast_span");
29330        assert!(ast.span.handle.starts_with("span-"));
29331        assert_eq!(ast.span.node_kind, "function_item");
29332        assert_eq!(ast.span.start_byte, 0);
29333        assert_eq!(ast.span.end_byte, source.len());
29334        assert_eq!(ast.span.body_start_byte, Some(body_start));
29335        assert_eq!(ast.span.body_end_byte, Some(body_end));
29336        assert!(ast.expand.source_window.contains("source-read"));
29337        assert!(
29338            ast.expand
29339                .source_body
29340                .as_ref()
29341                .unwrap()
29342                .contains("source-read")
29343        );
29344        assert!(ast.expand.symbol_read.contains("symbol-read"));
29345        assert!(ast.expand.markdown_ast.is_none());
29346    }
29347
29348    #[test]
29349    fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
29350        let dir = tempfile::tempdir().unwrap();
29351        let source = "# Guide\n\n## Install\n\n- Run setup.\n";
29352        let file = dir.path().join("README.md");
29353        fs::write(&file, source).unwrap();
29354        let heading_start = source.find("## Install").unwrap();
29355        let heading_end = source.len();
29356
29357        let response = empty_search_response(dir.path(), "lexical");
29358        let symbol_hits = vec![index::SymbolHit {
29359            name: "Install".to_string(),
29360            kind: "heading".to_string(),
29361            language: "markdown".to_string(),
29362            file: file.to_string_lossy().to_string(),
29363            line: 2,
29364            end_line: Some(4),
29365            node_kind: Some("atx_heading".to_string()),
29366            start_byte: Some(i64::try_from(heading_start).unwrap()),
29367            end_byte: Some(i64::try_from(heading_end).unwrap()),
29368            body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
29369            body_end_byte: Some(i64::try_from(heading_end).unwrap()),
29370            tags: Some("install".to_string()),
29371            score: 1.0,
29372            match_type: "exact_name".to_string(),
29373            tagpath_handle: None,
29374        }];
29375
29376        let report = build_relative_search_budget_report(
29377            "Install",
29378            "lexical",
29379            dir.path(),
29380            &response,
29381            &symbol_hits,
29382            ResponseBudget::new(Some(5), Some(96)),
29383            &SearchFacetFilters::default(),
29384        );
29385
29386        let ast = report.symbols[0]
29387            .ast
29388            .as_ref()
29389            .expect("Markdown search symbol should expose an AST span artifact");
29390        assert_eq!(ast.span.node_kind, "atx_heading");
29391        assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
29392        let markdown_ast = ast
29393            .expand
29394            .markdown_ast
29395            .as_ref()
29396            .expect("Markdown symbols should include markdown-ast expansion");
29397        assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
29398        assert!(markdown_ast.contains("--node"), "{markdown_ast}");
29399        assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
29400        assert!(ast.expand.source_window.contains("source-read"));
29401        assert!(ast.expand.symbol_read.contains("symbol-read"));
29402    }
29403
29404    #[test]
29405    fn search_budget_report_exposes_markdown_embedded_code_symbols() {
29406        let dir = tempfile::tempdir().unwrap();
29407        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29408        let file = dir.path().join("README.md");
29409        fs::write(&file, source).unwrap();
29410        let fence_start = source.find("```rust").unwrap();
29411        let body_start = source.find("fn sample").unwrap();
29412        let body_end = body_start + "fn sample() {}\n".len();
29413
29414        let response = empty_search_response(dir.path(), "lexical");
29415        let symbol_hits = vec![index::SymbolHit {
29416            name: "rust".to_string(),
29417            kind: "code_block".to_string(),
29418            language: "markdown".to_string(),
29419            file: file.to_string_lossy().to_string(),
29420            line: 2,
29421            end_line: Some(4),
29422            node_kind: Some("fenced_code_block".to_string()),
29423            start_byte: Some(i64::try_from(fence_start).unwrap()),
29424            end_byte: Some(i64::try_from(source.len()).unwrap()),
29425            body_start_byte: Some(i64::try_from(body_start).unwrap()),
29426            body_end_byte: Some(i64::try_from(body_end).unwrap()),
29427            tags: Some("rust".to_string()),
29428            score: 1.0,
29429            match_type: "exact_name".to_string(),
29430            tagpath_handle: None,
29431        }];
29432
29433        let report = build_relative_search_budget_report(
29434            "rust",
29435            "lexical",
29436            dir.path(),
29437            &response,
29438            &symbol_hits,
29439            ResponseBudget::new(Some(5), Some(96)),
29440            &SearchFacetFilters::default(),
29441        );
29442
29443        let embedded = &report.symbols[0]
29444            .ast
29445            .as_ref()
29446            .unwrap()
29447            .span
29448            .markdown
29449            .as_ref()
29450            .unwrap()
29451            .embedded_symbols;
29452        assert_eq!(embedded.len(), 1);
29453        assert_eq!(embedded[0].name, "sample");
29454        assert_eq!(embedded[0].kind, "function");
29455        assert_eq!(embedded[0].language, "rust");
29456        assert_eq!(embedded[0].node_kind, "function_item");
29457        assert!(embedded[0].handle.starts_with("span-"));
29458        assert_eq!(embedded[0].start_byte, body_start);
29459        assert_eq!(embedded[0].start_line, 4);
29460    }
29461
29462    fn test_lexical_search_hit(
29463        path: &Path,
29464        rank: usize,
29465        score: f64,
29466        snippet: &str,
29467    ) -> sift::SearchHit {
29468        sift::SearchHit {
29469            artifact_id: format!("hit-{rank}"),
29470            artifact_kind: sift::ContextArtifactKind::File,
29471            budget: sift::ArtifactBudget::from_text(snippet, 1),
29472            confidence: sift::ScoreConfidence::High,
29473            freshness: sift::ArtifactFreshness {
29474                modified_unix_secs: None,
29475                observed_unix_secs: 0,
29476            },
29477            location: Some("line 1".to_string()),
29478            path: path.to_string_lossy().to_string(),
29479            provenance: sift::ArtifactProvenance {
29480                adapter: sift::AcquisitionAdapterKind::FileSystem,
29481                source: "test lexical hit".to_string(),
29482                synthetic: false,
29483            },
29484            rank,
29485            score,
29486            snippet: snippet.to_string(),
29487        }
29488    }
29489
29490    fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
29491        summarize::Summary {
29492            id: 0,
29493            symbol_name: symbol_name.to_string(),
29494            file_path: file_path.to_string(),
29495            content_hash: "hash".to_string(),
29496            summary: summary.to_string(),
29497            entities: None,
29498            relationships: None,
29499            concept_labels: None,
29500            extracted_at: "2026-06-02T00:00:00Z".to_string(),
29501            model: "test".to_string(),
29502            tokens_input: None,
29503            tokens_output: None,
29504        }
29505    }
29506
29507    #[test]
29508    fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
29509        let dir = tempfile::tempdir().unwrap();
29510        let src_dir = dir.path().join("src");
29511        fs::create_dir_all(&src_dir).unwrap();
29512        let source = "fn alpha_helper() {}\n";
29513        let file = src_dir.join("lib.rs");
29514        let broad_file = dir.path().join("README.md");
29515        fs::write(&file, source).unwrap();
29516        fs::write(
29517            &broad_file,
29518            "alpha helper alpha helper alpha helper in prose\n",
29519        )
29520        .unwrap();
29521
29522        let mut response = empty_search_response(dir.path(), "lexical");
29523        response.hits.push(test_lexical_search_hit(
29524            &broad_file,
29525            1,
29526            240.0,
29527            "alpha helper alpha helper alpha helper in prose",
29528        ));
29529        let symbol_hits = vec![index::SymbolHit {
29530            name: "alpha_helper".to_string(),
29531            kind: "function".to_string(),
29532            language: "rust".to_string(),
29533            file: file.to_string_lossy().to_string(),
29534            line: 0,
29535            end_line: Some(0),
29536            node_kind: Some("function_item".to_string()),
29537            start_byte: Some(0),
29538            end_byte: Some(i64::try_from(source.len()).unwrap()),
29539            body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29540            body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29541            tags: Some("alpha,helper".to_string()),
29542            score: 0.8,
29543            match_type: "all_tags".to_string(),
29544            tagpath_handle: None,
29545        }];
29546
29547        let report = build_relative_search_budget_report(
29548            "alpha helper",
29549            "lexical",
29550            dir.path(),
29551            &response,
29552            &symbol_hits,
29553            ResponseBudget::new(Some(5), Some(128)),
29554            &SearchFacetFilters::default(),
29555        );
29556
29557        assert_eq!(report.ranked[0].source, "symbol_span");
29558        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
29559        assert!(report.ranked[0].score > report.ranked[1].score);
29560        assert_eq!(report.ranked[1].source, "lexical_file");
29561    }
29562
29563    #[test]
29564    fn search_budget_exact_hit_expands_to_source_handle_and_containing_symbol() {
29565        let dir = tempfile::tempdir().unwrap();
29566        let src_dir = dir.path().join("src");
29567        fs::create_dir_all(&src_dir).unwrap();
29568        let source = "fn alpha_helper() {\n    let needle = \"needle\";\n}\n\nfn other() {}\n";
29569        let file = src_dir.join("lib.rs");
29570        fs::write(&file, source).unwrap();
29571
29572        let mut response = empty_search_response(dir.path(), "exact");
29573        let mut hit = test_lexical_search_hit(&file, 1, 10.0, "let needle = \"needle\";");
29574        hit.location = Some("line 2".to_string());
29575        response.hits.push(hit);
29576
29577        let symbol_hits = vec![index::SymbolHit {
29578            name: "alpha_helper".to_string(),
29579            kind: "function".to_string(),
29580            language: "rust".to_string(),
29581            file: file.to_string_lossy().to_string(),
29582            line: 0,
29583            end_line: Some(2),
29584            node_kind: Some("function_item".to_string()),
29585            start_byte: Some(0),
29586            end_byte: Some(i64::try_from(source.find("\n\n").unwrap()).unwrap()),
29587            body_start_byte: Some(i64::try_from(source.find('{').unwrap() + 1).unwrap()),
29588            body_end_byte: Some(i64::try_from(source.find("\n}").unwrap()).unwrap()),
29589            tags: Some("alpha,helper".to_string()),
29590            score: 0.9,
29591            match_type: "all_tags".to_string(),
29592            tagpath_handle: None,
29593        }];
29594
29595        let report = build_relative_search_budget_report(
29596            "needle",
29597            "exact",
29598            dir.path(),
29599            &response,
29600            &symbol_hits,
29601            ResponseBudget::new(Some(5), Some(128)),
29602            &SearchFacetFilters::default(),
29603        );
29604
29605        let hit = &report.hits[0];
29606        assert_eq!(hit.line, Some(2));
29607        let source_handle = hit
29608            .source_handle
29609            .as_ref()
29610            .expect("exact hit should expose a bounded source_handle window");
29611        assert!(source_handle.handle.starts_with("xwin-"));
29612        assert_eq!(source_handle.kind, "source_handle");
29613        assert_eq!(source_handle.file, "src/lib.rs");
29614        assert_eq!(source_handle.start_line, 1);
29615        assert_eq!(source_handle.end_line, 3);
29616        assert!(source_handle.expand.contains("source-read"));
29617
29618        let containing_symbol = hit
29619            .containing_symbol
29620            .as_ref()
29621            .expect("exact hit should expose its containing symbol when indexed");
29622        assert_eq!(containing_symbol.name, "alpha_helper");
29623        assert_eq!(containing_symbol.kind, "function");
29624        assert_eq!(containing_symbol.line, 1);
29625        assert_eq!(containing_symbol.end_line, Some(3));
29626        assert!(containing_symbol.expand.contains("symbol-read"));
29627
29628        let lexical_rank = report
29629            .ranked
29630            .iter()
29631            .find(|item| item.source == "lexical_file")
29632            .expect("ranked preview should retain the lexical retrieval handle");
29633        assert!(
29634            lexical_rank
29635                .reasons
29636                .iter()
29637                .any(|reason| reason == "source_handle")
29638        );
29639        assert!(
29640            lexical_rank
29641                .reasons
29642                .iter()
29643                .any(|reason| reason == "containing_symbol")
29644        );
29645    }
29646
29647    #[test]
29648    fn search_budget_ranked_preview_prioritizes_source_definitions_before_tests() {
29649        let dir = tempfile::tempdir().unwrap();
29650        let src_dir = dir.path().join("src");
29651        let tests_dir = dir.path().join("tests");
29652        fs::create_dir_all(&src_dir).unwrap();
29653        fs::create_dir_all(&tests_dir).unwrap();
29654        let source_file = src_dir.join("lib.rs");
29655        let test_file = tests_dir.join("alpha_test.rs");
29656        fs::write(&source_file, "fn alpha_helper() {}\n").unwrap();
29657        fs::write(&test_file, "#[test]\nfn alpha_helper_test() {}\n").unwrap();
29658
29659        let response = empty_search_response(dir.path(), "lexical");
29660        let symbol_hits = vec![
29661            index::SymbolHit {
29662                name: "alpha_helper_test".to_string(),
29663                kind: "function".to_string(),
29664                language: "rust".to_string(),
29665                file: test_file.to_string_lossy().to_string(),
29666                line: 1,
29667                end_line: Some(1),
29668                node_kind: Some("function_item".to_string()),
29669                start_byte: Some(8),
29670                end_byte: Some(33),
29671                body_start_byte: Some(31),
29672                body_end_byte: Some(31),
29673                tags: Some("alpha,helper,test".to_string()),
29674                score: 1.0,
29675                match_type: "exact_name".to_string(),
29676                tagpath_handle: None,
29677            },
29678            index::SymbolHit {
29679                name: "alpha_helper".to_string(),
29680                kind: "function".to_string(),
29681                language: "rust".to_string(),
29682                file: source_file.to_string_lossy().to_string(),
29683                line: 0,
29684                end_line: Some(0),
29685                node_kind: Some("function_item".to_string()),
29686                start_byte: Some(0),
29687                end_byte: Some(20),
29688                body_start_byte: Some(18),
29689                body_end_byte: Some(18),
29690                tags: Some("alpha,helper".to_string()),
29691                score: 0.78,
29692                match_type: "all_tags".to_string(),
29693                tagpath_handle: None,
29694            },
29695        ];
29696
29697        let report = build_relative_search_budget_report(
29698            "alpha helper",
29699            "lexical",
29700            dir.path(),
29701            &response,
29702            &symbol_hits,
29703            ResponseBudget::new(Some(5), Some(128)),
29704            &SearchFacetFilters::default(),
29705        );
29706
29707        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
29708        assert_eq!(report.ranked[0].path, "src/lib.rs");
29709        assert!(
29710            report.ranked[0]
29711                .reasons
29712                .iter()
29713                .any(|reason| reason == "definition_kind")
29714        );
29715        assert!(
29716            report.ranked[0]
29717                .reasons
29718                .iter()
29719                .any(|reason| reason == "source_path")
29720        );
29721        let test_rank = report
29722            .ranked
29723            .iter()
29724            .find(|item| item.name.as_deref() == Some("alpha_helper_test"))
29725            .expect("test symbol should still be present in the ranked preview");
29726        assert!(test_rank.reasons.iter().any(|reason| reason == "test_path"));
29727    }
29728
29729    #[test]
29730    fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
29731        let dir = tempfile::tempdir().unwrap();
29732        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29733        let file = dir.path().join("README.md");
29734        fs::write(&file, source).unwrap();
29735        let summary_db =
29736            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
29737        summary_db
29738            .insert(&test_summary(
29739                "rust",
29740                "README.md",
29741                "Rust fence contains a sample function.",
29742            ))
29743            .unwrap();
29744
29745        let fence_start = source.find("```rust").unwrap();
29746        let body_start = source.find("fn sample").unwrap();
29747        let body_end = body_start + "fn sample() {}\n".len();
29748        let response = empty_search_response(dir.path(), "lexical");
29749        let symbol_hits = vec![index::SymbolHit {
29750            name: "rust".to_string(),
29751            kind: "code_block".to_string(),
29752            language: "markdown".to_string(),
29753            file: file.to_string_lossy().to_string(),
29754            line: 2,
29755            end_line: Some(4),
29756            node_kind: Some("fenced_code_block".to_string()),
29757            start_byte: Some(i64::try_from(fence_start).unwrap()),
29758            end_byte: Some(i64::try_from(source.len()).unwrap()),
29759            body_start_byte: Some(i64::try_from(body_start).unwrap()),
29760            body_end_byte: Some(i64::try_from(body_end).unwrap()),
29761            tags: Some("rust".to_string()),
29762            score: 1.0,
29763            match_type: "exact_name".to_string(),
29764            tagpath_handle: None,
29765        }];
29766
29767        let report = build_relative_search_budget_report(
29768            "rust",
29769            "lexical",
29770            dir.path(),
29771            &response,
29772            &symbol_hits,
29773            ResponseBudget::new(Some(5), Some(128)),
29774            &SearchFacetFilters::default(),
29775        );
29776
29777        let symbol = &report.symbols[0];
29778        assert_eq!(symbol.summary_refs, 1);
29779        assert_eq!(symbol.graph_neighbors, 1);
29780        assert!(
29781            report.ranked[0]
29782                .reasons
29783                .iter()
29784                .any(|reason| reason == "summary_refs:1")
29785        );
29786        assert!(
29787            report.ranked[0]
29788                .reasons
29789                .iter()
29790                .any(|reason| reason == "graph_neighbors:1")
29791        );
29792    }
29793
29794    fn markdown_search_facet_fixture() -> tempfile::TempDir {
29795        let dir = tempfile::tempdir().unwrap();
29796        let source = r#"# Guide
29797
29798## Install
29799
29800- Run setup.
29801  - Confirm setup.
29802
29803```rust
29804fn sample() {}
29805```
29806"#;
29807        fs::write(dir.path().join("README.md"), source).unwrap();
29808        let index_dir = dir.path().join(".tsift");
29809        fs::create_dir_all(&index_dir).unwrap();
29810        run_index_update(
29811            &index_dir.join("index.db"),
29812            dir.path(),
29813            "indexing markdown search facet fixture".to_string(),
29814            dir.path(),
29815            None,
29816            false,
29817            false,
29818        )
29819        .unwrap();
29820        dir
29821    }
29822
29823    fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
29824        let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
29825        db.symbol_search(query, 20).unwrap()
29826    }
29827
29828    #[test]
29829    fn search_facet_filters_match_scalar_symbol_fields() {
29830        let dir = tempfile::tempdir().unwrap();
29831        let hits = vec![
29832            index::SymbolHit {
29833                name: "alpha_helper".to_string(),
29834                kind: "function".to_string(),
29835                language: "rust".to_string(),
29836                file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
29837                line: 0,
29838                end_line: None,
29839                node_kind: Some("function_item".to_string()),
29840                start_byte: None,
29841                end_byte: None,
29842                body_start_byte: None,
29843                body_end_byte: None,
29844                tags: None,
29845                score: 1.0,
29846                match_type: "exact_name".to_string(),
29847                tagpath_handle: None,
29848            },
29849            index::SymbolHit {
29850                name: "Install".to_string(),
29851                kind: "heading".to_string(),
29852                language: "markdown".to_string(),
29853                file: dir.path().join("README.md").to_string_lossy().to_string(),
29854                line: 0,
29855                end_line: None,
29856                node_kind: Some("atx_heading".to_string()),
29857                start_byte: None,
29858                end_byte: None,
29859                body_start_byte: None,
29860                body_end_byte: None,
29861                tags: None,
29862                score: 0.9,
29863                match_type: "exact_name".to_string(),
29864                tagpath_handle: None,
29865            },
29866        ];
29867
29868        let filtered = apply_search_facet_filters(
29869            dir.path(),
29870            hits,
29871            &SearchFacetFilters {
29872                languages: vec!["rust".to_string()],
29873                kinds: vec!["function".to_string()],
29874                node_kinds: vec!["function_item".to_string()],
29875                ..SearchFacetFilters::default()
29876            },
29877        );
29878
29879        assert_eq!(filtered.len(), 1);
29880        assert_eq!(filtered[0].name, "alpha_helper");
29881    }
29882
29883    #[test]
29884    fn search_facet_filters_match_markdown_sections_and_block_metadata() {
29885        let dir = markdown_search_facet_fixture();
29886
29887        let nested_list = apply_search_facet_filters(
29888            dir.path(),
29889            markdown_search_facet_hits(dir.path(), "setup"),
29890            &SearchFacetFilters {
29891                sections: vec!["Install".to_string()],
29892                parents: vec!["Run setup.".to_string()],
29893                list_depths: vec![1],
29894                ..SearchFacetFilters::default()
29895            },
29896        );
29897        assert_eq!(nested_list.len(), 1);
29898        assert_eq!(nested_list[0].name, "Confirm setup.");
29899
29900        let parent_list = apply_search_facet_filters(
29901            dir.path(),
29902            markdown_search_facet_hits(dir.path(), "setup"),
29903            &SearchFacetFilters {
29904                children: vec!["Confirm setup.".to_string()],
29905                ..SearchFacetFilters::default()
29906            },
29907        );
29908        assert_eq!(parent_list.len(), 1);
29909        assert_eq!(parent_list[0].name, "Run setup.");
29910
29911        let heading = apply_search_facet_filters(
29912            dir.path(),
29913            markdown_search_facet_hits(dir.path(), "Install"),
29914            &SearchFacetFilters {
29915                heading_levels: vec![2],
29916                node_kinds: vec!["atx_heading".to_string()],
29917                ..SearchFacetFilters::default()
29918            },
29919        );
29920        assert_eq!(heading.len(), 1);
29921        assert_eq!(heading[0].name, "Install");
29922
29923        let fence = apply_search_facet_filters(
29924            dir.path(),
29925            markdown_search_facet_hits(dir.path(), "rust"),
29926            &SearchFacetFilters {
29927                fence_languages: vec!["rust".to_string()],
29928                kinds: vec!["code_block".to_string()],
29929                ..SearchFacetFilters::default()
29930            },
29931        );
29932        assert_eq!(fence.len(), 1);
29933        assert_eq!(fence[0].kind, "code_block");
29934
29935        let embedded_child = apply_search_facet_filters(
29936            dir.path(),
29937            markdown_search_facet_hits(dir.path(), "rust"),
29938            &SearchFacetFilters {
29939                children: vec!["sample".to_string()],
29940                kinds: vec!["code_block".to_string()],
29941                ..SearchFacetFilters::default()
29942            },
29943        );
29944        assert_eq!(embedded_child.len(), 1);
29945        assert_eq!(embedded_child[0].name, "rust");
29946    }
29947
29948    #[test]
29949    fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
29950        let response = empty_search_response(Path::new("/repo"), "lexical");
29951        let symbol_hits = vec![
29952            index::SymbolHit {
29953                name: "alpha_helper".to_string(),
29954                kind: "function".to_string(),
29955                language: "rust".to_string(),
29956                file: "/repo/src/lib.rs".to_string(),
29957                line: 12,
29958                end_line: None,
29959                node_kind: None,
29960                start_byte: None,
29961                end_byte: None,
29962                body_start_byte: None,
29963                body_end_byte: None,
29964                tags: Some("alpha,helper".to_string()),
29965                score: 0.98,
29966                match_type: "exact_name".to_string(),
29967                tagpath_handle: None,
29968            },
29969            index::SymbolHit {
29970                name: "alphaHelper".to_string(),
29971                kind: "method".to_string(),
29972                language: "rust".to_string(),
29973                file: "/repo/src/main.rs".to_string(),
29974                line: 34,
29975                end_line: None,
29976                node_kind: None,
29977                start_byte: None,
29978                end_byte: None,
29979                body_start_byte: None,
29980                body_end_byte: None,
29981                tags: Some("alpha,helper".to_string()),
29982                score: 0.93,
29983                match_type: "tag_overlap".to_string(),
29984                tagpath_handle: None,
29985            },
29986            index::SymbolHit {
29987                name: "alpha_helper".to_string(),
29988                kind: "function".to_string(),
29989                language: "rust".to_string(),
29990                file: "/repo/src/worker.rs".to_string(),
29991                line: 56,
29992                end_line: None,
29993                node_kind: None,
29994                start_byte: None,
29995                end_byte: None,
29996                body_start_byte: None,
29997                body_end_byte: None,
29998                tags: Some("alpha,helper".to_string()),
29999                score: 0.91,
30000                match_type: "tag_overlap".to_string(),
30001                tagpath_handle: None,
30002            },
30003        ];
30004
30005        let report = build_relative_search_budget_report(
30006            "alpha helper",
30007            "lexical",
30008            Path::new("/repo"),
30009            &response,
30010            &symbol_hits,
30011            ResponseBudget::new(Some(5), Some(48)),
30012            &SearchFacetFilters::default(),
30013        );
30014
30015        assert_eq!(report.symbol_total, 1);
30016        assert_eq!(report.raw_symbol_total, 3);
30017        assert_eq!(report.symbols.len(), 1);
30018        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
30019        assert_eq!(report.symbols[0].match_count, 3);
30020        assert_eq!(report.symbols[0].surface_count, 2);
30021        assert_eq!(report.symbols[0].file_count, 3);
30022        assert_eq!(
30023            report.symbols[0].surface_examples,
30024            vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
30025        );
30026        assert!(report.symbols[0].name.contains("(+1 variant)"));
30027        assert!(report.symbols[0].file.contains("(+2 files)"));
30028        assert!(report.symbols[0].expand.contains("tsift search"));
30029        assert!(report.symbols[0].expand.contains("alpha helper"));
30030    }
30031
30032    #[test]
30033    fn search_budget_report_carries_active_filters() {
30034        let response = empty_search_response(Path::new("/repo"), "lexical");
30035        let symbol_hits = vec![index::SymbolHit {
30036            name: "alpha_helper".to_string(),
30037            kind: "function".to_string(),
30038            language: "rust".to_string(),
30039            file: "/repo/src/lib.rs".to_string(),
30040            line: 12,
30041            end_line: None,
30042            node_kind: Some("function_item".to_string()),
30043            start_byte: None,
30044            end_byte: None,
30045            body_start_byte: None,
30046            body_end_byte: None,
30047            tags: Some("alpha,helper".to_string()),
30048            score: 0.98,
30049            match_type: "exact_name".to_string(),
30050            tagpath_handle: None,
30051        }];
30052        let filters = SearchFacetFilters {
30053            languages: vec!["rust".to_string()],
30054            kinds: vec!["function".to_string()],
30055            node_kinds: vec!["function_item".to_string()],
30056            ..SearchFacetFilters::default()
30057        };
30058
30059        let report = build_relative_search_budget_report(
30060            "alpha helper",
30061            "lexical",
30062            Path::new("/repo"),
30063            &response,
30064            &symbol_hits,
30065            ResponseBudget::new(Some(5), Some(48)),
30066            &filters,
30067        );
30068
30069        assert_eq!(report.filters, filters);
30070        assert_eq!(
30071            search_facet_filters_summary(&report.filters),
30072            "lang=rust kind=function node-kind=function_item"
30073        );
30074    }
30075
30076    #[test]
30077    fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
30078        let mut response = empty_search_response(Path::new("/repo"), "lexical");
30079        response.indexed_artifacts = 450;
30080        let symbol_hits = vec![
30081            index::SymbolHit {
30082                name: "alpha_helper".to_string(),
30083                kind: "function".to_string(),
30084                language: "rust".to_string(),
30085                file: "/repo/src/lib.rs".to_string(),
30086                line: 12,
30087                end_line: None,
30088                node_kind: None,
30089                start_byte: None,
30090                end_byte: None,
30091                body_start_byte: None,
30092                body_end_byte: None,
30093                tags: Some("alpha,helper".to_string()),
30094                score: 0.98,
30095                match_type: "exact_name".to_string(),
30096                tagpath_handle: None,
30097            },
30098            index::SymbolHit {
30099                name: "beta_helper".to_string(),
30100                kind: "function".to_string(),
30101                language: "rust".to_string(),
30102                file: "/repo/src/beta.rs".to_string(),
30103                line: 21,
30104                end_line: None,
30105                node_kind: None,
30106                start_byte: None,
30107                end_byte: None,
30108                body_start_byte: None,
30109                body_end_byte: None,
30110                tags: Some("beta,helper".to_string()),
30111                score: 0.92,
30112                match_type: "tag_overlap".to_string(),
30113                tagpath_handle: None,
30114            },
30115        ];
30116
30117        let report = build_relative_search_budget_report(
30118            "helper",
30119            "lexical",
30120            Path::new("/repo"),
30121            &response,
30122            &symbol_hits,
30123            ResponseBudget::new(Some(1), Some(64)),
30124            &SearchFacetFilters::default(),
30125        );
30126
30127        let guard = report
30128            .scale_guard
30129            .as_ref()
30130            .expect("broad previews should emit a scale guard");
30131        assert_eq!(guard.level, "high-hit");
30132        assert_eq!(guard.signals.indexed_artifacts, 450);
30133        assert_eq!(guard.signals.raw_symbol_matches, 2);
30134        assert!(
30135            guard
30136                .narrow_commands
30137                .iter()
30138                .any(|command| command.contains("--exact"))
30139        );
30140        assert!(
30141            guard
30142                .narrow_commands
30143                .iter()
30144                .any(|command| command.contains("alpha helper"))
30145        );
30146        assert!(
30147            guard
30148                .narrow_commands
30149                .last()
30150                .unwrap()
30151                .contains("workflow search")
30152        );
30153    }
30154
30155    #[test]
30156    fn explain_budget_report_limits_edges_and_members() {
30157        let symbols = vec![index::StoredSymbol {
30158            name: "alpha_helper".to_string(),
30159            kind: "function".to_string(),
30160            language: "rust".to_string(),
30161            signature: None,
30162            file: "src/lib.rs".to_string(),
30163            line: 10,
30164            end_line: None,
30165            node_kind: None,
30166            start_byte: None,
30167            end_byte: None,
30168            body_start_byte: None,
30169            body_end_byte: None,
30170            parent_module: None,
30171            visibility: None,
30172            tags: None,
30173            tagpath_handle: None,
30174        }];
30175        let callers = vec![
30176            index::StoredEdge {
30177                caller_file: "src/main.rs".to_string(),
30178                caller_name: "main".to_string(),
30179                caller_line: 1,
30180                callee_name: "alpha_helper".to_string(),
30181                call_site_line: 3,
30182                tagpath_handle: None,
30183            },
30184            index::StoredEdge {
30185                caller_file: "src/worker.rs".to_string(),
30186                caller_name: "worker".to_string(),
30187                caller_line: 5,
30188                callee_name: "alpha_helper".to_string(),
30189                call_site_line: 8,
30190                tagpath_handle: None,
30191            },
30192        ];
30193        let community = graph::Community {
30194            id: 1,
30195            members: vec![
30196                graph::CommunityMember::new("alpha_helper"),
30197                graph::CommunityMember::new("main"),
30198                graph::CommunityMember::new("worker"),
30199            ],
30200            modularity_contribution: 0.5,
30201        };
30202
30203        let report = build_explain_budget_report(
30204            "alpha_helper",
30205            Path::new("/repo"),
30206            &symbols,
30207            &callers,
30208            2,
30209            false,
30210            &[],
30211            0,
30212            false,
30213            Some(&community),
30214            ResponseBudget::new(Some(1), Some(24)),
30215        );
30216
30217        assert_eq!(report.definitions.len(), 1);
30218        assert_eq!(report.callers.len(), 1);
30219        assert!(report.truncated);
30220        assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
30221        assert_eq!(
30222            report.definitions[0].tag_alias.as_deref(),
30223            Some("alpha/helper")
30224        );
30225        assert!(report.callers[0].handle.starts_with("ecall-"));
30226        assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
30227    }
30228
30229    #[test]
30230    fn session_review_next_context_budget_limits_lists() {
30231        let report = session_review::SessionReviewReport {
30232            root: "/repo".to_string(),
30233            target: "tasks/software/tsift.md".to_string(),
30234            target_kind: "file".to_string(),
30235            sessions_considered: 1,
30236            sessions_matched: 1,
30237            claude_sessions: 1,
30238            codex_sessions: 0,
30239            agent_doc_logs: 0,
30240            prompt_target_count: 2,
30241            command_groups: 0,
30242            file_groups: 2,
30243            symbol_groups: 1,
30244            failure_groups: 1,
30245            runtime_event_groups: 0,
30246            restart_churn_groups: 0,
30247            closeout_groups: 0,
30248            usage_samples: 1,
30249            prompt_tokens: 120,
30250            cached_input_tokens: 80,
30251            cache_creation_input_tokens: 0,
30252            output_tokens: 40,
30253            reasoning_output_tokens: 0,
30254            total_tokens: 240,
30255            cached_input_ratio: Some(40.0),
30256            largest_turn_total_tokens: 240,
30257            aggregate_cost: session_review::SessionReviewCostSummary {
30258                scope: "bounded_matched_sessions".to_string(),
30259                sessions: 1,
30260                usage_samples: 1,
30261                prompt_tokens: 120,
30262                cached_input_tokens: 80,
30263                cache_creation_input_tokens: 0,
30264                output_tokens: 40,
30265                reasoning_output_tokens: 0,
30266                total_tokens: 240,
30267                cached_input_ratio: Some(40.0),
30268                largest_turn_total_tokens: 240,
30269            },
30270            latest_session_cost: Some(session_review::SessionReviewCostSummary {
30271                scope: "latest_matched_session".to_string(),
30272                sessions: 1,
30273                usage_samples: 1,
30274                prompt_tokens: 120,
30275                cached_input_tokens: 80,
30276                cache_creation_input_tokens: 0,
30277                output_tokens: 40,
30278                reasoning_output_tokens: 0,
30279                total_tokens: 240,
30280                cached_input_ratio: Some(66.67),
30281                largest_turn_total_tokens: 240,
30282            }),
30283            prompt_cache_roi_scorecard: vec![],
30284            guardrails: vec![
30285                session_cost::SessionCostGuardrail {
30286                    kind: "cache_resend".to_string(),
30287                    severity: "warn".to_string(),
30288                    message: "cached input ratio was high".to_string(),
30289                    guidance: "compact or restart the session".to_string(),
30290                },
30291                session_cost::SessionCostGuardrail {
30292                    kind: "prompt_budget".to_string(),
30293                    severity: "warn".to_string(),
30294                    message: "largest prompt turn reached 999999 tokens".to_string(),
30295                    guidance: "compact the session before another large turn".to_string(),
30296                },
30297                session_cost::SessionCostGuardrail {
30298                    kind: "restart_loop".to_string(),
30299                    severity: "warn".to_string(),
30300                    message: "restart churn detected".to_string(),
30301                    guidance: "restart cleanly".to_string(),
30302                },
30303                session_cost::SessionCostGuardrail {
30304                    kind: "noop_closeout".to_string(),
30305                    severity: "warn".to_string(),
30306                    message: "commit_already_current appeared 8 times".to_string(),
30307                    guidance: "avoid reopening without new edits".to_string(),
30308                },
30309            ],
30310            loop_clusters: vec![session_cost::SessionCostLoopCluster {
30311                kind: "command_bundle".to_string(),
30312                label: "cargo test -> cargo build --release".to_string(),
30313                occurrences: 2,
30314                max_consecutive: 2,
30315            }],
30316            file_read_diagnostics: vec![session_cost::SessionCostFileReadDiagnostic {
30317                path: "src/lib.rs".to_string(),
30318                range: "12-40".to_string(),
30319                occurrences: 3,
30320                estimated_tokens: 1200,
30321                duplicate_estimated_tokens: 800,
30322                follow_up_commands: vec![
30323                    "tsift source-read src/lib.rs --start 12 --lines 29 --budget normal"
30324                        .to_string(),
30325                ],
30326            }],
30327            prompt_targets: vec![
30328                session_review::SessionReviewPromptTarget {
30329                    text: "do one".to_string(),
30330                    occurrences: 1,
30331                },
30332                session_review::SessionReviewPromptTarget {
30333                    text: "do two".to_string(),
30334                    occurrences: 1,
30335                },
30336            ],
30337            commands: vec![],
30338            touched_files: vec![],
30339            touched_symbols: vec![],
30340            failures: vec![],
30341            runtime_events: vec![],
30342            restart_churn: vec![],
30343            closeout: vec![],
30344            largest_turns: vec![],
30345            sessions: vec![session_review::SessionReviewSession {
30346                source: "claude_jsonl".to_string(),
30347                path: "/tmp/session.jsonl".to_string(),
30348                matched_by: vec!["path".to_string()],
30349                modified_unix_secs: None,
30350                prompt_target_count: 2,
30351                command_groups: 0,
30352                file_groups: 2,
30353                symbol_groups: 1,
30354                failure_groups: 1,
30355                runtime_event_groups: 0,
30356                restart_churn_groups: 0,
30357                closeout_groups: 0,
30358                usage_samples: 1,
30359                prompt_tokens: 120,
30360                cached_input_tokens: 80,
30361                cache_creation_input_tokens: 0,
30362                output_tokens: 40,
30363                reasoning_output_tokens: 0,
30364                total_tokens: 240,
30365                largest_turn_total_tokens: 240,
30366            }],
30367            next_context: session_review::SessionReviewNextContext {
30368                target: "tasks/software/tsift.md".to_string(),
30369                active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
30370                last_verification: session_review::SessionReviewVerificationState {
30371                    status: "green".to_string(),
30372                    detail: "cargo test".to_string(),
30373                },
30374                touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
30375                touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
30376                unresolved_failures: vec![session_review::SessionReviewFailure {
30377                    kind: "timeout".to_string(),
30378                    message: "search timed out".to_string(),
30379                    occurrences: 1,
30380                    command: None,
30381                    session_path: None,
30382                }],
30383                agent_doc_queue: Some(session_review::SessionReviewAgentDocQueueProfile {
30384                    active_queue_prompt: Some(
30385                        "[#one] do one with enough detail to truncate".to_string(),
30386                    ),
30387                    live_exchange_tail: vec!["do one".to_string(), "do two".to_string()],
30388                    backlog_rows: vec!["[#one] do one".to_string(), "[#two] do two".to_string()],
30389                    review_rows: vec![
30390                        "[#review] review one".to_string(),
30391                        "[#review2] review two".to_string(),
30392                    ],
30393                    prompt_presets: vec![
30394                        "#spec-test-build-install-commit-push: update spec + tests"
30395                            .to_string(),
30396                        "#next-steps: collect follow-ups".to_string(),
30397                    ],
30398                    expansion_handles: vec![
30399                        session_review::SessionReviewAgentDocExpansionHandle {
30400                            handle: "adq-next-context".to_string(),
30401                            label: "refresh next-context".to_string(),
30402                            expand: "tsift --envelope session-review tasks/software/tsift.md --next-context --budget normal".to_string(),
30403                        },
30404                        session_review::SessionReviewAgentDocExpansionHandle {
30405                            handle: "adq-context-pack".to_string(),
30406                            label: "refresh context-pack".to_string(),
30407                            expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal".to_string(),
30408                        },
30409                    ],
30410                }),
30411                next_digest_commands: vec![
30412                    "tsift session-review --next-context tasks/software/tsift.md".to_string(),
30413                    "tsift diff-digest .".to_string(),
30414                    "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
30415                    "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
30416                ],
30417            },
30418            warnings: vec![],
30419        };
30420
30421        let budget_report = build_session_review_next_context_budget_report(
30422            &report,
30423            ResponseBudget::new(Some(1), Some(12)),
30424            None,
30425        );
30426
30427        assert!(budget_report.truncated);
30428        assert_eq!(budget_report.prompt_targets, vec!["do one"]);
30429        assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
30430        assert!(
30431            budget_report.touched_symbol_refs[0]
30432                .handle
30433                .starts_with("ncsym-")
30434        );
30435        assert_eq!(
30436            budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
30437            Some("alpha/helper")
30438        );
30439        assert!(
30440            budget_report.unresolved_failures[0]
30441                .handle
30442                .starts_with("snf-")
30443        );
30444        assert_eq!(budget_report.next_digest_commands.len(), 4);
30445        assert_eq!(
30446            budget_report.next_digest_commands[2],
30447            "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
30448        );
30449        let queue = budget_report
30450            .agent_doc_queue
30451            .as_ref()
30452            .expect("agent-doc queue budget profile should be present");
30453        assert_eq!(queue.active_queue_prompt.as_deref(), Some("[#one] do..."));
30454        assert_eq!(queue.backlog_rows, vec!["[#one] do..."]);
30455        assert_eq!(queue.review_row_total, 2);
30456        assert_eq!(queue.prompt_presets.len(), 1);
30457        assert_eq!(queue.expansion_handles.len(), 2);
30458        assert!(queue.truncated);
30459        assert_eq!(budget_report.next_token_actions.len(), 1);
30460        assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
30461
30462        let full_action_report = build_session_review_next_context_budget_report(
30463            &report,
30464            ResponseBudget::new(Some(6), Some(120)),
30465            None,
30466        );
30467        assert_eq!(
30468            full_action_report
30469                .next_token_actions
30470                .iter()
30471                .map(|action| action.kind.as_str())
30472                .collect::<Vec<_>>(),
30473            vec![
30474                "prompt_budget",
30475                "cache_resend",
30476                "repeated_raw_read",
30477                "repeated_command_bundle",
30478                "restart_loop",
30479                "noop_closeout"
30480            ]
30481        );
30482        assert_eq!(
30483            full_action_report.next_token_actions[0]
30484                .compact_command
30485                .as_deref(),
30486            Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
30487        );
30488        assert_eq!(
30489            full_action_report.next_token_actions[0]
30490                .restart_command
30491                .as_deref(),
30492            Some("agent-doc start \"tasks/software/tsift.md\"")
30493        );
30494        assert!(
30495            full_action_report.next_token_actions[0]
30496                .digest_commands
30497                .iter()
30498                .any(|command| command
30499                    == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
30500        );
30501        let raw_read_action = full_action_report
30502            .next_token_actions
30503            .iter()
30504            .find(|action| action.kind == "repeated_raw_read")
30505            .expect("raw read action");
30506        assert!(
30507            raw_read_action.rewrite_commands.iter().any(
30508                |command| command == "tsift rewrite --run \"sed -n 12,40p \\\"src/lib.rs\\\"\""
30509            ),
30510            "raw read rewrite commands: {:?}",
30511            raw_read_action.rewrite_commands
30512        );
30513        assert!(raw_read_action.rewrite_commands.iter().any(|command| command
30514        == "tsift --envelope source-read src/lib.rs --start 12 --lines 29 --budget normal"));
30515        let command_bundle_action = full_action_report
30516            .next_token_actions
30517            .iter()
30518            .find(|action| action.kind == "repeated_command_bundle")
30519            .expect("command bundle action");
30520        assert!(
30521            command_bundle_action
30522                .rewrite_commands
30523                .iter()
30524                .any(|command| command == "tsift rewrite --run \"cargo test\"")
30525        );
30526        assert!(
30527            command_bundle_action
30528                .rewrite_commands
30529                .iter()
30530                .any(|command| command == "tsift rewrite --run \"cargo build --release\"")
30531        );
30532    }
30533
30534    #[test]
30535    fn context_pack_diff_preview_limits_files_and_symbols() {
30536        let report = diff_digest::DiffDigestReport {
30537            root: "/repo".to_string(),
30538            mode: diff_digest::DiffDigestMode::WorkingTree,
30539            revision: None,
30540            files_changed: 2,
30541            files_with_current_summaries: 1,
30542            symbols_touched: 3,
30543            call_edges_added: 1,
30544            call_edges_removed: 0,
30545            files: vec![
30546                diff_digest::DiffDigestFile {
30547                    path: "src/lib.rs".to_string(),
30548                    status: diff_digest::DiffDigestFileStatus::Modified,
30549                    touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
30550                    summary_state: diff_digest::DiffDigestSummaryState::Current,
30551                    current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30552                        symbol: "alpha_helper".to_string(),
30553                        summary: "alpha helper handles the main alpha workflow".to_string(),
30554                    }],
30555                    added_call_edges: vec!["alpha->beta".to_string()],
30556                    removed_call_edges: vec![],
30557                    warnings: vec!["stale parse".to_string()],
30558                },
30559                diff_digest::DiffDigestFile {
30560                    path: "src/main.rs".to_string(),
30561                    status: diff_digest::DiffDigestFileStatus::Added,
30562                    touched_symbols: vec!["main".to_string()],
30563                    summary_state: diff_digest::DiffDigestSummaryState::Missing,
30564                    current_summaries: vec![],
30565                    added_call_edges: vec![],
30566                    removed_call_edges: vec![],
30567                    warnings: vec![],
30568                },
30569            ],
30570        };
30571
30572        let preview =
30573            build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
30574
30575        assert!(preview.truncated);
30576        assert_eq!(preview.files.len(), 1);
30577        assert_eq!(preview.files[0].path, "src/lib.rs");
30578        assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
30579        assert!(
30580            preview.files[0].touched_symbol_refs[0]
30581                .handle
30582                .starts_with("cdsym-")
30583        );
30584        assert_eq!(
30585            preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
30586            Some("alpha/he...")
30587        );
30588        assert!(
30589            preview.files[0].summary_refs[0]
30590                .handle
30591                .starts_with("cdsum-")
30592        );
30593        assert_eq!(
30594            preview.files[0].summary_refs[0].tag_alias.as_deref(),
30595            Some("alpha/he...")
30596        );
30597        assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
30598        assert_eq!(
30599            preview.files[0].summary_refs[0].expand,
30600            "tsift summarize --file \"src/lib.rs\""
30601        );
30602        assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
30603    }
30604
30605    #[test]
30606    fn context_pack_status_reminders_include_stale_index_state() {
30607        let dir = setup_graph_index();
30608        std::thread::sleep(std::time::Duration::from_millis(50));
30609        std::fs::write(
30610            dir.path().join("main.rs"),
30611            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
30612        )
30613        .unwrap();
30614
30615        let reminders = context_pack_status_reminders(dir.path());
30616
30617        assert_eq!(reminders.len(), 1);
30618        assert!(reminders[0].contains("index stale"));
30619        assert!(reminders[0].contains("tsift index ."));
30620    }
30621
30622    // #gdbgatecold regression-lock: the trusted context-pack pipeline must
30623    // share its index-inspection across `prepare_agent_doc_index_gate` and
30624    // `context_pack_status_reminders` (both call `IndexDb::inspect_read_only`
30625    // on the same `(root, .tsift/index.db)` key). With the scope guard
30626    // active in `build_context_pack_report_with_profile`, the second call
30627    // hits the cache, so we should record one miss and at least one hit.
30628    #[test]
30629    fn build_context_pack_reuses_inspect_within_scope() {
30630        let dir = setup_graph_index();
30631        init_git_repo(dir.path());
30632        let _guard = index::InspectScopeGuard::new();
30633        let _ = build_context_pack_report(
30634            dir.path(),
30635            None,
30636            None,
30637            None,
30638            ResponseBudget::new(Some(2), Some(96)),
30639        )
30640        .unwrap();
30641        let (hits, misses) = index::inspect_scope_stats();
30642        assert!(
30643            hits >= 1,
30644            "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
30645        );
30646        assert!(
30647            misses >= 1,
30648            "expected at least one initial inspect miss (hits={hits}, misses={misses})"
30649        );
30650    }
30651
30652    // #gdbgatecold scope-isolation: outside of any scope, every call to
30653    // `IndexDb::inspect_read_only` must hit the disk fresh. This locks in
30654    // the contract that the search/status fast-paths never reuse a cached
30655    // inspection across consecutive top-level calls.
30656    #[test]
30657    fn inspect_read_only_outside_scope_does_not_cache() {
30658        let dir = setup_graph_index();
30659        let db_path = dir.path().join(".tsift/index.db");
30660        let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30661        let (hits, misses) = index::inspect_scope_stats();
30662        assert_eq!(
30663            (hits, misses),
30664            (0, 0),
30665            "no scope guard => no hits/misses recorded"
30666        );
30667        let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30668        let (hits, _) = index::inspect_scope_stats();
30669        assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
30670    }
30671
30672    #[test]
30673    fn context_pack_refreshes_stale_index_before_handoff() {
30674        let dir = setup_graph_index();
30675        init_git_repo(dir.path());
30676        std::thread::sleep(std::time::Duration::from_millis(50));
30677        std::fs::write(
30678            dir.path().join("main.rs"),
30679            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
30680        )
30681        .unwrap();
30682
30683        let report = build_context_pack_report(
30684            dir.path(),
30685            None,
30686            None,
30687            None,
30688            ResponseBudget::new(Some(2), Some(96)),
30689        )
30690        .unwrap();
30691
30692        assert!(
30693            report
30694                .status_reminders
30695                .iter()
30696                .any(|reminder| reminder.contains("index refreshed")
30697                    && reminder.contains("context-pack handoff")),
30698            "expected context-pack refresh diagnostic, got {:?}",
30699            report.status_reminders
30700        );
30701        assert!(
30702            !report
30703                .status_reminders
30704                .iter()
30705                .any(|reminder| reminder.contains("index stale")),
30706            "stale reminder should be gone after refresh: {:?}",
30707            report.status_reminders
30708        );
30709
30710        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
30711        let summary = db.compute_changes(dir.path()).unwrap();
30712        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
30713    }
30714
30715    #[test]
30716    fn context_pack_materializes_source_handles_into_graph_store() {
30717        let dir = tempfile::tempdir().unwrap();
30718        let packet = ExplorationPacket {
30719            budget: exploration_budget_for_counts(2, 1),
30720            relationship_map: vec![ExplorationRelation {
30721                from: "file:main.rs".to_string(),
30722                relation: "touches_symbol".to_string(),
30723                to: "symbol:helper".to_string(),
30724                label: Some("modified diff".to_string()),
30725            }],
30726            source_windows: vec![ExplorationSourceWindow {
30727                handle: "xwin-test".to_string(),
30728                file: "main.rs".to_string(),
30729                start: 1,
30730                end: 32,
30731                reason: "changed file".to_string(),
30732                expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
30733            }],
30734            worker_context: vec![ExplorationWorkerContext {
30735                handle: "xwrk-test".to_string(),
30736                target: "tasks/software/tsift.md".to_string(),
30737                summary: "do #kgnv".to_string(),
30738                expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
30739                    .to_string(),
30740            }],
30741            no_reread_guidance: "use windows".to_string(),
30742        };
30743
30744        let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
30745        assert_eq!(packet.source_windows[0].handle, "xwin-test");
30746
30747        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
30748        let source_handles = store.nodes_by_kind("source_handle").unwrap();
30749        assert_eq!(source_handles.len(), 1);
30750        assert_eq!(
30751            source_handles[0].properties.get("file"),
30752            Some(&"main.rs".to_string())
30753        );
30754        assert_eq!(
30755            store
30756                .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
30757                .unwrap()
30758                .len(),
30759            1
30760        );
30761        let worker_context = store.nodes_by_kind("worker_context").unwrap();
30762        assert_eq!(worker_context.len(), 1);
30763        assert_eq!(
30764            store
30765                .outgoing_edges("xwrk-test", Some("scopes_source"))
30766                .unwrap()
30767                .len(),
30768            1
30769        );
30770    }
30771
30772    #[test]
30773    fn context_pack_records_graph_orchestration_observability() {
30774        let dir = setup_traversal_project();
30775        init_git_repo(dir.path());
30776        let session = dir.path().join("tasks/software/tsift.md");
30777        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
30778
30779        let report = build_context_pack_report(
30780            &session,
30781            None,
30782            None,
30783            None,
30784            ResponseBudget::new(Some(4), Some(160)),
30785        )
30786        .unwrap();
30787
30788        assert_eq!(
30789            report.graph_orchestration.contract_version,
30790            CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
30791        );
30792        assert_eq!(
30793            report
30794                .graph_orchestration
30795                .projection_freshness
30796                .status
30797                .as_str(),
30798            "current"
30799        );
30800        assert!(!report.graph_orchestration.projection_hashes.is_empty());
30801        assert_eq!(report.graph_orchestration.readiness.status, "blocked");
30802        assert_eq!(
30803            report.graph_orchestration.readiness.reason,
30804            "summary_cache_empty"
30805        );
30806        assert!(report.graph_orchestration.readiness.fail_closed);
30807        assert!(
30808            report
30809                .graph_orchestration
30810                .readiness
30811                .next_commands
30812                .iter()
30813                .any(|command| command == "tsift summarize --extract ."),
30814            "{:?}",
30815            report.graph_orchestration.readiness.next_commands
30816        );
30817        assert!(
30818            report
30819                .graph_orchestration
30820                .evidence_packet_ids
30821                .iter()
30822                .all(|id| !id.starts_with("gevd-")),
30823            "evidence packet ids should be empty when readiness is blocked: {:?}",
30824            report.graph_orchestration.evidence_packet_ids
30825        );
30826        assert!(
30827            report
30828                .graph_orchestration
30829                .conflict_matrix_decisions
30830                .iter()
30831                .any(|decision| decision.contains("readiness blocked")),
30832            "conflict-matrix decisions should reference readiness block: {:?}",
30833            report.graph_orchestration.conflict_matrix_decisions
30834        );
30835        assert!(
30836            !report
30837                .graph_orchestration
30838                .follow_up_commands
30839                .iter()
30840                .any(|command| command.contains("conflict-matrix")),
30841            "conflict-matrix command should not appear when readiness is blocked: {:?}",
30842            report.graph_orchestration.follow_up_commands
30843        );
30844        assert!(
30845            report
30846                .graph_orchestration
30847                .follow_up_commands
30848                .iter()
30849                .any(|command| command == "tsift summarize --extract ."),
30850            "{:?}",
30851            report.graph_orchestration.follow_up_commands
30852        );
30853        assert!(
30854            !report
30855                .graph_orchestration
30856                .worker_ownership_blocks
30857                .is_empty()
30858        );
30859    }
30860
30861    #[test]
30862    fn convex_sync_report_chunks_upserts_and_tombstones() {
30863        let dir = setup_traversal_project();
30864        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
30865        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
30866        let mut snapshot = projection.to_convex_rows();
30867        snapshot.nodes.push(ConvexNodeRow {
30868            external_id: "stale-node".to_string(),
30869            kind: "backlog".to_string(),
30870            label: "stale".to_string(),
30871            properties: BTreeMap::new(),
30872            provenance: Vec::new(),
30873            freshness: None,
30874        });
30875        snapshot.edges.clear();
30876        snapshot.edges.push(ConvexEdgeRow {
30877            edge_key: "stale-edge".to_string(),
30878            from_external_id: "stale-node".to_string(),
30879            to_external_id: "stale-node".to_string(),
30880            kind: "mentions".to_string(),
30881            properties: BTreeMap::new(),
30882            provenance: Vec::new(),
30883            freshness: None,
30884        });
30885        let snapshot_path = dir.path().join("convex-snapshot.json");
30886        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30887
30888        let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
30889
30890        assert_eq!(report.freshness.status, "stale");
30891        assert!(report.freshness.fail_closed);
30892        assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
30893        assert!(
30894            report.edge_upserts.len() > 1,
30895            "snapshot without edges should upsert local edges"
30896        );
30897        assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
30898        assert_eq!(
30899            report.chunks.first().map(|chunk| chunk.operation.as_str()),
30900            Some("delete_edges"),
30901            "edge tombstones should be planned before node tombstones"
30902        );
30903        assert!(
30904            report
30905                .chunks
30906                .iter()
30907                .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
30908            "expected chunked edge upserts, got {:?}",
30909            report.chunks
30910        );
30911    }
30912
30913    #[test]
30914    fn convex_snapshot_validation_fails_closed_when_stale() {
30915        let dir = setup_traversal_project();
30916        build_traversal_graph(dir.path(), dir.path(), None).unwrap();
30917        let snapshot = ConvexProjectionRows::default();
30918        let snapshot_path = dir.path().join("empty-convex-snapshot.json");
30919        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30920
30921        let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
30922        assert!(
30923            err.to_string()
30924                .contains("Convex graph projection is not current"),
30925            "{err}"
30926        );
30927    }
30928
30929    #[test]
30930    fn convex_sync_report_marks_live_apply_mode_without_network() {
30931        let dir = setup_traversal_project();
30932        let report =
30933            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30934
30935        assert!(!report.dry_run);
30936        assert!(
30937            !report
30938                .diagnostics
30939                .iter()
30940                .any(|diagnostic| diagnostic.contains("dry-run only")),
30941            "apply-mode report should not claim dry-run diagnostics"
30942        );
30943        assert!(
30944            report
30945                .chunks
30946                .iter()
30947                .any(|chunk| chunk.operation == "upsert_nodes"),
30948            "live apply mode should still expose chunked idempotent operations"
30949        );
30950    }
30951
30952    #[test]
30953    fn convex_sync_apply_round_trips_with_http_backend() {
30954        use std::net::TcpListener;
30955        use std::sync::{Arc, Mutex};
30956
30957        let dir = setup_traversal_project();
30958        let report =
30959            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30960        let expected_chunks = report.chunks.len();
30961        assert!(expected_chunks > 0);
30962
30963        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
30964        let endpoint = format!("http://{}", listener.local_addr().unwrap());
30965        let operations = Arc::new(Mutex::new(Vec::<String>::new()));
30966        let server_operations = Arc::clone(&operations);
30967        let server = std::thread::spawn(move || {
30968            for _ in 0..expected_chunks {
30969                let (mut stream, _) = listener.accept().unwrap();
30970                let mut reader = BufReader::new(stream.try_clone().unwrap());
30971                let mut request_line = String::new();
30972                reader.read_line(&mut request_line).unwrap();
30973                assert!(request_line.starts_with("POST "));
30974
30975                let mut content_length = 0usize;
30976                loop {
30977                    let mut line = String::new();
30978                    reader.read_line(&mut line).unwrap();
30979                    if line == "\r\n" {
30980                        break;
30981                    }
30982                    if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
30983                        content_length = value.trim().parse().unwrap();
30984                    }
30985                }
30986
30987                let mut body = vec![0u8; content_length];
30988                reader.read_exact(&mut body).unwrap();
30989                let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
30990                server_operations
30991                    .lock()
30992                    .unwrap()
30993                    .push(request["operation"].as_str().unwrap().to_string());
30994
30995                let response = br#"{"status":"ok","message":"accepted"}"#;
30996                write!(
30997                    stream,
30998                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
30999                    response.len()
31000                )
31001                .unwrap();
31002                stream.write_all(response).unwrap();
31003            }
31004        });
31005
31006        cmd_convex_sync(
31007            ConvexSyncOptions {
31008                path: dir.path(),
31009                scope: None,
31010                snapshot: None,
31011                chunk_size: 100,
31012                remote_snapshot: false,
31013                apply: true,
31014                endpoint: Some(&endpoint),
31015                auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
31016            },
31017            OutputFormat {
31018                json_output: false,
31019                compact: true,
31020                pretty: false,
31021                terse: false,
31022                ultra_terse: false,
31023                schema: false,
31024                envelope: false,
31025            },
31026        )
31027        .unwrap();
31028        server.join().unwrap();
31029
31030        let operations = operations.lock().unwrap().clone();
31031        assert!(operations.contains(&"upsert_nodes".to_string()));
31032        assert!(operations.contains(&"upsert_edges".to_string()));
31033    }
31034
31035    #[test]
31036    fn context_pack_diff_preview_attaches_tag_ontology_refs() {
31037        let root = tempfile::tempdir().unwrap();
31038        fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
31039        fs::write(
31040            root.path().join(".naming/tags/alpha.md"),
31041            "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
31042        )
31043        .unwrap();
31044        let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
31045        let report = diff_digest::DiffDigestReport {
31046            root: root.path().display().to_string(),
31047            mode: diff_digest::DiffDigestMode::WorkingTree,
31048            revision: None,
31049            files_changed: 1,
31050            files_with_current_summaries: 1,
31051            symbols_touched: 1,
31052            call_edges_added: 0,
31053            call_edges_removed: 0,
31054            files: vec![diff_digest::DiffDigestFile {
31055                path: "src/lib.rs".to_string(),
31056                status: diff_digest::DiffDigestFileStatus::Modified,
31057                touched_symbols: vec!["alpha_helper".to_string()],
31058                summary_state: diff_digest::DiffDigestSummaryState::Current,
31059                current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
31060                    symbol: "alpha_helper".to_string(),
31061                    summary: "alpha helper summary".to_string(),
31062                }],
31063                added_call_edges: vec![],
31064                removed_call_edges: vec![],
31065                warnings: vec![],
31066            }],
31067        };
31068
31069        let preview = build_context_pack_diff_preview(
31070            &report,
31071            ResponseBudget::new(Some(1), Some(80)),
31072            Some(&ontology),
31073        );
31074
31075        let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
31076        assert!(symbol_ref.handle.starts_with("tont-"));
31077        assert_eq!(symbol_ref.tag, "alpha");
31078        assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
31079        assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
31080        assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
31081        assert_eq!(
31082            preview.files[0].summary_refs[0].ontology_refs[0].path,
31083            ".naming/tags/alpha.md"
31084        );
31085    }
31086
31087    #[test]
31088    fn context_pack_test_preview_limits_failure_groups() {
31089        let report = test_digest::TestDigestReport {
31090            root: "/repo".to_string(),
31091            runner: "cargo".to_string(),
31092            failures: 2,
31093            grouped_failures: 2,
31094            counts: test_digest::TestDigestCounts {
31095                passed: Some(8),
31096                failed: Some(2),
31097                skipped: Some(1),
31098            },
31099            failure_groups: vec![
31100                test_digest::TestDigestFailure {
31101                    tests: vec!["suite::alpha_failure".to_string()],
31102                    message: "assertion failed".to_string(),
31103                    path: Some("src/lib.rs".to_string()),
31104                    line: Some(42),
31105                    column: None,
31106                    occurrences: 1,
31107                    summary_state: test_digest::TestDigestSummaryState::Current,
31108                    current_summaries: vec![test_digest::TestDigestSummarySnippet {
31109                        symbol: "alpha_failure".to_string(),
31110                        summary: "failure summary for alpha test".to_string(),
31111                    }],
31112                },
31113                test_digest::TestDigestFailure {
31114                    tests: vec!["suite::beta_failure".to_string()],
31115                    message: "panic".to_string(),
31116                    path: Some("src/main.rs".to_string()),
31117                    line: Some(7),
31118                    column: None,
31119                    occurrences: 1,
31120                    summary_state: test_digest::TestDigestSummaryState::Missing,
31121                    current_summaries: vec![],
31122                },
31123            ],
31124            warnings: vec!["warning text".to_string()],
31125        };
31126
31127        let preview =
31128            build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
31129
31130        assert!(preview.truncated);
31131        assert_eq!(preview.failure_groups.len(), 1);
31132        assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
31133        assert_eq!(preview.failure_groups[0].message, "assertion f...");
31134        assert!(
31135            preview.failure_groups[0].summary_refs[0]
31136                .handle
31137                .starts_with("ctsum-")
31138        );
31139        assert_eq!(
31140            preview.failure_groups[0].summary_refs[0].expand,
31141            "tsift summarize --file \"src/lib.rs\""
31142        );
31143        assert_eq!(preview.warnings, vec!["warning text"]);
31144    }
31145
31146    #[test]
31147    fn maybe_attach_log_digest_raw_artifact_persists_bulky_logs() {
31148        let dir = tempfile::tempdir().unwrap();
31149        let root = dir.path();
31150
31151        // Small log: no artifact attached, nothing written.
31152        let small_input = "Compiling serde v1.0.130\n";
31153        let mut small = log_digest::compute(root, small_input).unwrap();
31154        maybe_attach_log_digest_raw_artifact(root, &mut small, small_input).unwrap();
31155        assert!(small.raw_log_artifact.is_none());
31156        assert!(!root.join(".tsift/artifacts").exists());
31157
31158        // Bulky log: artifact persisted with a stable handle and expand command.
31159        let bulky_input = "x".repeat(log_digest::LOG_DIGEST_RAW_ARTIFACT_MIN_BYTES) + "\n";
31160        let mut bulky = log_digest::compute(root, &bulky_input).unwrap();
31161        maybe_attach_log_digest_raw_artifact(root, &mut bulky, &bulky_input).unwrap();
31162        let artifact = bulky.raw_log_artifact.expect("artifact attached for bulky log");
31163        assert!(artifact.handle.starts_with("logdg-"));
31164        assert_eq!(artifact.bytes, bulky_input.len());
31165        assert!(artifact.expand.contains("tsift log-digest"));
31166        assert!(artifact.expand.contains("--input"));
31167        let persisted = root.join(&artifact.path);
31168        assert!(persisted.exists(), "artifact file written to {persisted:?}");
31169        assert_eq!(std::fs::read_to_string(&persisted).unwrap(), bulky_input);
31170    }
31171
31172    #[test]
31173    fn context_pack_log_preview_limits_signals_and_refs() {
31174        let report = log_digest::LogDigestReport {
31175            root: "/repo".to_string(),
31176            total_lines: 12,
31177            non_empty_lines: 10,
31178            signal_groups: 2,
31179            repeated_line_groups: 2,
31180            repeated_line_occurrences: 3,
31181            line_family_groups: 0,
31182            file_ref_groups: 2,
31183            symbol_ref_groups: 2,
31184            stack_groups: 1,
31185            signals: vec![
31186                log_digest::LogDigestSignal {
31187                    severity: "error".to_string(),
31188                    message: "src/lib.rs:42 boom".to_string(),
31189                    path: Some("src/lib.rs".to_string()),
31190                    line: Some(42),
31191                    column: None,
31192                    occurrences: 2,
31193                    summary_state: log_digest::LogDigestSummaryState::Current,
31194                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
31195                        symbol: "alpha_helper".to_string(),
31196                        summary: "alpha helper cached log summary".to_string(),
31197                    }],
31198                },
31199                log_digest::LogDigestSignal {
31200                    severity: "warn".to_string(),
31201                    message: "slow path".to_string(),
31202                    path: None,
31203                    line: None,
31204                    column: None,
31205                    occurrences: 1,
31206                    summary_state: log_digest::LogDigestSummaryState::Unavailable,
31207                    current_summaries: vec![],
31208                },
31209            ],
31210            repeated_lines: vec![
31211                log_digest::LogDigestRepeatedLine {
31212                    line: "retrying work item alpha".to_string(),
31213                    occurrences: 3,
31214                },
31215                log_digest::LogDigestRepeatedLine {
31216                    line: "retrying work item beta".to_string(),
31217                    occurrences: 2,
31218                },
31219            ],
31220            line_families: vec![],
31221            file_refs: vec![
31222                log_digest::LogDigestFileRef {
31223                    path: "src/lib.rs".to_string(),
31224                    line: Some(42),
31225                    column: None,
31226                    occurrences: 2,
31227                    summary_state: log_digest::LogDigestSummaryState::Current,
31228                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
31229                        symbol: "alpha_helper".to_string(),
31230                        summary: "alpha helper cached file summary".to_string(),
31231                    }],
31232                },
31233                log_digest::LogDigestFileRef {
31234                    path: "src/main.rs".to_string(),
31235                    line: Some(7),
31236                    column: None,
31237                    occurrences: 1,
31238                    summary_state: log_digest::LogDigestSummaryState::Missing,
31239                    current_summaries: vec![],
31240                },
31241            ],
31242            symbol_refs: vec![
31243                log_digest::LogDigestSymbolRef {
31244                    symbol: "alpha_helper".to_string(),
31245                    occurrences: 2,
31246                    summary_state: log_digest::LogDigestSummaryState::Current,
31247                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
31248                        symbol: "alpha_helper".to_string(),
31249                        summary: "alpha helper cached symbol summary".to_string(),
31250                    }],
31251                },
31252                log_digest::LogDigestSymbolRef {
31253                    symbol: "beta_helper".to_string(),
31254                    occurrences: 1,
31255                    summary_state: log_digest::LogDigestSummaryState::Missing,
31256                    current_summaries: vec![],
31257                },
31258            ],
31259            stack_traces: vec![log_digest::LogDigestStackGroup {
31260                frames: vec!["frame one".to_string()],
31261                occurrences: 1,
31262            }],
31263            raw_log_artifact: None,
31264            warnings: vec!["warning text".to_string()],
31265        };
31266
31267        let preview =
31268            build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
31269
31270        assert!(preview.truncated);
31271        assert_eq!(preview.signals.len(), 1);
31272        assert_eq!(preview.signals[0].message, "src/lib.rs:...");
31273        assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
31274        assert_eq!(preview.file_refs.len(), 1);
31275        assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
31276        assert!(
31277            preview.signals[0].summary_refs[0]
31278                .handle
31279                .starts_with("clsum-")
31280        );
31281        assert!(
31282            preview.file_refs[0].summary_refs[0]
31283                .handle
31284                .starts_with("clfsum-")
31285        );
31286        assert!(
31287            preview.symbol_refs[0].summary_refs[0]
31288                .handle
31289                .starts_with("clssum-")
31290        );
31291        assert_eq!(
31292            preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
31293            Some("alpha/helper")
31294        );
31295        assert_eq!(
31296            preview.symbol_refs[0].summary_refs[0].expand,
31297            "tsift summarize \"alpha_helper\""
31298        );
31299        assert_eq!(preview.warnings, vec!["warning text"]);
31300    }
31301
31302    #[test]
31303    fn cli_search_rejects_exact_with_strategy_flag() {
31304        let cli = try_parse_cli([
31305            "tsift",
31306            "search",
31307            "test",
31308            "--exact",
31309            "--strategy",
31310            "lexical",
31311        ]);
31312        assert!(cli.is_err());
31313    }
31314
31315    #[test]
31316    fn cli_search_autoindexes_by_default() {
31317        let cli = parse_cli(["tsift", "search", "test"]);
31318        match cli.command {
31319            Some(Commands::Search {
31320                autoindex,
31321                no_autoindex,
31322                ..
31323            }) => {
31324                assert!(!autoindex);
31325                assert!(!no_autoindex);
31326                assert!(autoindex || !no_autoindex);
31327            }
31328            _ => panic!("expected Search command"),
31329        }
31330    }
31331
31332    #[test]
31333    fn cli_search_accepts_no_autoindex_flag() {
31334        let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
31335        match cli.command {
31336            Some(Commands::Search {
31337                autoindex,
31338                no_autoindex,
31339                ..
31340            }) => {
31341                assert!(!autoindex);
31342                assert!(no_autoindex);
31343            }
31344            _ => panic!("expected Search command"),
31345        }
31346    }
31347
31348    #[test]
31349    fn cli_search_rejects_conflicting_autoindex_flags() {
31350        let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
31351        assert!(cli.is_err());
31352    }
31353
31354    // --- relativize paths ---
31355
31356    #[test]
31357    fn cli_accepts_global_absolute_flag() {
31358        let cli = parse_cli(["tsift", "--absolute", "status"]);
31359        assert!(cli.absolute);
31360        assert!(matches!(cli.command, Some(Commands::Status { .. })));
31361    }
31362
31363    #[test]
31364    fn cli_accepts_global_tabular_flag() {
31365        let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
31366        assert!(cli.tabular);
31367        assert!(matches!(cli.command, Some(Commands::Search { .. })));
31368    }
31369
31370    #[test]
31371    fn cli_tabular_with_graph() {
31372        let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
31373        assert!(cli.tabular);
31374        assert!(matches!(cli.command, Some(Commands::Graph { .. })));
31375    }
31376
31377    #[test]
31378    fn cli_tabular_with_communities() {
31379        let cli = parse_cli(["tsift", "--tabular", "communities"]);
31380        assert!(cli.tabular);
31381        assert!(matches!(cli.command, Some(Commands::Communities { .. })));
31382    }
31383
31384    #[test]
31385    fn cli_tabular_with_explain() {
31386        let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
31387        assert!(cli.tabular);
31388        assert!(matches!(cli.command, Some(Commands::Explain { .. })));
31389    }
31390
31391    #[test]
31392    fn cli_traverse_accepts_path_target_and_html_format() {
31393        let cli = parse_cli([
31394            "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
31395        ]);
31396        match cli.command {
31397            Some(Commands::Traverse {
31398                node,
31399                to,
31400                path,
31401                format,
31402                ..
31403            }) => {
31404                assert_eq!(node.as_deref(), Some("#kgnv"));
31405                assert_eq!(to.as_deref(), Some("main"));
31406                assert_eq!(path, PathBuf::from("."));
31407                assert_eq!(format, TraverseFormat::Html);
31408            }
31409            _ => panic!("expected Traverse command"),
31410        }
31411    }
31412
31413    #[test]
31414    fn cli_parses_semantic_related_command() {
31415        let cli = parse_cli([
31416            "tsift",
31417            "semantic",
31418            "graph navigation",
31419            "--path",
31420            ".",
31421            "--kind",
31422            "all",
31423            "--limit",
31424            "3",
31425            "--json",
31426        ]);
31427        match cli.command {
31428            Some(Commands::Semantic {
31429                query,
31430                path,
31431                kind,
31432                limit,
31433                json,
31434                ..
31435            }) => {
31436                assert_eq!(query, "graph navigation");
31437                assert_eq!(path, PathBuf::from("."));
31438                assert_eq!(kind, SemanticRelatedKind::All);
31439                assert_eq!(limit, 3);
31440                assert!(json);
31441            }
31442            _ => panic!("expected Semantic command"),
31443        }
31444    }
31445
31446    #[test]
31447    fn cli_parses_convex_sync_command() {
31448        let cli = parse_cli([
31449            "tsift",
31450            "convex-sync",
31451            ".",
31452            "--snapshot",
31453            "rows.json",
31454            "--chunk-size",
31455            "25",
31456            "--json",
31457        ]);
31458        match cli.command {
31459            Some(Commands::ConvexSync {
31460                path,
31461                snapshot,
31462                chunk_size,
31463                json,
31464                ..
31465            }) => {
31466                assert_eq!(path, PathBuf::from("."));
31467                assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
31468                assert_eq!(chunk_size, 25);
31469                assert!(json);
31470            }
31471            _ => panic!("expected ConvexSync command"),
31472        }
31473    }
31474
31475    #[test]
31476    fn cli_parses_convex_sync_live_flags() {
31477        let cli = parse_cli([
31478            "tsift",
31479            "convex-sync",
31480            ".",
31481            "--remote-snapshot",
31482            "--apply",
31483            "--endpoint",
31484            "https://example.test/convex-graph",
31485            "--auth-token-env",
31486            "TSIFT_TEST_TOKEN",
31487        ]);
31488        match cli.command {
31489            Some(Commands::ConvexSync {
31490                remote_snapshot,
31491                apply,
31492                endpoint,
31493                auth_token_env,
31494                ..
31495            }) => {
31496                assert!(remote_snapshot);
31497                assert!(apply);
31498                assert_eq!(
31499                    endpoint.as_deref(),
31500                    Some("https://example.test/convex-graph")
31501                );
31502                assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
31503            }
31504            _ => panic!("expected ConvexSync command"),
31505        }
31506    }
31507
31508    #[test]
31509    fn cli_parses_graph_db_query() {
31510        let cli = parse_cli([
31511            "tsift",
31512            "graph-db",
31513            "--backend",
31514            "convex-snapshot",
31515            "--convex-snapshot",
31516            "rows.json",
31517            "--json",
31518            "neighborhood",
31519            "gbak-kgnv",
31520            "--depth",
31521            "2",
31522            "--edge-kind",
31523            "mentions",
31524            "--property",
31525            "path=tasks/software/tsift.md",
31526            "--cursor",
31527            "gbak-old",
31528            "--limit",
31529            "10",
31530        ]);
31531        match cli.command {
31532            Some(Commands::GraphDb {
31533                backend,
31534                convex_snapshot,
31535                json,
31536                query,
31537                ..
31538            }) => {
31539                assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
31540                assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
31541                assert!(json);
31542                match query {
31543                    GraphDbQuery::Neighborhood {
31544                        id,
31545                        depth,
31546                        edge_kind,
31547                        cursor,
31548                        limit,
31549                        property_filters,
31550                    } => {
31551                        assert_eq!(id, "gbak-kgnv");
31552                        assert_eq!(depth, 2);
31553                        assert_eq!(edge_kind.as_deref(), Some("mentions"));
31554                        assert_eq!(cursor.as_deref(), Some("gbak-old"));
31555                        assert_eq!(limit, Some(10));
31556                        assert_eq!(
31557                            property_filters,
31558                            vec!["path=tasks/software/tsift.md".to_string()]
31559                        );
31560                    }
31561                    _ => panic!("expected graph-db neighborhood query"),
31562                }
31563            }
31564            _ => panic!("expected GraphDb command"),
31565        }
31566    }
31567
31568    #[test]
31569    fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
31570        let cli = parse_cli([
31571            "tsift",
31572            "graph-db",
31573            "--json",
31574            "backend-eval",
31575            "--candidate",
31576            "surrealdb",
31577            "--target",
31578            "gval",
31579            "--full-projection",
31580        ]);
31581        match cli.command {
31582            Some(Commands::GraphDb { json, query, .. }) => {
31583                assert!(json);
31584                match query {
31585                    GraphDbQuery::BackendEval {
31586                        candidates,
31587                        targets,
31588                        full_projection,
31589                    } => {
31590                        assert_eq!(candidates, vec!["surrealdb".to_string()]);
31591                        assert_eq!(targets, vec!["gval".to_string()]);
31592                        assert!(full_projection);
31593                    }
31594                    _ => panic!("expected graph-db backend-eval query"),
31595                }
31596            }
31597            _ => panic!("expected GraphDb command"),
31598        }
31599    }
31600
31601    #[test]
31602    fn cli_parses_graph_db_tokensave_backend() {
31603        let cli = parse_cli([
31604            "tsift",
31605            "graph-db",
31606            "--backend",
31607            "tokensave",
31608            "--json",
31609            "node",
31610            "fn:main",
31611        ]);
31612        match cli.command {
31613            Some(Commands::GraphDb {
31614                backend,
31615                json,
31616                query,
31617                ..
31618            }) => {
31619                assert_eq!(backend, GraphDbBackend::Tokensave);
31620                assert!(json);
31621                match query {
31622                    GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
31623                    _ => panic!("expected graph-db node query"),
31624                }
31625            }
31626            _ => panic!("expected GraphDb command"),
31627        }
31628    }
31629
31630    #[test]
31631    fn cli_parses_analyze_command() {
31632        let cli = parse_cli([
31633            "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
31634            "--limit", "7", "--json",
31635        ]);
31636        match cli.command {
31637            Some(Commands::Analyze {
31638                path,
31639                scope,
31640                entry_points,
31641                limit,
31642                json,
31643            }) => {
31644                assert_eq!(path, PathBuf::from("."));
31645                assert_eq!(scope.as_deref(), Some("core"));
31646                assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
31647                assert_eq!(limit, 7);
31648                assert!(json);
31649            }
31650            _ => panic!("expected Analyze command"),
31651        }
31652    }
31653
31654    #[test]
31655    fn cli_parses_graph_db_related_query() {
31656        let cli = parse_cli([
31657            "tsift",
31658            "graph-db",
31659            "--json",
31660            "related",
31661            "voice avatar memory retrieval",
31662            "--kind",
31663            "all",
31664            "--depth",
31665            "3",
31666            "--seed-limit",
31667            "4",
31668            "--limit",
31669            "12",
31670        ]);
31671        match cli.command {
31672            Some(Commands::GraphDb { json, query, .. }) => {
31673                assert!(json);
31674                match query {
31675                    GraphDbQuery::Related {
31676                        query,
31677                        kind,
31678                        depth,
31679                        seed_limit,
31680                        limit,
31681                    } => {
31682                        assert_eq!(query, "voice avatar memory retrieval");
31683                        assert_eq!(kind, SemanticRelatedKind::All);
31684                        assert_eq!(depth, 3);
31685                        assert_eq!(seed_limit, 4);
31686                        assert_eq!(limit, 12);
31687                    }
31688                    _ => panic!("expected graph-db related query"),
31689                }
31690            }
31691            _ => panic!("expected GraphDb command"),
31692        }
31693    }
31694
31695    #[test]
31696    fn cli_parses_graph_db_compact_query() {
31697        let cli = parse_cli([
31698            "tsift",
31699            "graph-db",
31700            "--path",
31701            ".",
31702            "compact",
31703            "--apply",
31704            "--prune-tombstones",
31705            "--confirmed-convex-reconciled",
31706        ]);
31707        match cli.command {
31708            Some(Commands::GraphDb { query, .. }) => match query {
31709                GraphDbQuery::Compact {
31710                    apply,
31711                    prune_tombstones,
31712                    confirmed_convex_reconciled,
31713                } => {
31714                    assert!(apply);
31715                    assert!(prune_tombstones);
31716                    assert!(confirmed_convex_reconciled);
31717                }
31718                _ => panic!("expected graph-db compact query"),
31719            },
31720            _ => panic!("expected GraphDb command"),
31721        }
31722    }
31723
31724    #[test]
31725    fn cli_parses_graph_db_snapshot_queries() {
31726        let export_cli = parse_cli([
31727            "tsift",
31728            "graph-db",
31729            "--json",
31730            "snapshot-export",
31731            "graph.db.gz",
31732            "--force",
31733        ]);
31734        match export_cli.command {
31735            Some(Commands::GraphDb { json, query, .. }) => {
31736                assert!(json);
31737                match query {
31738                    GraphDbQuery::SnapshotExport { output, force } => {
31739                        assert_eq!(output, PathBuf::from("graph.db.gz"));
31740                        assert!(force);
31741                    }
31742                    _ => panic!("expected graph-db snapshot-export query"),
31743                }
31744            }
31745            _ => panic!("expected GraphDb command"),
31746        }
31747
31748        let import_cli = parse_cli([
31749            "tsift",
31750            "graph-db",
31751            "snapshot-import",
31752            "graph.db.gz",
31753            "--replace",
31754        ]);
31755        match import_cli.command {
31756            Some(Commands::GraphDb { query, .. }) => match query {
31757                GraphDbQuery::SnapshotImport { artifact, replace } => {
31758                    assert_eq!(artifact, PathBuf::from("graph.db.gz"));
31759                    assert!(replace);
31760                }
31761                _ => panic!("expected graph-db snapshot-import query"),
31762            },
31763            _ => panic!("expected GraphDb command"),
31764        }
31765    }
31766
31767    #[test]
31768    fn cli_parses_impact_command() {
31769        let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
31770        match cli.command {
31771            Some(Commands::Impact {
31772                path,
31773                cached,
31774                limit,
31775                ..
31776            }) => {
31777                assert_eq!(path, PathBuf::from("."));
31778                assert!(cached);
31779                assert_eq!(limit, 5);
31780            }
31781            _ => panic!("expected Impact command"),
31782        }
31783    }
31784
31785    #[test]
31786    fn cli_parses_conflict_matrix_command() {
31787        let cli = parse_cli([
31788            "tsift",
31789            "conflict-matrix",
31790            "--path",
31791            "tasks/software/tsift.md",
31792            "--depth",
31793            "4",
31794            "--limit",
31795            "12",
31796            "--impact-limit",
31797            "6",
31798            "--json",
31799            "pwcm",
31800            "#g6kf",
31801        ]);
31802        match cli.command {
31803            Some(Commands::ConflictMatrix {
31804                targets,
31805                path,
31806                depth,
31807                limit,
31808                impact_limit,
31809                json,
31810                ..
31811            }) => {
31812                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31813                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31814                assert_eq!(depth, 4);
31815                assert_eq!(limit, 12);
31816                assert_eq!(impact_limit, 6);
31817                assert!(json);
31818            }
31819            _ => panic!("expected ConflictMatrix command"),
31820        }
31821    }
31822
31823    #[test]
31824    fn cli_parses_dispatch_trace_command() {
31825        let cli = parse_cli([
31826            "tsift",
31827            "dispatch-trace",
31828            "--path",
31829            "tasks/software/tsift.md",
31830            "--format",
31831            "html",
31832            "--depth",
31833            "4",
31834            "pwcm",
31835            "#g6kf",
31836        ]);
31837        match cli.command {
31838            Some(Commands::DispatchTrace {
31839                targets,
31840                path,
31841                format,
31842                depth,
31843                ..
31844            }) => {
31845                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31846                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31847                assert_eq!(format, DispatchTraceFormat::Html);
31848                assert_eq!(depth, 4);
31849            }
31850            _ => panic!("expected DispatchTrace command"),
31851        }
31852    }
31853
31854    #[test]
31855    fn cli_parses_dependency_dag_command() {
31856        let cli = parse_cli([
31857            "tsift",
31858            "dependency-dag",
31859            "--path",
31860            "tasks/software/tsift.md",
31861            "--depth",
31862            "5",
31863            "--limit",
31864            "20",
31865            "--json",
31866            "alpha",
31867            "#beta",
31868        ]);
31869        match cli.command {
31870            Some(Commands::DependencyDag {
31871                targets,
31872                path,
31873                depth,
31874                limit,
31875                json,
31876                ..
31877            }) => {
31878                assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
31879                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31880                assert_eq!(depth, 5);
31881                assert_eq!(limit, 20);
31882                assert!(json);
31883            }
31884            _ => panic!("expected DependencyDag command"),
31885        }
31886    }
31887
31888    #[test]
31889    fn relativize_strips_root_prefix() {
31890        let root = std::path::Path::new("/home/user/project");
31891        assert_eq!(
31892            relativize("/home/user/project/src/main.rs", root),
31893            "src/main.rs"
31894        );
31895    }
31896
31897    #[test]
31898    fn relativize_leaves_non_matching_path() {
31899        let root = std::path::Path::new("/home/user/project");
31900        assert_eq!(
31901            relativize("/other/path/file.rs", root),
31902            "/other/path/file.rs"
31903        );
31904    }
31905
31906    #[test]
31907    fn relativize_leaves_already_relative() {
31908        let root = std::path::Path::new("/home/user/project");
31909        assert_eq!(relativize("src/main.rs", root), "src/main.rs");
31910    }
31911
31912    #[test]
31913    fn relativize_pathbuf_strips_prefix() {
31914        let root = std::path::Path::new("/home/user/project");
31915        let path = std::path::Path::new("/home/user/project/src/lib.rs");
31916        assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
31917    }
31918
31919    #[test]
31920    fn relativize_edges_strips_caller_file() {
31921        let root = std::path::Path::new("/tmp/proj");
31922        let mut edges = vec![index::StoredEdge {
31923            caller_file: "/tmp/proj/src/main.rs".to_string(),
31924            caller_name: "main".to_string(),
31925            caller_line: 1,
31926            callee_name: "helper".to_string(),
31927            call_site_line: 5,
31928            tagpath_handle: None,
31929        }];
31930        relativize_edges(&mut edges, root);
31931        assert_eq!(edges[0].caller_file, "src/main.rs");
31932    }
31933
31934    #[test]
31935    fn relativize_json_paths_strips_known_keys() {
31936        let root = std::path::Path::new("/tmp/proj");
31937        let mut val = serde_json::json!({
31938            "file": "/tmp/proj/src/main.rs",
31939            "path": "/tmp/proj/test.rs",
31940            "name": "/tmp/proj/not-a-path",
31941            "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
31942        });
31943        relativize_json_paths(&mut val, root);
31944        assert_eq!(val["file"], "src/main.rs");
31945        assert_eq!(val["path"], "test.rs");
31946        assert_eq!(val["name"], "/tmp/proj/not-a-path");
31947        assert_eq!(val["hits"][0]["path"], "nested.rs");
31948    }
31949
31950    // --- limit caps ---
31951
31952    #[test]
31953    fn cli_graph_accepts_limit_flag() {
31954        let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
31955        match cli.command {
31956            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
31957            _ => panic!("expected Graph command"),
31958        }
31959    }
31960
31961    #[test]
31962    fn cli_graph_default_limit_is_20() {
31963        let cli = parse_cli(["tsift", "graph", "main"]);
31964        match cli.command {
31965            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
31966            _ => panic!("expected Graph command"),
31967        }
31968    }
31969
31970    #[test]
31971    fn cli_communities_accepts_limit_flag() {
31972        let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
31973        match cli.command {
31974            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
31975            _ => panic!("expected Communities command"),
31976        }
31977    }
31978
31979    #[test]
31980    fn cli_communities_default_limit_is_10() {
31981        let cli = parse_cli(["tsift", "communities"]);
31982        match cli.command {
31983            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
31984            _ => panic!("expected Communities command"),
31985        }
31986    }
31987
31988    #[test]
31989    fn cli_explain_accepts_limit_flag() {
31990        let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
31991        match cli.command {
31992            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
31993            _ => panic!("expected Explain command"),
31994        }
31995    }
31996
31997    #[test]
31998    fn cli_explain_default_limit_is_15() {
31999        let cli = parse_cli(["tsift", "explain", "main"]);
32000        match cli.command {
32001            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
32002            _ => panic!("expected Explain command"),
32003        }
32004    }
32005
32006    #[test]
32007    fn cli_limit_zero_means_unlimited() {
32008        let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
32009        match cli.command {
32010            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
32011            _ => panic!("expected Graph command"),
32012        }
32013    }
32014
32015    #[test]
32016    fn graph_cmd_limit_runs_ok() {
32017        let dir = setup_graph_index();
32018        let result = cmd_graph(
32019            "main",
32020            dir.path(),
32021            false,
32022            false,
32023            None,
32024            1,
32025            false,
32026            false,
32027            false,
32028            false,
32029            false,
32030            false,
32031            false,
32032            TagpathSearchOpts::default(),
32033        );
32034        assert!(result.is_ok());
32035    }
32036
32037    #[test]
32038    fn graph_cmd_unlimited_runs_ok() {
32039        let dir = setup_graph_index();
32040        let result = cmd_graph(
32041            "main",
32042            dir.path(),
32043            false,
32044            false,
32045            None,
32046            0,
32047            false,
32048            false,
32049            false,
32050            false,
32051            false,
32052            false,
32053            false,
32054            TagpathSearchOpts::default(),
32055        );
32056        assert!(result.is_ok());
32057    }
32058
32059    #[test]
32060    fn graph_cmd_tabular_runs_ok() {
32061        let dir = setup_graph_index();
32062        let result = cmd_graph(
32063            "main",
32064            dir.path(),
32065            false,
32066            false,
32067            None,
32068            20,
32069            false,
32070            false,
32071            false,
32072            false,
32073            false,
32074            true,
32075            false,
32076            TagpathSearchOpts::default(),
32077        );
32078        assert!(result.is_ok());
32079    }
32080
32081    #[test]
32082    fn communities_cmd_tabular_runs_ok() {
32083        let dir = setup_graph_index();
32084        let result = cmd_communities(
32085            dir.path(),
32086            None,
32087            1,
32088            10,
32089            false,
32090            false,
32091            false,
32092            false,
32093            true,
32094            false,
32095            TagpathSearchOpts::default(),
32096        );
32097        assert!(result.is_ok());
32098    }
32099
32100    #[test]
32101    fn explain_cmd_tabular_runs_ok() {
32102        let dir = setup_graph_index();
32103        let result = cmd_explain(
32104            "main",
32105            dir.path(),
32106            None,
32107            15,
32108            false,
32109            false,
32110            false,
32111            false,
32112            false,
32113            true,
32114            false,
32115            false,
32116        );
32117        assert!(result.is_ok());
32118    }
32119
32120    #[test]
32121    fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
32122        // #gdbcacheprove: .agent-doc runtime markdown (snapshots, baselines, archives,
32123        // session docs, runtime logs) must not contribute to the source watermark, or
32124        // every agent-doc cycle would invalidate the graph-db backend-eval cache and
32125        // force a full rebuild on the next run.
32126        let cases = [
32127            ".agent-doc",
32128            ".agent-doc/snapshots/abc.md",
32129            ".agent-doc/baselines/abc.md",
32130            ".agent-doc/archives/2026.md",
32131            ".agent-doc/runtime/run.jsonl",
32132            "src/foo/.agent-doc",
32133            "src/foo/.agent-doc/snapshots/x.md",
32134            "./.agent-doc/snapshots/x.md",
32135        ];
32136        for path in cases {
32137            assert!(
32138                traversal_relative_path_is_generated_artifact(path),
32139                "expected `{path}` to be excluded from source watermark"
32140            );
32141        }
32142        // Real source paths must NOT be excluded.
32143        for path in [
32144            "src/main.rs",
32145            "tests/perf_gate.rs",
32146            "fixtures/x.json",
32147            "agent-doc/src/lib.rs", // sibling dir without the leading dot
32148            "src/.agent-doc-helper.rs",
32149        ] {
32150            assert!(
32151                !traversal_relative_path_is_generated_artifact(path),
32152                "expected `{path}` to be included in source watermark"
32153            );
32154        }
32155    }
32156
32157    #[test]
32158    fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
32159        // #cachelookupshift: the conflict-matrix preparation cache key hashes
32160        // file_state snapshot rows + every markdown file under the root. Any
32161        // .tsift/, target/, or .agent-doc/ path slipping past the filter would
32162        // shift the watermark every run because those directories mutate as a
32163        // side effect of running tsift itself. This test locks the artifact
32164        // filter against regressions for each prefix variant
32165        // (bare, root-anchored, nested, and './' leading).
32166        let cases = [
32167            ".tsift",
32168            ".tsift/index.db",
32169            ".tsift/indexes/foo/index.db",
32170            ".tsift/conflict-matrix-cache/inputs/abc.json",
32171            ".tsift/summaries.db",
32172            "src/foo/.tsift",
32173            "src/foo/.tsift/graph.db",
32174            "./.tsift/index.db",
32175            "target",
32176            "target/debug/build/x",
32177            "target/release/tsift",
32178            "src/foo/target/debug/x",
32179            "./target/release/x",
32180        ];
32181        for path in cases {
32182            assert!(
32183                traversal_relative_path_is_generated_artifact(path),
32184                "expected `{path}` to be excluded from source watermark"
32185            );
32186        }
32187        // Look-alike paths must NOT be excluded — only true artifact dirs.
32188        for path in [
32189            "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
32190            "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
32191            "src/tsift-extras/lib.rs",
32192            "tsift/README.md",
32193            "src/targeting.rs",
32194            "src/.tsiftrc",
32195            "src/agent-doc-helper.rs",
32196        ] {
32197            assert!(
32198                !traversal_relative_path_is_generated_artifact(path),
32199                "expected `{path}` to be included in source watermark"
32200            );
32201        }
32202    }
32203
32204    #[test]
32205    fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
32206        // #cachelookupshift: the conflict-matrix preparation cache only hits
32207        // when traversal_source_watermark returns the same hash for two
32208        // consecutive calls on identical source state. Lock that invariant so
32209        // a future change that folds wall-clock time, a directory mtime, or
32210        // any other non-content input into the hash trips this test before
32211        // regressing the preparation_cache_lookup hit rate. We exercise the
32212        // session_only=true path with a hinted markdown file so the test does
32213        // not need a full index DB to drive the index-snapshot branch.
32214        let dir = tempfile::tempdir().unwrap();
32215        let root = dir.path();
32216        std::fs::create_dir_all(root.join("src")).unwrap();
32217        std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
32218        let hint = root.join("README.md");
32219        std::fs::write(&hint, "# stable\n").unwrap();
32220        // Add a generated-artifact directory that must NOT affect the watermark.
32221        std::fs::create_dir_all(root.join(".tsift")).unwrap();
32222        std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
32223        std::fs::create_dir_all(root.join("target/debug")).unwrap();
32224        std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
32225
32226        let first = traversal_source_watermark(root, &hint, None, true)
32227            .expect("first watermark call must succeed")
32228            .expect("first watermark must produce a hash for hinted markdown");
32229        let second = traversal_source_watermark(root, &hint, None, true)
32230            .expect("second watermark call must succeed")
32231            .expect("second watermark must produce a hash for hinted markdown");
32232        assert_eq!(
32233            first, second,
32234            "watermark must be identical across back-to-back invocations on a quiescent root"
32235        );
32236
32237        // Mutating a generated-artifact file must NOT shift the hash.
32238        std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
32239        std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
32240        let third = traversal_source_watermark(root, &hint, None, true)
32241            .expect("third watermark call must succeed")
32242            .expect("third watermark must produce a hash for hinted markdown");
32243        assert_eq!(
32244            first, third,
32245            "watermark must ignore mutations under .tsift/ and target/"
32246        );
32247
32248        // Mutating the hinted markdown file MUST shift the hash so the
32249        // preparation cache invalidates correctly when user state changes.
32250        // Sleep briefly to push the file mtime past the original even on
32251        // coarse-resolution filesystems.
32252        std::thread::sleep(std::time::Duration::from_millis(20));
32253        std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
32254        let fourth = traversal_source_watermark(root, &hint, None, true)
32255            .expect("fourth watermark call must succeed")
32256            .expect("fourth watermark must produce a hash for hinted markdown");
32257        assert_ne!(
32258            first, fourth,
32259            "watermark must invalidate when the hinted markdown file changes"
32260        );
32261    }
32262
32263    #[test]
32264    fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
32265        // #gcachemiss: full-projection cache keys must not miss just because the
32266        // SQLite summary cache file header or mtime churned. Only the semantic rows
32267        // that feed traversal projection should participate in the source watermark.
32268        let dir = tempfile::tempdir().unwrap();
32269        let root = dir.path();
32270        std::fs::write(root.join("README.md"), "# stable\n").unwrap();
32271        let summaries_db_path = root.join(".tsift/summaries.db");
32272        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
32273        let mut summary = summarize::Summary {
32274            id: 0,
32275            symbol_name: "main".to_string(),
32276            file_path: "src/main.rs".to_string(),
32277            content_hash: "hash-main".to_string(),
32278            summary: "main wires the CLI".to_string(),
32279            entities: Some(vec![summarize::Entity {
32280                name: "Cli".to_string(),
32281                kind: "type".to_string(),
32282                description: "Command-line interface".to_string(),
32283            }]),
32284            relationships: None,
32285            concept_labels: Some(vec!["cli".to_string()]),
32286            extracted_at: "1700000000".to_string(),
32287            model: "test-model".to_string(),
32288            tokens_input: Some(10),
32289            tokens_output: Some(5),
32290        };
32291        summary_db.insert(&summary).unwrap();
32292        drop(summary_db);
32293
32294        let hint = root.join("README.md");
32295        let first = traversal_source_watermark(root, &hint, None, true)
32296            .expect("first watermark call must succeed")
32297            .expect("first watermark must produce a hash");
32298
32299        std::thread::sleep(std::time::Duration::from_millis(20));
32300        let conn = Connection::open(&summaries_db_path).unwrap();
32301        conn.pragma_update(None, "user_version", 1).unwrap();
32302        conn.pragma_update(None, "user_version", 0).unwrap();
32303        drop(conn);
32304
32305        let second = traversal_source_watermark(root, &hint, None, true)
32306            .expect("second watermark call must succeed")
32307            .expect("second watermark must produce a hash");
32308        assert_eq!(
32309            first, second,
32310            "metadata-only summaries.db churn must not invalidate the source watermark"
32311        );
32312
32313        summary.entities = Some(vec![summarize::Entity {
32314            name: "GraphCache".to_string(),
32315            kind: "type".to_string(),
32316            description: "Stable full-projection cache input".to_string(),
32317        }]);
32318        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
32319        summary_db.delete_by_file("src/main.rs").unwrap();
32320        summary_db.insert(&summary).unwrap();
32321        drop(summary_db);
32322
32323        let third = traversal_source_watermark(root, &hint, None, true)
32324            .expect("third watermark call must succeed")
32325            .expect("third watermark must produce a hash");
32326        assert_ne!(
32327            first, third,
32328            "semantic summary row changes must invalidate the source watermark"
32329        );
32330    }
32331
32332    #[test]
32333    fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
32334        // #gfullhot: backend-eval full-projection cache keys should be based on
32335        // the indexed graph inputs, not file_state mtimes. Touching a source file
32336        // without changing extracted symbols/call edges must still hit the cache.
32337        let dir = tempfile::tempdir().unwrap();
32338        let root = dir.path();
32339        std::fs::create_dir_all(root.join("src")).unwrap();
32340        std::fs::create_dir_all(root.join(".tsift")).unwrap();
32341        let source = root.join("src/lib.rs");
32342        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
32343        std::fs::write(&source, source_body).unwrap();
32344        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32345        db.rebuild(root).unwrap();
32346        drop(db);
32347
32348        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
32349            .unwrap()
32350            .value;
32351        std::thread::sleep(std::time::Duration::from_millis(20));
32352        std::fs::write(&source, source_body).unwrap();
32353        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32354        db.apply_changes(root).unwrap();
32355        drop(db);
32356
32357        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
32358            .unwrap()
32359            .value;
32360        assert_eq!(
32361            first, second,
32362            "mtime-only source index churn must not invalidate the full-projection cache"
32363        );
32364    }
32365
32366    #[test]
32367    fn full_projection_source_watermark_ignores_session_markdown_churn() {
32368        // #gfullhot: the full-projection performance cache isolates code graph
32369        // and semantic-summary inputs. Current session evidence is measured by
32370        // the bounded real dataset, so unrelated task-doc edits must not force a
32371        // million-row full-projection rebuild.
32372        let dir = tempfile::tempdir().unwrap();
32373        let root = dir.path();
32374        std::fs::create_dir_all(root.join("src")).unwrap();
32375        std::fs::create_dir_all(root.join("tasks/software")).unwrap();
32376        std::fs::create_dir_all(root.join(".tsift")).unwrap();
32377        std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
32378        let task_doc = root.join("tasks/software/tsift.md");
32379        std::fs::write(
32380            &task_doc,
32381            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
32382        )
32383        .unwrap();
32384        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32385        db.rebuild(root).unwrap();
32386        drop(db);
32387
32388        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
32389            .unwrap()
32390            .value;
32391        std::fs::write(
32392            &task_doc,
32393            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
32394        )
32395        .unwrap();
32396        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
32397            .unwrap()
32398            .value;
32399        assert_eq!(
32400            first, second,
32401            "session markdown churn must not invalidate the full-projection code/summary cache"
32402        );
32403    }
32404
32405    #[test]
32406    fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
32407        // #gfullhot: once a full-project projection is cached, repeated samples
32408        // with unchanged graph inputs must report zero source_graph_build and
32409        // projection_rows work even if indexed file mtimes changed.
32410        let dir = tempfile::tempdir().unwrap();
32411        let root = dir.path();
32412        std::fs::create_dir_all(root.join("src")).unwrap();
32413        std::fs::create_dir_all(root.join(".tsift")).unwrap();
32414        let source = root.join("src/lib.rs");
32415        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
32416        std::fs::write(&source, source_body).unwrap();
32417        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32418        db.rebuild(root).unwrap();
32419        drop(db);
32420
32421        let (_projection, _warnings, _phases, first_stats) =
32422            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
32423        assert!(
32424            !first_stats.hit,
32425            "the first full-projection run should populate the cache"
32426        );
32427
32428        std::thread::sleep(std::time::Duration::from_millis(20));
32429        std::fs::write(&source, source_body).unwrap();
32430        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32431        db.apply_changes(root).unwrap();
32432        drop(db);
32433
32434        let (_projection, _warnings, phases, second_stats) =
32435            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
32436        assert!(second_stats.hit, "mtime-only churn should still cache-hit");
32437        let source_graph_build = phases
32438            .iter()
32439            .find(|phase| phase.name == "full_projection.source_graph_build")
32440            .expect("cache hit must report source_graph_build");
32441        let projection_rows = phases
32442            .iter()
32443            .find(|phase| phase.name == "full_projection.projection_rows")
32444            .expect("cache hit must report projection_rows");
32445        assert_eq!(source_graph_build.duration_micros, 0);
32446        assert_eq!(projection_rows.duration_micros, 0);
32447    }
32448
32449    #[test]
32450    fn build_token_capped_preview_within_cap() {
32451        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 2", "}"];
32452        let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
32453        assert!(!capped.was_capped);
32454        assert_eq!(capped.preview.len(), 3);
32455        assert_eq!(capped.capped_end, 3);
32456    }
32457
32458    #[test]
32459    fn build_token_capped_preview_truncates_long_body() {
32460        let owned: Vec<String> = (0..200)
32461            .map(|i| format!("    let line_{i} = {i};"))
32462            .collect();
32463        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
32464        let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
32465        assert!(capped.was_capped);
32466        assert!(capped.preview.len() < 200);
32467        assert!(capped.capped_end < 200);
32468        assert!(!capped.preview.is_empty());
32469    }
32470
32471    #[test]
32472    fn build_token_capped_preview_respects_start_offset() {
32473        let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
32474        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
32475        let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
32476        assert!(capped.was_capped);
32477        assert!(capped.capped_end >= 50);
32478        assert!(capped.capped_end < 100);
32479        assert_eq!(capped.preview[0].line, 50);
32480    }
32481
32482    #[test]
32483    fn response_budget_body_token_cap_defaults() {
32484        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
32485        assert_eq!(budget.body_token_cap(), 1500);
32486
32487        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
32488        assert_eq!(budget.body_token_cap(), 500);
32489
32490        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
32491        assert_eq!(budget.body_token_cap(), 3000);
32492    }
32493
32494    #[test]
32495    fn build_token_capped_preview_empty_input() {
32496        let lines: Vec<&str> = vec![];
32497        let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
32498        assert!(!capped.was_capped);
32499        assert!(capped.preview.is_empty());
32500    }
32501
32502    #[test]
32503    fn build_token_capped_preview_single_long_line_fits() {
32504        let lines: Vec<&str> = vec!["short"];
32505        let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
32506        assert!(!capped.was_capped);
32507        assert_eq!(capped.preview.len(), 1);
32508        assert_eq!(capped.capped_end, 1);
32509    }
32510
32511    #[test]
32512    fn edge_index_replaces_from_id_to_id_with_positions() {
32513        let input = serde_json::json!({
32514            "nodes": [
32515                {"id": "symbol:src/lib.rs:foo"},
32516                {"id": "symbol:src/lib.rs:bar"},
32517                {"id": "symbol:src/lib.rs:baz"}
32518            ],
32519            "edges": [
32520                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
32521                {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
32522            ]
32523        });
32524        let result = edge_index_transform(input);
32525        let edges = result.get("edges").unwrap().as_array().unwrap();
32526        assert_eq!(edges.len(), 2);
32527        assert_eq!(edges[0]["from"], 0);
32528        assert_eq!(edges[0]["to"], 1);
32529        assert_eq!(edges[1]["from"], 1);
32530        assert_eq!(edges[1]["to"], 2);
32531        assert!(edges[0].get("from_id").is_none());
32532        assert!(edges[0].get("to_id").is_none());
32533    }
32534
32535    #[test]
32536    fn edge_index_preserves_unresolved_ids_as_strings() {
32537        let input = serde_json::json!({
32538            "nodes": [{"id": "symbol:src/lib.rs:foo"}],
32539            "edges": [
32540                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
32541            ]
32542        });
32543        let result = edge_index_transform(input);
32544        let edge = &result["edges"][0];
32545        assert_eq!(edge["from"], 0);
32546        assert_eq!(edge["to_id"], "symbol:other.rs:missing");
32547    }
32548
32549    #[test]
32550    fn edge_index_noop_without_nodes_and_edges() {
32551        let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
32552        let result = edge_index_transform(input);
32553        assert_eq!(result["report"]["entries"][0]["from_id"], "a");
32554    }
32555}
32556
32557// --- SQL introspection ---
32558
32559#[derive(Serialize)]
32560struct TableInfo {
32561    name: String,
32562    columns: Vec<ColumnInfo>,
32563    row_count: i64,
32564}
32565
32566#[derive(Serialize)]
32567struct ColumnInfo {
32568    name: String,
32569    #[serde(rename = "type")]
32570    col_type: String,
32571    notnull: bool,
32572    pk: bool,
32573    #[serde(skip_serializing_if = "Option::is_none")]
32574    default_value: Option<String>,
32575}
32576
32577/// Open a SQLite connection (read-only).
32578pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
32579    let conn = Connection::open_with_flags(
32580        path,
32581        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
32582    )
32583    .with_context(|| format!("opening database: {}", path.display()))?;
32584    Ok(conn)
32585}
32586
32587/// List all user tables with column metadata and row counts.
32588pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
32589    let mut stmt = conn.prepare(
32590        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
32591    )?;
32592    let table_names: Vec<String> = stmt
32593        .query_map([], |row| row.get(0))?
32594        .collect::<std::result::Result<Vec<_>, _>>()?;
32595
32596    let mut tables = Vec::new();
32597    for tbl in table_names {
32598        let columns = table_columns(conn, &tbl)?;
32599        let row_count: i64 =
32600            conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
32601                row.get(0)
32602            })?;
32603        tables.push(TableInfo {
32604            name: tbl,
32605            columns,
32606            row_count,
32607        });
32608    }
32609    Ok(tables)
32610}
32611
32612/// Get column metadata for a single table.
32613pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
32614    let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
32615    let cols = stmt
32616        .query_map([], |row| {
32617            Ok(ColumnInfo {
32618                name: row.get(1)?,
32619                col_type: row.get::<_, String>(2).unwrap_or_default(),
32620                notnull: row.get::<_, bool>(3).unwrap_or(false),
32621                pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
32622                default_value: row.get(4)?,
32623            })
32624        })?
32625        .collect::<std::result::Result<Vec<_>, _>>()?;
32626    Ok(cols)
32627}
32628
32629/// Execute an arbitrary SQL query and return rows as JSON values.
32630pub(crate) fn execute_query(
32631    conn: &Connection,
32632    sql: &str,
32633) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
32634    let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
32635    let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
32636    let col_count = col_names.len();
32637
32638    let mut rows = Vec::new();
32639    let mut query_rows = stmt.query([])?;
32640    while let Some(row) = query_rows.next()? {
32641        let mut vals = Vec::with_capacity(col_count);
32642        for i in 0..col_count {
32643            let val = match row.get_ref(i)? {
32644                rusqlite::types::ValueRef::Null => serde_json::Value::Null,
32645                rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
32646                rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
32647                rusqlite::types::ValueRef::Text(s) => {
32648                    serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
32649                }
32650                rusqlite::types::ValueRef::Blob(b) => {
32651                    serde_json::Value::String(format!("<blob {} bytes>", b.len()))
32652                }
32653            };
32654            vals.push(val);
32655        }
32656        rows.push(vals);
32657    }
32658    Ok((col_names, rows))
32659}
32660
32661#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32662enum DigestRunnerKind {
32663    Test,
32664    Log,
32665}
32666
32667impl DigestRunnerKind {
32668    fn parse(raw: &str) -> Result<Self> {
32669        match raw.trim().to_ascii_lowercase().as_str() {
32670            "test" => Ok(Self::Test),
32671            "log" => Ok(Self::Log),
32672            other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
32673        }
32674    }
32675
32676    fn as_str(self) -> &'static str {
32677        match self {
32678            Self::Test => "test",
32679            Self::Log => "log",
32680        }
32681    }
32682}
32683
32684/// Simple shell word splitting (handles single and double quotes).
32685pub(crate) fn shell_split(s: &str) -> Vec<&str> {
32686    let mut parts = Vec::new();
32687    let mut i = 0;
32688    let bytes = s.as_bytes();
32689    while i < bytes.len() {
32690        // Skip whitespace
32691        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
32692            i += 1;
32693        }
32694        if i >= bytes.len() {
32695            break;
32696        }
32697        let start = i;
32698        if bytes[i] == b'"' || bytes[i] == b'\'' {
32699            let quote = bytes[i];
32700            i += 1;
32701            while i < bytes.len() && bytes[i] != quote {
32702                i += 1;
32703            }
32704            if i < bytes.len() {
32705                i += 1; // closing quote
32706            }
32707        } else {
32708            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
32709                i += 1;
32710            }
32711        }
32712        parts.push(&s[start..i]);
32713    }
32714    parts
32715}
32716
32717/// Quote a string for shell if it contains special characters.
32718pub(crate) fn shell_quote(s: &str) -> String {
32719    // Strip existing quotes
32720    let unquoted =
32721        if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
32722            &s[1..s.len() - 1]
32723        } else {
32724            s
32725        };
32726
32727    if unquoted
32728        .chars()
32729        .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
32730    {
32731        format!("\"{}\"", unquoted)
32732    } else {
32733        format!(
32734            "\"{}\"",
32735            unquoted.replace('\\', "\\\\").replace('"', "\\\"")
32736        )
32737    }
32738}
32739
32740fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
32741    sift::SearchCoverageSnapshot {
32742        mode: sift::SearchCoverageMode::Sealed,
32743        total_sector_count: 0,
32744        mounted_sector_count: 0,
32745        reused_sector_count: 0,
32746        dirty_sector_count: 0,
32747        completed_dirty_sector_count: 0,
32748        rebuilding_sector_count: 0,
32749        resumed_sector_count: 0,
32750        active_rebuild: None,
32751    }
32752}
32753
32754fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
32755    let total_sector_count = responses
32756        .iter()
32757        .map(|response| response.coverage.total_sector_count)
32758        .sum();
32759    let mounted_sector_count = responses
32760        .iter()
32761        .map(|response| response.coverage.mounted_sector_count)
32762        .sum();
32763    let reused_sector_count = responses
32764        .iter()
32765        .map(|response| response.coverage.reused_sector_count)
32766        .sum();
32767    let dirty_sector_count = responses
32768        .iter()
32769        .map(|response| response.coverage.dirty_sector_count)
32770        .sum();
32771    let completed_dirty_sector_count = responses
32772        .iter()
32773        .map(|response| response.coverage.completed_dirty_sector_count)
32774        .sum();
32775    let rebuilding_sector_count = responses
32776        .iter()
32777        .map(|response| response.coverage.rebuilding_sector_count)
32778        .sum();
32779    let resumed_sector_count = responses
32780        .iter()
32781        .map(|response| response.coverage.resumed_sector_count)
32782        .sum();
32783
32784    let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
32785        sift::SearchCoverageMode::Sealed
32786    } else if completed_dirty_sector_count > 0
32787        || rebuilding_sector_count > 0
32788        || resumed_sector_count > 0
32789    {
32790        sift::SearchCoverageMode::Converging
32791    } else {
32792        sift::SearchCoverageMode::Frontier
32793    };
32794
32795    sift::SearchCoverageSnapshot {
32796        mode,
32797        total_sector_count,
32798        mounted_sector_count,
32799        reused_sector_count,
32800        dirty_sector_count,
32801        completed_dirty_sector_count,
32802        rebuilding_sector_count,
32803        resumed_sector_count,
32804        active_rebuild: responses
32805            .iter()
32806            .find_map(|response| response.coverage.active_rebuild.clone()),
32807    }
32808}
32809
32810fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
32811    sift::SearchResponse {
32812        strategy: strategy.to_string(),
32813        root: root.display().to_string(),
32814        indexed_artifacts: 0,
32815        skipped_artifacts: 0,
32816        coverage: empty_search_coverage(),
32817        hits: Vec::new(),
32818    }
32819}
32820
32821fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
32822    for hit in &mut response.hits {
32823        let path = Path::new(&hit.path);
32824        if path.is_relative() {
32825            hit.path = search_root.join(path).display().to_string();
32826        }
32827    }
32828}
32829
32830fn merge_search_responses(
32831    root: &Path,
32832    strategy: &str,
32833    limit: usize,
32834    responses: Vec<sift::SearchResponse>,
32835) -> sift::SearchResponse {
32836    let indexed_artifacts = responses
32837        .iter()
32838        .map(|response| response.indexed_artifacts)
32839        .sum();
32840    let skipped_artifacts = responses
32841        .iter()
32842        .map(|response| response.skipped_artifacts)
32843        .sum();
32844    let coverage = if responses.is_empty() {
32845        empty_search_coverage()
32846    } else {
32847        aggregate_search_coverage(&responses)
32848    };
32849    let mut hits: Vec<sift::SearchHit> = responses
32850        .into_iter()
32851        .flat_map(|response| response.hits)
32852        .collect();
32853    hits.sort_by(|left, right| {
32854        right
32855            .score
32856            .partial_cmp(&left.score)
32857            .unwrap_or(Ordering::Equal)
32858            .then_with(|| left.path.cmp(&right.path))
32859            .then_with(|| left.location.cmp(&right.location))
32860    });
32861    hits.truncate(limit);
32862    for (rank, hit) in hits.iter_mut().enumerate() {
32863        hit.rank = rank + 1;
32864    }
32865
32866    sift::SearchResponse {
32867        strategy: strategy.to_string(),
32868        root: root.display().to_string(),
32869        indexed_artifacts,
32870        skipped_artifacts,
32871        coverage,
32872        hits,
32873    }
32874}
32875
32876pub(crate) fn federated_sift_search(
32877    root: &Path,
32878    cache_dir: &Path,
32879    query: &str,
32880    limit: usize,
32881    timeout_secs: u64,
32882    strategy: &str,
32883) -> Result<sift::SearchResponse> {
32884    let targets = resolve_search_index_targets(root, root, None, true)?;
32885    if targets.is_empty() {
32886        if config::Config::submodule_dirs(root)?.is_empty() {
32887            return run_search_with_timeout(
32888                root,
32889                cache_dir,
32890                query,
32891                limit,
32892                timeout_secs,
32893                strategy,
32894                &[],
32895            );
32896        }
32897        return Ok(empty_search_response(root, strategy));
32898    }
32899
32900    let mut responses = Vec::with_capacity(targets.len());
32901    for target in &targets {
32902        let mut response = run_search_with_timeout(
32903            &target.source_root,
32904            cache_dir,
32905            query,
32906            limit,
32907            timeout_secs,
32908            strategy,
32909            std::slice::from_ref(target),
32910        )?;
32911        absolutize_search_hit_paths(&mut response, &target.source_root);
32912        response.root = root.display().to_string();
32913        responses.push(response);
32914    }
32915
32916    Ok(merge_search_responses(root, strategy, limit, responses))
32917}
32918
32919/// Federated symbol search across every scoped `.tsift/indexes/<scope>/index.db`
32920/// in the workspace. Per-scope tagpath annotation runs inside the per-scope
32921/// loop so each scope's adapter resolves against its own `.naming.toml` /
32922/// `.naming/index.json` (the workspace root usually has no tagpath of its
32923/// own). The merged `TagpathAnnotationDiagnostic` reports `loaded=true` when
32924/// at least one scope loaded, and `stale=true` with the first stale reason
32925/// when any scope was stale.
32926pub(crate) fn federated_symbol_search(
32927    root: &std::path::Path,
32928    query: &str,
32929    limit: usize,
32930    tagpath_opts: &TagpathSearchOpts,
32931) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
32932    let cfg = config::Config::load(root)?;
32933    let submodules = config::Config::submodule_dirs(root)?;
32934    let mut all_hits: Vec<index::SymbolHit> = Vec::new();
32935    let mut combined = TagpathAnnotationDiagnostic::default();
32936    for scope in &submodules {
32937        if !cfg.federation_for_scope(scope) {
32938            continue;
32939        }
32940        let db_path = cfg.db_path_for(root, &scope.id);
32941        if !db_path.exists() {
32942            continue;
32943        }
32944        let db = index::IndexDb::open_read_only(&db_path)?;
32945        let mut hits = db.symbol_search(query, limit)?;
32946        let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
32947        combined.loaded |= diag.loaded;
32948        if diag.stale && !combined.stale {
32949            combined.stale = true;
32950            combined.reason = diag.reason;
32951        }
32952        all_hits.append(&mut hits);
32953    }
32954    all_hits.sort_by(|a, b| {
32955        b.score
32956            .partial_cmp(&a.score)
32957            .unwrap_or(std::cmp::Ordering::Equal)
32958    });
32959    all_hits.truncate(limit);
32960    Ok((all_hits, combined))
32961}
32962
32963#[derive(Debug, Deserialize)]
32964#[serde(tag = "type", rename_all = "lowercase")]
32965enum RipgrepJsonEvent {
32966    Match {
32967        data: RipgrepMatchData,
32968    },
32969    #[serde(other)]
32970    Other,
32971}
32972
32973#[derive(Debug, Deserialize)]
32974struct RipgrepMatchData {
32975    path: RipgrepTextField,
32976    lines: RipgrepTextField,
32977    line_number: Option<usize>,
32978}
32979
32980#[derive(Debug, Deserialize)]
32981struct RipgrepTextField {
32982    text: Option<String>,
32983}
32984
32985pub(crate) fn federated_exact_search(
32986    root: &Path,
32987    query: &str,
32988    limit: usize,
32989    timeout_secs: u64,
32990) -> Result<sift::SearchResponse> {
32991    let cfg = config::Config::load(root)?;
32992    let mut responses = Vec::new();
32993    for scope in config::Config::submodule_dirs(root)? {
32994        if !cfg.federation_for_scope(&scope) {
32995            continue;
32996        }
32997        let mut response =
32998            run_exact_search_with_timeout(&scope.source_root, query, limit, timeout_secs)?;
32999        absolutize_search_hit_paths(&mut response, &scope.source_root);
33000        response.root = root.display().to_string();
33001        responses.push(response);
33002    }
33003
33004    Ok(merge_search_responses(root, "exact", limit, responses))
33005}
33006
33007pub(crate) fn run_sift_search(
33008    search_path: &Path,
33009    cache_dir: &Path,
33010    query: &str,
33011    limit: usize,
33012    strategy: &str,
33013) -> Result<sift::SearchResponse> {
33014    let engine = Sift::builder().with_cache_dir(cache_dir).build();
33015    let options = SearchOptions::default()
33016        .with_limit(limit)
33017        .with_strategy(strategy.to_string());
33018    let input = SearchInput::new(search_path, query).with_options(options);
33019    engine.search(input).context("sift search failed")
33020}
33021
33022fn exact_search_timeout_message(timeout_secs: u64) -> String {
33023    format!(
33024        "tsift search timed out after {}s (strategy: exact). \
33025         Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
33026        timeout_secs
33027    )
33028}
33029
33030fn exact_search_command(search_path: &Path, query: &str) -> Command {
33031    let mut command = Command::new("rg");
33032    command
33033        .arg("--json")
33034        .arg("--fixed-strings")
33035        .arg("--line-number")
33036        .arg("--hidden")
33037        .arg("--")
33038        .arg(query)
33039        .arg(search_path);
33040    command
33041}
33042
33043fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
33044    let observed_unix_secs = SystemTime::now()
33045        .duration_since(UNIX_EPOCH)
33046        .unwrap_or_default()
33047        .as_secs() as i64;
33048    let modified_unix_secs = fs::metadata(path)
33049        .ok()
33050        .and_then(|metadata| metadata.modified().ok())
33051        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
33052        .map(|duration| duration.as_secs() as i64);
33053    sift::ArtifactFreshness {
33054        observed_unix_secs,
33055        modified_unix_secs,
33056    }
33057}
33058
33059fn parse_exact_search_output(
33060    search_path: &Path,
33061    limit: usize,
33062    raw: &str,
33063) -> Result<sift::SearchResponse> {
33064    if limit == 0 {
33065        return Ok(sift::SearchResponse {
33066            strategy: "exact".to_string(),
33067            root: search_path.display().to_string(),
33068            indexed_artifacts: 0,
33069            skipped_artifacts: 0,
33070            coverage: empty_search_coverage(),
33071            hits: Vec::new(),
33072        });
33073    }
33074
33075    let mut hits = Vec::new();
33076    for line in raw.lines() {
33077        let event: RipgrepJsonEvent =
33078            serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
33079        let RipgrepJsonEvent::Match { data } = event else {
33080            continue;
33081        };
33082        let Some(path_text) = data.path.text else {
33083            continue;
33084        };
33085        let Some(lines_text) = data.lines.text else {
33086            continue;
33087        };
33088        let path = PathBuf::from(path_text);
33089        let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
33090        let rank = hits.len() + 1;
33091        hits.push(sift::SearchHit {
33092            artifact_id: format!(
33093                "exact:{}:{}:{}",
33094                path.display(),
33095                data.line_number.unwrap_or(0),
33096                rank
33097            ),
33098            artifact_kind: sift::ContextArtifactKind::File,
33099            path: path.display().to_string(),
33100            rank,
33101            score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
33102            confidence: sift::ScoreConfidence::High,
33103            location: data.line_number.map(|line| format!("line {}", line)),
33104            snippet: snippet.clone(),
33105            provenance: sift::ArtifactProvenance {
33106                adapter: sift::AcquisitionAdapterKind::FileSystem,
33107                source: "ripgrep -F".to_string(),
33108                synthetic: false,
33109            },
33110            freshness: exact_search_file_timestamp(&path),
33111            budget: sift::ArtifactBudget::from_text(&snippet, 1),
33112        });
33113        if hits.len() >= limit {
33114            break;
33115        }
33116    }
33117
33118    Ok(sift::SearchResponse {
33119        strategy: "exact".to_string(),
33120        root: search_path.display().to_string(),
33121        indexed_artifacts: hits.len(),
33122        skipped_artifacts: 0,
33123        coverage: empty_search_coverage(),
33124        hits,
33125    })
33126}
33127
33128fn exact_search_response_from_process(
33129    search_path: &Path,
33130    limit: usize,
33131    status: std::process::ExitStatus,
33132    stdout: &[u8],
33133    stderr: &[u8],
33134) -> Result<sift::SearchResponse> {
33135    if !status.success() && status.code() != Some(1) {
33136        let message = String::from_utf8_lossy(stderr);
33137        let trimmed = message.trim();
33138        if trimmed.is_empty() {
33139            bail!("ripgrep exact search exited with status {}", status);
33140        }
33141        bail!("{}", trimmed);
33142    }
33143
33144    let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
33145    parse_exact_search_output(search_path, limit, &raw)
33146}
33147
33148fn run_exact_search(search_path: &Path, query: &str, limit: usize) -> Result<sift::SearchResponse> {
33149    let output = exact_search_command(search_path, query)
33150        .output()
33151        .context("running exact search with ripgrep")?;
33152    exact_search_response_from_process(
33153        search_path,
33154        limit,
33155        output.status,
33156        &output.stdout,
33157        &output.stderr,
33158    )
33159}
33160
33161pub(crate) fn run_exact_search_with_timeout(
33162    search_path: &Path,
33163    query: &str,
33164    limit: usize,
33165    timeout_secs: u64,
33166) -> Result<sift::SearchResponse> {
33167    if timeout_secs == 0 {
33168        return run_exact_search(search_path, query, limit);
33169    }
33170
33171    let mut child = exact_search_command(search_path, query)
33172        .stdin(Stdio::null())
33173        .stdout(Stdio::piped())
33174        .stderr(Stdio::piped())
33175        .spawn()
33176        .context("spawning timed exact search worker")?;
33177
33178    let timeout = Duration::from_secs(timeout_secs);
33179    let status = wait_for_child_exit(&mut child, timeout)
33180        .context("waiting for timed exact search worker")?;
33181    if status.is_none() {
33182        let _ = child.kill();
33183        let _ = child.wait();
33184        bail!("{}", exact_search_timeout_message(timeout_secs));
33185    }
33186
33187    let status = status.unwrap();
33188    let stdout = read_child_stdout(&mut child)?;
33189    let stderr = read_child_stderr(&mut child)?;
33190    exact_search_response_from_process(
33191        search_path,
33192        limit,
33193        status,
33194        stdout.as_bytes(),
33195        stderr.as_bytes(),
33196    )
33197}
33198
33199pub(crate) fn run_search_with_timeout(
33200    search_path: &Path,
33201    cache_dir: &Path,
33202    query: &str,
33203    limit: usize,
33204    timeout_secs: u64,
33205    strategy: &str,
33206    search_targets: &[SearchIndexTarget],
33207) -> Result<sift::SearchResponse> {
33208    if timeout_secs == 0 {
33209        return run_sift_search(search_path, cache_dir, query, limit, strategy);
33210    }
33211
33212    let output_path = next_search_worker_output_path();
33213    let mut child = Command::new(
33214        std::env::current_exe().context("resolving tsift executable for timed search")?,
33215    )
33216    .arg("__search-worker")
33217    .arg("--path")
33218    .arg(search_path)
33219    .arg("--cache-dir")
33220    .arg(cache_dir)
33221    .arg("--query")
33222    .arg(query)
33223    .arg("--limit")
33224    .arg(limit.to_string())
33225    .arg("--strategy")
33226    .arg(strategy)
33227    .arg("--output")
33228    .arg(&output_path)
33229    .stdin(Stdio::null())
33230    .stdout(Stdio::null())
33231    .stderr(Stdio::piped())
33232    .spawn()
33233    .context("spawning timed sift search worker")?;
33234
33235    let timeout = Duration::from_secs(timeout_secs);
33236    let status =
33237        wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
33238    if status.is_none() {
33239        let _ = child.kill();
33240        let _ = child.wait();
33241        let _ = fs::remove_file(&output_path);
33242        bail!(
33243            "{}",
33244            search_timeout_message(timeout_secs, strategy, search_targets)?
33245        );
33246    }
33247
33248    let status = status.unwrap();
33249    let stderr = read_child_stderr(&mut child)?;
33250    if !status.success() {
33251        let _ = fs::remove_file(&output_path);
33252        let message = stderr.trim();
33253        if message.is_empty() {
33254            bail!("sift search worker exited with status {}", status);
33255        }
33256        bail!("{}", message);
33257    }
33258
33259    let raw = fs::read_to_string(&output_path)
33260        .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
33261    let _ = fs::remove_file(&output_path);
33262    serde_json::from_str(&raw).context("parsing search worker output")
33263}
33264
33265fn next_search_worker_output_path() -> PathBuf {
33266    let stamp = SystemTime::now()
33267        .duration_since(UNIX_EPOCH)
33268        .unwrap_or_default()
33269        .as_nanos();
33270    std::env::temp_dir().join(format!(
33271        "tsift-search-{}-{}.json",
33272        std::process::id(),
33273        stamp
33274    ))
33275}
33276
33277fn wait_for_child_exit(
33278    child: &mut std::process::Child,
33279    timeout: Duration,
33280) -> Result<Option<std::process::ExitStatus>> {
33281    let started = Instant::now();
33282    loop {
33283        if let Some(status) = child.try_wait()? {
33284            return Ok(Some(status));
33285        }
33286        if started.elapsed() >= timeout {
33287            return Ok(None);
33288        }
33289        let remaining = timeout.saturating_sub(started.elapsed());
33290        std::thread::sleep(remaining.min(Duration::from_millis(10)));
33291    }
33292}
33293
33294fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
33295    let mut stderr = String::new();
33296    if let Some(mut pipe) = child.stderr.take() {
33297        pipe.read_to_string(&mut stderr)
33298            .context("reading search worker stderr")?;
33299    }
33300    Ok(stderr)
33301}
33302
33303fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
33304    let mut stdout = String::new();
33305    if let Some(mut pipe) = child.stdout.take() {
33306        pipe.read_to_string(&mut stdout)
33307            .context("reading search worker stdout")?;
33308    }
33309    Ok(stdout)
33310}
33311
33312pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
33313    if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
33314        fs::write(&path, std::process::id().to_string())
33315            .with_context(|| format!("writing search worker pid file: {path}"))?;
33316    }
33317    if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
33318        let delay_ms = ms
33319            .parse::<u64>()
33320            .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
33321        std::thread::sleep(Duration::from_millis(delay_ms));
33322    }
33323    Ok(())
33324}
33325
33326#[cfg(test)]
33327thread_local! {
33328    static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
33329}
33330
33331#[cfg(test)]
33332enum SearchPostPrecheckLockMode {
33333    RollbackJournal,
33334    Wal,
33335}
33336
33337#[cfg(test)]
33338struct SearchPostPrecheckLockHook {
33339    db_path: PathBuf,
33340    mode: SearchPostPrecheckLockMode,
33341}
33342
33343#[cfg(test)]
33344struct SearchPostPrecheckLockGuard;
33345
33346#[cfg(test)]
33347impl Drop for SearchPostPrecheckLockGuard {
33348    fn drop(&mut self) {
33349        SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
33350            hook.borrow_mut().take();
33351        });
33352    }
33353}
33354
33355#[cfg(test)]
33356fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
33357    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
33358}
33359
33360#[cfg(test)]
33361fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
33362    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
33363}
33364
33365#[cfg(test)]
33366fn install_search_post_precheck_lock_hook(
33367    db_path: PathBuf,
33368    mode: SearchPostPrecheckLockMode,
33369) -> SearchPostPrecheckLockGuard {
33370    SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
33371        assert!(
33372            hook.borrow().is_none(),
33373            "search post-precheck lock hook already installed"
33374        );
33375        *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
33376    });
33377    SearchPostPrecheckLockGuard
33378}
33379
33380#[cfg(test)]
33381pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
33382    let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
33383        return Ok(());
33384    };
33385    let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
33386    std::thread::spawn(move || {
33387        let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
33388        match hook.mode {
33389            SearchPostPrecheckLockMode::RollbackJournal => {
33390                conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
33391                    .expect("acquiring rollback-journal hook lock");
33392                fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
33393                    .expect("writing rollback journal marker");
33394            }
33395            SearchPostPrecheckLockMode::Wal => {
33396                conn.execute_batch(
33397                    "PRAGMA journal_mode=WAL;
33398                     PRAGMA wal_autocheckpoint=0;
33399                     CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
33400                     INSERT INTO search_wal_lock_probe DEFAULT VALUES;
33401                     PRAGMA locking_mode=EXCLUSIVE;
33402                     BEGIN EXCLUSIVE;",
33403                )
33404                .expect("acquiring WAL hook lock");
33405                assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
33406            }
33407        }
33408        ready_tx.send(()).expect("signaling search lock hook");
33409        std::thread::sleep(Duration::from_millis(200));
33410        drop(conn);
33411        let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
33412    });
33413    ready_rx
33414        .recv_timeout(Duration::from_secs(1))
33415        .context("waiting for search post-precheck lock hook")?;
33416    Ok(())
33417}
33418
33419#[cfg(not(test))]
33420pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
33421    Ok(())
33422}