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 use rewrite::rewrite_command;
15pub(crate) use rewrite::{apply_rewrite_output_format, execute_rewritten_command, no_rewrite_message};
16pub(crate) use community_detection::{
17    CommunityDetectionReport, annotate_community_members_with_context,
18    community_tagpath_cache_part, community_tagpath_cache_part_for_loaded,
19    detect_communities_cached, file_communities_from_callers,
20    graph_effectiveness_blocked, graph_effectiveness_ready,
21    resolve_tagpath_handle_for_callee_edge, update_community_annotation_diagnostics,
22};
23#[allow(unused_imports)]
24pub(crate) use conflict_matrix::{
25    ConflictMatrixCandidate, ConflictMatrixGraphPreparedInputs,
26    ConflictMatrixPreparedInputs, ConflictMatrixReport,
27    ConflictMatrixSemanticRef, ConflictMatrixSharedPreparationSummary,
28    ConflictMatrixWorkerFeedback, ConflictMatrixWorkerPromptPacket,
29    build_conflict_matrix_report, build_conflict_matrix_report_from_prepared_graph,
30    cmd_conflict_matrix, collect_conflict_matrix_evidence_packets,
31    conflict_matrix_candidate_from_evidence, conflict_matrix_graph_index,
32    conflict_matrix_semantic_ref, conflict_matrix_shared_preparation_summary,
33    conflict_matrix_source_handle, conflict_matrix_target_scoped_graph_snapshot,
34    conflict_matrix_worker_feedback,
35    conflict_risk_label, extract_conflict_target_refs, hash_bytes_hex,
36    is_planner_config_path, normalize_conflict_target,
37    prepare_conflict_matrix_graph_orchestration,
38    prepare_conflict_matrix_inputs, resolve_conflict_matrix_targets,
39    sorted_intersection, sorted_set,
40};
41#[allow(unused_imports)]
42pub(crate) use context_pack::{
43    ContextPackReport, ContextPackSummaryRefPreview,
44    build_context_pack_diff_preview, build_context_pack_log_preview,
45    build_context_pack_report, build_context_pack_report_with_profile,
46    build_context_pack_test_preview, context_pack_status_reminders,
47    exploration_ref_id, materialize_context_pack_exploration_packet,
48    print_context_pack_human,
49};
50pub(crate) use search_budget::{
51    SearchBudgetReportInput,
52    apply_search_facet_filters, build_search_budget_follow_up, build_search_budget_report,
53    print_search_budget_human,
54};
55#[allow(unused_imports)]
56pub(crate) use session_review_budget::{
57    SessionReviewBudgetFailurePreview, SessionReviewBudgetReport,
58    SessionReviewNextContextBudgetReport, SessionReviewNextTokenAction,
59    build_session_review_budget_report, build_session_review_next_context_budget_report,
60    print_session_review_budget_human, print_session_review_next_context_budget_human,
61};
62#[cfg(test)]
63use search_budget::{SearchBudgetReport, search_facet_filters_summary};
64pub(crate) use semantic_edit::{
65    AstSpanPreview, EditBatch, EditResult, EditStatus,
66    MarkdownEmbeddedSymbol, MarkdownSpanMetadata, MetricDigestOptions,
67    SemanticEditVerifyOptions, apply_edit_plan_atomically, build_edit_plan, cmd_edit_intents,
68};
69
70#[cfg(test)]
71use rewrite::{apply_output_cap, effective_rewrite_run_command, resolve_digest_context_path, rewrite_output_cap, OutputCap};
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};
107use output::tagpath::{
108    TagpathAnnotationDiagnostic, TagpathSearchOpts,
109    annotate_communities_with_tagpath, annotate_hits_with_tagpath,
110    annotate_path_nodes_with_tagpath, annotate_stored_edges_with_tagpath,
111    annotate_stored_symbols_with_tagpath,
112};
113#[cfg(test)]
114use output::ResponseBudgetPreset;
115use output::{
116    OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric,
117    ToolEnvelopeSummary, TranscriptArtifactRef,
118};
119use rusqlite::{Connection, OptionalExtension};
120use serde::{Deserialize, Serialize};
121use sift::{SearchInput, SearchOptions, Sift};
122#[cfg(test)]
123use std::cell::RefCell;
124use std::cmp::Ordering;
125use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
126use std::env;
127use std::fs;
128use std::io::{Read as _, Write as _};
129use std::path::{Path, PathBuf};
130use std::process::{Command, Stdio};
131use std::sync::{Mutex, OnceLock};
132use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
133use substrate::{
134    ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
135    GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
136    GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
137    SqliteGraphStore, SqliteProjectionRefresh,
138    TerseGraphNode as SubstrateTerseGraphNode, TerseGraphEdge as SubstrateTerseGraphEdge,
139};
140use tsift_core::{NeighborhoodScoring, RankedNeighborhoodOptions};
141use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
142#[cfg(test)]
143use tsift_agent_doc::session_cost;
144#[cfg(test)]
145use tsift_agent_doc::session_review;
146use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
147use tsift_graph as graph;
148use tsift_index::{config, index, init, multiplicity, walk};
149use tsift_memory::{MemoryEvent, default_memory_db_path, read_memory_events};
150use tsift_quality::{cycle_packet_cache, dci_benchmark, lint, perf_gate, token_gate};
151use tsift_resolution as resolution;
152use tsift_search::{impact, sift};
153use tsift_sqlite as substrate;
154use tsift_status::status;
155use tsift_summarize::summarize;
156#[cfg(feature = "backend-surrealdb")]
157use tsift_surrealdb::SurrealdbGraphStore;
158use tsift_tokensave::TokensaveDb;
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
161pub(crate) enum GraphDbExperimentalBackend {
162    DuckdbDuckpgq,
163    Falkordb,
164    Ladybug,
165    Kuzu,
166    Surrealdb,
167}
168
169#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
170pub(crate) struct SearchFacetFilters {
171    #[serde(skip_serializing_if = "Vec::is_empty", default)]
172    pub(crate) languages: Vec<String>,
173    #[serde(skip_serializing_if = "Vec::is_empty", default)]
174    pub(crate) kinds: Vec<String>,
175    #[serde(skip_serializing_if = "Vec::is_empty", default)]
176    pub(crate) node_kinds: Vec<String>,
177    #[serde(skip_serializing_if = "Vec::is_empty", default)]
178    pub(crate) sections: Vec<String>,
179    #[serde(skip_serializing_if = "Vec::is_empty", default)]
180    pub(crate) parents: Vec<String>,
181    #[serde(skip_serializing_if = "Vec::is_empty", default)]
182    pub(crate) children: Vec<String>,
183    #[serde(skip_serializing_if = "Vec::is_empty", default)]
184    pub(crate) fence_languages: Vec<String>,
185    #[serde(skip_serializing_if = "Vec::is_empty", default)]
186    pub(crate) list_depths: Vec<usize>,
187    #[serde(skip_serializing_if = "Vec::is_empty", default)]
188    pub(crate) heading_levels: Vec<usize>,
189}
190
191impl SearchFacetFilters {
192    pub(crate) fn is_empty(&self) -> bool {
193        self.languages.is_empty()
194            && self.kinds.is_empty()
195            && self.node_kinds.is_empty()
196            && self.sections.is_empty()
197            && self.parents.is_empty()
198            && self.children.is_empty()
199            && self.fence_languages.is_empty()
200            && self.list_depths.is_empty()
201            && self.heading_levels.is_empty()
202    }
203
204    fn needs_ast_context(&self) -> bool {
205        !self.sections.is_empty()
206            || !self.parents.is_empty()
207            || !self.children.is_empty()
208            || !self.fence_languages.is_empty()
209            || !self.list_depths.is_empty()
210            || !self.heading_levels.is_empty()
211    }
212}
213
214#[derive(Serialize)]
215struct GraphDbBackendPromotionGate {
216    status: String,
217    native_adapter_required: bool,
218    required_checks: Vec<String>,
219}
220
221impl GraphDbExperimentalBackend {
222    fn name(self) -> &'static str {
223        match self {
224            Self::DuckdbDuckpgq => "duckdb-duckpgq",
225            Self::Falkordb => "falkordb",
226            Self::Ladybug => "ladybug",
227            Self::Kuzu => "kuzu",
228            Self::Surrealdb => "surrealdb",
229        }
230    }
231
232    fn adapter_label(self) -> &'static str {
233        match self {
234            Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
235            Self::Falkordb => "FalkorDB read-only prototype",
236            Self::Ladybug => "Ladybug read-only prototype",
237            Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
238            Self::Surrealdb => "SurrealDB read-only prototype",
239        }
240    }
241
242    fn projection_load(self) -> &'static str {
243        match self {
244            Self::Falkordb => {
245                "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"
246            }
247            Self::Kuzu => {
248                "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"
249            }
250            Self::Surrealdb => {
251                "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"
252            }
253            _ => {
254                "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
255            }
256        }
257    }
258
259    fn lock_behavior(self) -> &'static str {
260        match self {
261            Self::Falkordb => {
262                "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
263            }
264            Self::Kuzu => {
265                "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"
266            }
267            Self::Surrealdb => {
268                "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
269            }
270            _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
271        }
272    }
273
274    fn install_portability(self) -> &'static str {
275        match self {
276            Self::Falkordb => {
277                "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
278            }
279            Self::Kuzu => {
280                "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"
281            }
282            Self::Surrealdb => {
283                "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"
284            }
285            _ => {
286                "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
287            }
288        }
289    }
290
291    fn prototype_hold_reason(self) -> Option<&'static str> {
292        match self {
293            Self::DuckdbDuckpgq => Some(
294                "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
295            ),
296            Self::Falkordb => Some(
297                "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",
298            ),
299            Self::Ladybug => Some(
300                "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
301            ),
302            Self::Kuzu => Some(
303                "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
304            ),
305            Self::Surrealdb => Some(
306                "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",
307            ),
308        }
309    }
310
311    fn promotion_gate(self) -> GraphDbBackendPromotionGate {
312        match self {
313            Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
314                status: "hold_native_adapter_required".to_string(),
315                native_adapter_required: true,
316                required_checks: vec![
317                    "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
318                        .to_string(),
319                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
320                        .to_string(),
321                    "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
322                    "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
323                        .to_string(),
324                ],
325            },
326            Self::Falkordb => GraphDbBackendPromotionGate {
327                status: "hold_native_adapter_required".to_string(),
328                native_adapter_required: true,
329                required_checks: vec![
330                    "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
331                        .to_string(),
332                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
333                        .to_string(),
334                    "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
335                        .to_string(),
336                    "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
337                        .to_string(),
338                ],
339            },
340            Self::Ladybug => GraphDbBackendPromotionGate {
341                status: "hold_native_adapter_required".to_string(),
342                native_adapter_required: true,
343                required_checks: vec![
344                    "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
345                        .to_string(),
346                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
347                        .to_string(),
348                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
349                        .to_string(),
350                    "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
351                        .to_string(),
352                ],
353            },
354            Self::Kuzu => GraphDbBackendPromotionGate {
355                status: "hold_native_adapter_required".to_string(),
356                native_adapter_required: true,
357                required_checks: vec![
358                    "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
359                        .to_string(),
360                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
361                        .to_string(),
362                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
363                        .to_string(),
364                    "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
365                        .to_string(),
366                ],
367            },
368            Self::Surrealdb => GraphDbBackendPromotionGate {
369                status: "hold_native_adapter_required".to_string(),
370                native_adapter_required: true,
371                required_checks: vec![
372                    "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
373                        .to_string(),
374                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
375                        .to_string(),
376                    "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
377                        .to_string(),
378                    "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
379                        .to_string(),
380                ],
381            },
382        }
383    }
384
385    fn parse(raw: &str) -> Result<Self> {
386        match raw {
387            "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
388            "falkordb" | "falkor" => Ok(Self::Falkordb),
389            "ladybug" => Ok(Self::Ladybug),
390            "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
391            "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
392            _ => {
393                bail!(
394                    "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
395                )
396            }
397        }
398    }
399}
400
401
402pub fn run() -> Result<()> {
403    let cli = Cli::parse();
404    let compact = cli.compact;
405    let pretty = cli.pretty;
406    let terse = cli.terse || cli.ultra_terse;
407    let ultra_terse = cli.ultra_terse;
408    let absolute = cli.absolute;
409    let tabular = cli.tabular;
410    let schema = cli.schema;
411    let envelope = cli.envelope;
412    match cli.command {
413        Some(Commands::Search {
414            query,
415            path,
416            limit,
417            strategy,
418            exact,
419            scope,
420            federated,
421            lang,
422            kind,
423            node_kind,
424            section,
425            parent,
426            child,
427            fence_language,
428            list_depth,
429            heading_level,
430            json,
431            autoindex,
432            no_autoindex,
433            timeout,
434            max_items,
435            max_bytes,
436            budget,
437            no_tagpath,
438            tagpath_strict,
439        }) => cmd_search_with_budget(
440            query,
441            path,
442            limit,
443            if exact {
444                Some("exact".to_string())
445            } else {
446                strategy
447            },
448            scope,
449            federated,
450            json || terse || schema || envelope,
451            autoindex || !no_autoindex,
452            timeout,
453            compact,
454            pretty,
455            terse,
456            ultra_terse,
457            absolute,
458            tabular,
459            schema,
460            envelope,
461            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
462            TagpathSearchOpts {
463                no_tagpath,
464                strict: tagpath_strict,
465            },
466            SearchFacetFilters {
467                languages: lang,
468                kinds: kind,
469                node_kinds: node_kind,
470                sections: section,
471                parents: parent,
472                children: child,
473                fence_languages: fence_language,
474                list_depths: list_depth,
475                heading_levels: heading_level,
476            },
477        ),
478        Some(Commands::SearchWorker {
479            path,
480            cache_dir,
481            query,
482            limit,
483            strategy,
484            output,
485        }) => cmd_search_worker(&path, &cache_dir, &query, limit, &strategy, &output),
486        Some(Commands::DigestRunner {
487            kind,
488            path,
489            runner,
490            shell_command,
491            json,
492        }) => cmd_digest_runner(
493            &kind,
494            &path,
495            runner.as_deref(),
496            &shell_command,
497            OutputFormat {
498                json_output: json || terse || schema || envelope,
499                compact,
500                pretty,
501                terse,
502                ultra_terse,
503                schema,
504                envelope,
505            },
506        ),
507        Some(Commands::Edit { dry_run, file }) => {
508            cmd_edit(dry_run, file, compact, pretty, terse, schema)
509        }
510        Some(Commands::EditIntents {
511            path,
512            scope,
513            file,
514            json,
515            apply,
516            verify,
517            verify_command,
518            max_items,
519            max_bytes,
520            budget,
521        }) => cmd_edit_intents(
522            &path,
523            scope.as_deref(),
524            file,
525            apply,
526            SemanticEditVerifyOptions {
527                enabled: verify,
528                command: verify_command.as_deref(),
529            },
530            OutputFormat {
531                json_output: json || terse || schema || envelope,
532                compact,
533                pretty,
534                terse,
535                ultra_terse,
536                schema,
537                envelope,
538            },
539            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
540        ),
541        Some(Commands::Index {
542            path,
543            rebuild,
544            check,
545            exit_code,
546            prune,
547            quiet,
548            workspace,
549            submodule,
550            json,
551        }) => cmd_index(
552            &path,
553            rebuild,
554            check,
555            exit_code,
556            prune,
557            quiet,
558            workspace,
559            submodule.as_deref(),
560            json || terse || schema || envelope,
561            compact,
562            pretty,
563            terse,
564            absolute,
565            schema,
566        ),
567        Some(Commands::Rewrite { command, run }) => cmd_rewrite(
568            &command,
569            run,
570            OutputFormat {
571                json_output: terse || schema || envelope,
572                compact,
573                pretty,
574                terse,
575                ultra_terse,
576                schema,
577                envelope,
578            },
579        ),
580        Some(Commands::Route { task, id }) => cmd_route(&task, id),
581        Some(Commands::Memory { command }) => {
582            let json = command.json_output();
583            cmd_memory(
584                command,
585                OutputFormat {
586                    json_output: json || terse || schema || envelope,
587                    compact,
588                    pretty,
589                    terse,
590                    ultra_terse,
591                    schema,
592                    envelope,
593                },
594            )
595        }
596        Some(Commands::Finding { command }) => match command {
597            cli::FindingCommand::Add {
598                path,
599                kind,
600                title,
601                body,
602                about,
603                confidence,
604                status,
605                relates,
606                scope,
607                json,
608            } => commands::finding::cmd_finding_add(
609                &path,
610                &kind,
611                &title,
612                &body,
613                &about,
614                confidence,
615                &status,
616                relates.as_deref(),
617                scope.as_deref(),
618                json || terse || schema || envelope,
619                pretty,
620            ),
621            cli::FindingCommand::List {
622                path,
623                about,
624                kind,
625                status,
626                include_stale,
627                scope,
628                json,
629            } => commands::finding::cmd_finding_list(
630                &path,
631                about.as_deref(),
632                kind.as_deref(),
633                status.as_deref(),
634                include_stale,
635                scope.as_deref(),
636                json || terse || schema || envelope,
637                pretty,
638            ),
639            cli::FindingCommand::Harvest { path, scope, json } => {
640                commands::finding::cmd_finding_harvest(
641                    &path,
642                    scope.as_deref(),
643                    json || terse || schema || envelope,
644                    pretty,
645                )
646            }
647            cli::FindingCommand::Promote { id, path, json } => {
648                commands::finding::cmd_finding_promote(
649                    &path,
650                    &id,
651                    json || terse || schema || envelope,
652                    pretty,
653                )
654            }
655        },
656        Some(Commands::Graph {
657            symbol,
658            path,
659            callers,
660            callees,
661            scope,
662            limit,
663            json,
664            no_tagpath,
665            tagpath_strict,
666        }) => cmd_graph(
667            &symbol,
668            &path,
669            callers,
670            callees,
671            scope.as_deref(),
672            limit,
673            json || terse || schema || envelope,
674            compact,
675            pretty,
676            terse,
677            absolute,
678            tabular,
679            schema,
680            TagpathSearchOpts {
681                no_tagpath,
682                strict: tagpath_strict,
683            },
684        ),
685        Some(Commands::Sql {
686            db,
687            query,
688            table,
689            json,
690        }) => cmd_sql(
691            &db,
692            query,
693            table,
694            json || terse || schema || envelope,
695            compact,
696            pretty,
697            terse,
698            schema,
699        ),
700        Some(Commands::Communities {
701            path,
702            scope,
703            min_size,
704            limit,
705            json,
706            no_tagpath,
707            tagpath_strict,
708        }) => cmd_communities(
709            &path,
710            scope.as_deref(),
711            min_size,
712            limit,
713            json || terse || schema || envelope,
714            compact,
715            pretty,
716            terse,
717            tabular,
718            schema,
719            TagpathSearchOpts {
720                no_tagpath,
721                strict: tagpath_strict,
722            },
723        ),
724        Some(Commands::Analyze {
725            path,
726            scope,
727            entry_points,
728            limit,
729            json,
730        }) => cmd_analyze(
731            &path,
732            scope.as_deref(),
733            &entry_points,
734            limit,
735            OutputFormat {
736                json_output: json || terse || schema || envelope,
737                compact,
738                pretty,
739                terse,
740                ultra_terse,
741                schema,
742                envelope,
743            },
744        ),
745        Some(Commands::Path {
746            from,
747            to,
748            path,
749            scope,
750            json,
751            no_tagpath,
752            tagpath_strict,
753        }) => cmd_path(
754            &from,
755            &to,
756            &path,
757            scope.as_deref(),
758            json || terse || schema || envelope,
759            compact,
760            pretty,
761            terse,
762            schema,
763            TagpathSearchOpts {
764                no_tagpath,
765                strict: tagpath_strict,
766            },
767        ),
768        Some(Commands::Explain {
769            symbol,
770            path,
771            scope,
772            limit,
773            json,
774            max_items,
775            max_bytes,
776            budget,
777            no_tagpath,
778            tagpath_strict,
779        }) => cmd_explain_with_budget(
780            &symbol,
781            &path,
782            scope.as_deref(),
783            limit,
784            json || terse || schema || envelope,
785            compact,
786            pretty,
787            terse,
788            ultra_terse,
789            absolute,
790            tabular,
791            schema,
792            envelope,
793            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
794            TagpathSearchOpts {
795                no_tagpath,
796                strict: tagpath_strict,
797            },
798        ),
799        Some(Commands::Traverse {
800            node,
801            to,
802            path,
803            scope,
804            depth,
805            limit,
806            format,
807            convex_snapshot,
808        }) => cmd_traverse(
809            node.as_deref(),
810            to.as_deref(),
811            &path,
812            scope.as_deref(),
813            depth,
814            limit,
815            format,
816            pretty,
817            terse,
818            schema,
819            convex_snapshot.as_deref(),
820        ),
821        Some(Commands::ConvexSync {
822            path,
823            scope,
824            snapshot,
825            chunk_size,
826            remote_snapshot,
827            apply,
828            endpoint,
829            auth_token_env,
830            json,
831        }) => cmd_convex_sync(
832            ConvexSyncOptions {
833                path: &path,
834                scope: scope.as_deref(),
835                snapshot: snapshot.as_deref(),
836                chunk_size,
837                remote_snapshot,
838                apply,
839                endpoint: endpoint.as_deref(),
840                auth_token_env: &auth_token_env,
841            },
842            OutputFormat {
843                json_output: json || terse || schema || envelope,
844                compact,
845                pretty,
846                terse,
847                ultra_terse,
848                schema,
849                envelope,
850            },
851        ),
852        Some(Commands::GraphDb {
853            path,
854            scope,
855            backend,
856            convex_snapshot,
857            json,
858            query,
859        }) => cmd_graph_db(
860            &path,
861            scope.as_deref(),
862            backend,
863            convex_snapshot.as_deref(),
864            query,
865            OutputFormat {
866                json_output: json || terse || schema || envelope,
867                compact,
868                pretty,
869                terse,
870                ultra_terse,
871                schema,
872                envelope,
873            },
874        ),
875        Some(Commands::SourceRead {
876            file,
877            path,
878            style,
879            start,
880            lines,
881            end,
882            scope,
883            json,
884            max_items,
885            max_bytes,
886            budget,
887        }) => cmd_source_read(
888            &file,
889            &path,
890            style,
891            start,
892            lines,
893            end,
894            scope.as_deref(),
895            OutputFormat {
896                json_output: json || terse || schema || envelope,
897                compact,
898                pretty,
899                terse,
900                ultra_terse,
901                schema,
902                envelope,
903            },
904            absolute,
905            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
906        ),
907        Some(Commands::MarkdownAst {
908            file,
909            path,
910            node,
911            json,
912            max_items,
913            max_bytes,
914            budget,
915        }) => cmd_markdown_ast(
916            &file,
917            &path,
918            node.as_deref(),
919            OutputFormat {
920                json_output: json || terse || schema || envelope,
921                compact,
922                pretty,
923                terse,
924                ultra_terse,
925                schema,
926                envelope,
927            },
928            absolute,
929            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
930        ),
931        Some(Commands::SymbolRead {
932            symbol,
933            file,
934            path,
935            scope,
936            json,
937            max_items,
938            max_bytes,
939            budget,
940        }) => cmd_symbol_read(
941            &symbol,
942            file.as_deref(),
943            &path,
944            scope.as_deref(),
945            OutputFormat {
946                json_output: json || terse || schema || envelope,
947                compact,
948                pretty,
949                terse,
950                ultra_terse,
951                schema,
952                envelope,
953            },
954            absolute,
955            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
956        ),
957        Some(Commands::Audit {
958            skills_dir,
959            manifest,
960            usage,
961            cleanup,
962            report,
963            json,
964        }) => cmd_audit(
965            &skills_dir,
966            manifest,
967            usage,
968            cleanup,
969            report,
970            json || terse || schema || envelope,
971            compact,
972            pretty,
973            terse,
974            schema,
975        ),
976        Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
977            &path,
978            scope.as_deref(),
979            json || terse || schema || envelope,
980            pretty,
981            terse,
982            schema,
983        ),
984        Some(Commands::Init {
985            path,
986            codex,
987            opencode,
988            workspace,
989        }) => cmd_init(&path, codex, opencode, workspace),
990        Some(Commands::Lint {
991            file,
992            index,
993            entities_from,
994            json,
995        }) => cmd_lint(
996            &file,
997            index,
998            entities_from,
999            json || terse || schema || envelope,
1000            compact,
1001            pretty,
1002            terse,
1003            schema,
1004        ),
1005        Some(Commands::Summarize {
1006            symbol,
1007            file,
1008            extract,
1009            diff,
1010            stats,
1011            path,
1012            json,
1013        }) => cmd_summarize(
1014            symbol,
1015            file,
1016            extract,
1017            diff,
1018            stats,
1019            &path,
1020            json || terse || schema || envelope,
1021            compact,
1022            pretty,
1023            terse,
1024            schema,
1025        ),
1026        Some(Commands::Semantic {
1027            query,
1028            path,
1029            scope,
1030            limit,
1031            kind,
1032            json,
1033        }) => cmd_semantic_related(
1034            &query,
1035            &path,
1036            scope.as_deref(),
1037            limit,
1038            kind,
1039            json || terse || schema || envelope,
1040            compact,
1041            pretty,
1042            terse,
1043            schema,
1044        ),
1045        Some(Commands::DiffDigest {
1046            path,
1047            cached,
1048            revision,
1049            max_parsed_files,
1050            json,
1051        }) => cmd_diff_digest(
1052            &path,
1053            cached,
1054            revision.as_deref(),
1055            max_parsed_files,
1056            OutputFormat {
1057                json_output: json || terse || schema || envelope,
1058                compact,
1059                pretty,
1060                terse,
1061                ultra_terse,
1062                schema,
1063                envelope,
1064            },
1065        ),
1066        Some(Commands::Impact {
1067            path,
1068            cached,
1069            revision,
1070            scope,
1071            limit,
1072            json,
1073        }) => cmd_impact(
1074            &path,
1075            cached,
1076            revision.as_deref(),
1077            scope.as_deref(),
1078            limit,
1079            OutputFormat {
1080                json_output: json || terse || schema || envelope,
1081                compact,
1082                pretty,
1083                terse,
1084                ultra_terse,
1085                schema,
1086                envelope,
1087            },
1088        ),
1089        Some(Commands::TestDigest {
1090            path,
1091            input,
1092            runner,
1093            json,
1094        }) => cmd_test_digest(
1095            &path,
1096            input.as_deref(),
1097            runner.as_deref(),
1098            OutputFormat {
1099                json_output: json || terse || schema || envelope,
1100                compact,
1101                pretty,
1102                terse,
1103                ultra_terse,
1104                schema,
1105                envelope,
1106            },
1107        ),
1108        Some(Commands::LogDigest { path, input, json }) => cmd_log_digest(
1109            &path,
1110            input.as_deref(),
1111            OutputFormat {
1112                json_output: json || terse || schema || envelope,
1113                compact,
1114                pretty,
1115                terse,
1116                ultra_terse,
1117                schema,
1118                envelope,
1119            },
1120        ),
1121        Some(Commands::ContextPack {
1122            path,
1123            test_input,
1124            runner,
1125            log_input,
1126            json,
1127            max_items,
1128            max_bytes,
1129            budget,
1130            convex_snapshot,
1131        }) => cmd_context_pack(
1132            &path,
1133            test_input.as_deref(),
1134            runner.as_deref(),
1135            log_input.as_deref(),
1136            OutputFormat {
1137                json_output: json || terse || schema || envelope,
1138                compact,
1139                pretty,
1140                terse,
1141                ultra_terse,
1142                schema,
1143                envelope,
1144            },
1145            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1146            convex_snapshot.as_deref(),
1147        ),
1148        Some(Commands::ConflictMatrix {
1149            targets,
1150            path,
1151            scope,
1152            depth,
1153            limit,
1154            impact_limit,
1155            json,
1156        }) => cmd_conflict_matrix(
1157            &path,
1158            scope.as_deref(),
1159            &targets,
1160            depth,
1161            limit,
1162            impact_limit,
1163            OutputFormat {
1164                json_output: json || terse || schema || envelope,
1165                compact,
1166                pretty,
1167                terse,
1168                ultra_terse,
1169                schema,
1170                envelope,
1171            },
1172        ),
1173        Some(Commands::DispatchTrace {
1174            targets,
1175            path,
1176            scope,
1177            depth,
1178            limit,
1179            impact_limit,
1180            format,
1181            json,
1182        }) => cmd_dispatch_trace(
1183            DispatchTraceOptions {
1184                path: &path,
1185                scope: scope.as_deref(),
1186                raw_targets: &targets,
1187                depth,
1188                limit,
1189                impact_limit,
1190                trace_format: if json {
1191                    DispatchTraceFormat::Json
1192                } else {
1193                    format
1194                },
1195            },
1196            OutputFormat {
1197                json_output: json || terse || schema || envelope,
1198                compact,
1199                pretty,
1200                terse,
1201                ultra_terse,
1202                schema,
1203                envelope,
1204            },
1205        ),
1206        Some(Commands::DependencyDag {
1207            targets,
1208            path,
1209            scope,
1210            depth,
1211            limit,
1212            json,
1213        }) => cmd_dependency_dag(
1214            &path,
1215            scope.as_deref(),
1216            &targets,
1217            depth,
1218            limit,
1219            OutputFormat {
1220                json_output: json || terse || schema || envelope,
1221                compact,
1222                pretty,
1223                terse,
1224                ultra_terse,
1225                schema,
1226                envelope,
1227            },
1228        ),
1229        Some(Commands::TokenSavings {
1230            fixture,
1231            fail_under,
1232            json,
1233        }) => token_savings::cmd_token_savings(
1234            &fixture,
1235            fail_under,
1236            OutputFormat {
1237                json_output: json || terse || schema || envelope,
1238                compact,
1239                pretty,
1240                terse,
1241                ultra_terse,
1242                schema,
1243                envelope,
1244            },
1245        ),
1246        Some(Commands::MetricDigest {
1247            input,
1248            baseline,
1249            metrics,
1250            lower_is_better,
1251            higher_is_better,
1252            history,
1253            top,
1254            json,
1255        }) => cmd_metric_digest(
1256            MetricDigestOptions {
1257                input_path: input.as_deref(),
1258                baseline_path: baseline.as_deref(),
1259                metrics: &metrics,
1260                lower_is_better: &lower_is_better,
1261                higher_is_better: &higher_is_better,
1262                history,
1263                top,
1264            },
1265            OutputFormat {
1266                json_output: json || terse || schema || envelope,
1267                compact,
1268                pretty,
1269                terse,
1270                ultra_terse,
1271                schema,
1272                envelope,
1273            },
1274        ),
1275        Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1276            &fixture,
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::TokenGate { command }) => {
1288            cmd_token_gate(command, OutputFormat {
1289                json_output: true,
1290                compact,
1291                pretty,
1292                terse,
1293                ultra_terse,
1294                schema,
1295                envelope,
1296            })?;
1297            Ok(())
1298        },
1299        Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1300            &topic,
1301            OutputFormat {
1302                json_output: json || terse || schema || envelope,
1303                compact,
1304                pretty,
1305                terse,
1306                ultra_terse,
1307                schema,
1308                envelope,
1309            },
1310        ),
1311        Some(Commands::SessionDigest {
1312            path,
1313            input,
1314            source,
1315            json,
1316        }) => cmd_session_digest(
1317            &path,
1318            input.as_deref(),
1319            source.as_deref(),
1320            OutputFormat {
1321                json_output: json || terse || schema || envelope,
1322                compact,
1323                pretty,
1324                terse,
1325                ultra_terse,
1326                schema,
1327                envelope,
1328            },
1329        ),
1330        Some(Commands::SessionCost {
1331            input,
1332            source,
1333            json,
1334        }) => cmd_session_cost(
1335            input.as_deref(),
1336            source.as_deref(),
1337            OutputFormat {
1338                json_output: json || terse || schema || envelope,
1339                compact,
1340                pretty,
1341                terse,
1342                ultra_terse,
1343                schema,
1344                envelope,
1345            },
1346        ),
1347        Some(Commands::SessionReview {
1348            path,
1349            next_context,
1350            json,
1351            max_items,
1352            max_bytes,
1353            budget,
1354        }) => cmd_session_review_with_budget(
1355            &path,
1356            next_context,
1357            OutputFormat {
1358                json_output: json || terse || schema || envelope,
1359                compact,
1360                pretty,
1361                terse,
1362                ultra_terse,
1363                schema,
1364                envelope,
1365            },
1366            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1367        ),
1368        Some(Commands::Status {
1369            path,
1370            fix,
1371            no_fix,
1372            json,
1373        }) => cmd_status(
1374            &path,
1375            StatusCommandOptions {
1376                fix,
1377                no_fix,
1378                json_output: json || terse || schema || envelope,
1379                compact,
1380                pretty,
1381                terse,
1382                schema,
1383            },
1384        ),
1385        Some(Commands::Locks { path, scope, json }) => cmd_locks(
1386            &path,
1387            scope.as_deref(),
1388            json || terse || schema || envelope,
1389            compact,
1390            pretty,
1391            terse,
1392            schema,
1393        ),
1394        None => {
1395            println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1396            println!("Run `tsift --help` for usage.");
1397            Ok(())
1398        }
1399    }
1400}
1401
1402/// Classify a task description into a model tier.
1403/// Returns (tier_name, model_id).
1404pub fn classify_task(task: &str) -> (&'static str, &'static str) {
1405    let lower = task.to_lowercase();
1406    // Architecture/design signals → opus
1407    for signal in &[
1408        "architect",
1409        "architecture",
1410        "design",
1411        "plan",
1412        "strateg",
1413        "analy",
1414        "review",
1415        "evaluate",
1416        "assess",
1417    ] {
1418        if lower.contains(signal) {
1419            return ("opus", "claude-opus-4-6");
1420        }
1421    }
1422    // Edit/write signals → sonnet
1423    for signal in &[
1424        "edit",
1425        "write",
1426        "fix",
1427        "change",
1428        "update",
1429        "create",
1430        "add ",
1431        "remove",
1432        "delete",
1433        "modify",
1434        "refactor",
1435        "implement",
1436        "build",
1437    ] {
1438        if lower.contains(signal) {
1439            return ("sonnet", "claude-sonnet-4-6");
1440        }
1441    }
1442    // Default: search/lookup → haiku
1443    ("haiku", "claude-haiku-4-5-20251001")
1444}
1445
1446#[cfg(test)]
1447fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
1448    to_json_schema(val, pretty, terse, false, false)
1449}
1450
1451/// Add top-level `tagpath_index_stale: true` + `tagpath_stale_reason: <reason>`
1452/// fields to a JSON response when the tagpath adapter reported any helper
1453/// going stale. JSON consumers (`tsift --envelope` / `--json` callers) can
1454/// then act on the same condition the stderr `tagpath_index_stale: …` log
1455/// already surfaces without parsing logs. No-op when `stale=false` or when
1456/// `value` is not a JSON object.
1457pub(crate) fn inject_tagpath_stale_into_json(
1458    value: &mut serde_json::Value,
1459    stale: bool,
1460    reason: Option<&str>,
1461) {
1462    if !stale {
1463        return;
1464    }
1465    if let Some(obj) = value.as_object_mut() {
1466        obj.insert(
1467            "tagpath_index_stale".to_string(),
1468            serde_json::Value::Bool(true),
1469        );
1470        if let Some(reason) = reason {
1471            obj.insert(
1472                "tagpath_stale_reason".to_string(),
1473                serde_json::Value::String(reason.to_string()),
1474            );
1475        }
1476    }
1477}
1478
1479pub(crate) fn to_json_schema<T: serde::Serialize>(
1480    val: &T,
1481    pretty: bool,
1482    terse: bool,
1483    ultra_terse: bool,
1484    schema: bool,
1485) -> anyhow::Result<String> {
1486    if terse || schema {
1487        let value = serde_json::to_value(val)?;
1488        let mut transformed = if terse { terse_transform(value) } else { value };
1489        if ultra_terse {
1490            transformed = ultra_terse_transform(transformed);
1491            transformed = edge_index_transform(transformed);
1492        }
1493        if schema {
1494            transformed = schema_transform(transformed);
1495        }
1496        if terse {
1497            let terse_schema = terse_schema_for(&transformed);
1498            let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
1499            if pretty {
1500                Ok(serde_json::to_string_pretty(&wrapped)?)
1501            } else {
1502                Ok(serde_json::to_string(&wrapped)?)
1503            }
1504        } else if pretty {
1505            Ok(serde_json::to_string_pretty(&transformed)?)
1506        } else {
1507            Ok(serde_json::to_string(&transformed)?)
1508        }
1509    } else if pretty {
1510        Ok(serde_json::to_string_pretty(val)?)
1511    } else {
1512        Ok(serde_json::to_string(val)?)
1513    }
1514}
1515
1516pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
1517    ToolEnvelopeMetric {
1518        label: label.to_string(),
1519        value: value.to_string(),
1520    }
1521}
1522
1523pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
1524    let mut seen = HashSet::new();
1525    let mut deduped = Vec::new();
1526    for value in values {
1527        if seen.insert(value.clone()) {
1528            deduped.push(value);
1529        }
1530    }
1531    deduped
1532}
1533
1534pub(crate) fn print_json_or_envelope<T: Serialize>(
1535    report: &T,
1536    format: &OutputFormat,
1537    tool: &str,
1538    view: &str,
1539    summary: ToolEnvelopeSummary,
1540    truncated: bool,
1541    follow_up: Vec<String>,
1542) -> Result<()> {
1543    if format.envelope {
1544        let schema = format.schema || tool == "source-read";
1545        let envelope = ToolEnvelope {
1546            tool,
1547            view,
1548            summary,
1549            truncated,
1550            follow_up: dedupe_preserve_order(follow_up),
1551            report,
1552        };
1553        println!(
1554            "{}",
1555            to_json_schema(
1556                &envelope,
1557                format.pretty,
1558                format.terse,
1559                format.ultra_terse,
1560                schema
1561            )?
1562        );
1563    } else {
1564        println!(
1565            "{}",
1566            to_json_schema(
1567                report,
1568                format.pretty,
1569                format.terse,
1570                format.ultra_terse,
1571                format.schema
1572            )?
1573        );
1574    }
1575    Ok(())
1576}
1577
1578pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
1579    bytes.div_ceil(4)
1580}
1581
1582fn cmd_token_gate(
1583    command: cli::TokenGateCommand,
1584    format: OutputFormat,
1585) -> Result<()> {
1586    match command {
1587        cli::TokenGateCommand::Sample {
1588            surface,
1589            path,
1590            scope,
1591            target,
1592            depth,
1593            sample_index,
1594            json: _,
1595        } => cmd_token_gate_sample(&surface, &path, scope.as_deref(), target.as_deref(), depth, sample_index),
1596        cli::TokenGateCommand::Evaluate {
1597            history,
1598            allowed_regression_percent,
1599            json: _,
1600        } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
1601    }
1602}
1603
1604fn cmd_token_gate_sample(
1605    surface: &str,
1606    path: &Path,
1607    scope: Option<&str>,
1608    target: Option<&str>,
1609    depth: usize,
1610    sample_index: usize,
1611) -> Result<()> {
1612    if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
1613        bail!(
1614            "unknown surface `{}`; expected one of: {}",
1615            surface,
1616            token_gate::TOKEN_GATE_SURFACES.join(", ")
1617        );
1618    }
1619
1620    let path_str = path.to_string_lossy().to_string();
1621    let tsift_bin = std::env::current_exe()?;
1622
1623    let args: Vec<String> = match surface {
1624        "context_pack" => vec![
1625            "context-pack".to_string(),
1626            "--json".to_string(),
1627            path_str,
1628        ],
1629        "session_review_next_context" => vec![
1630            "session-review".to_string(),
1631            "--json".to_string(),
1632            "--next-context".to_string(),
1633            path_str,
1634        ],
1635        "graph_db_evidence" => {
1636            let tgt = target.unwrap_or("default").to_string();
1637            vec![
1638                "graph-db".to_string(),
1639                "--json".to_string(),
1640                "--path".to_string(),
1641                path_str,
1642                "evidence".to_string(),
1643                tgt,
1644                "--depth".to_string(),
1645                depth.to_string(),
1646            ]
1647        }
1648        "conflict_matrix" => {
1649            let tgt = target.unwrap_or("default").to_string();
1650            let mut a = vec![
1651                "conflict-matrix".to_string(),
1652                "--json".to_string(),
1653                "--path".to_string(),
1654                path_str,
1655                "--depth".to_string(),
1656                depth.to_string(),
1657            ];
1658            if let Some(s) = scope {
1659                a.push("--scope".to_string());
1660                a.push(s.to_string());
1661            }
1662            a.push(tgt);
1663            a
1664        }
1665        "dispatch_trace" => {
1666            let tgt = target.unwrap_or("default").to_string();
1667            vec![
1668                "dispatch-trace".to_string(),
1669                "--json".to_string(),
1670                "--path".to_string(),
1671                path_str,
1672                tgt,
1673            ]
1674        }
1675        _ => bail!("unhandled surface: {}", surface),
1676    };
1677
1678    let start = Instant::now();
1679    let child = Command::new(&tsift_bin)
1680        .args(&args)
1681        .stdout(Stdio::piped())
1682        .stderr(Stdio::piped())
1683        .env("TSIFT_QUIET", "1")
1684        .spawn();
1685    let output = match child {
1686        Ok(c) => c.wait_with_output()?,
1687        Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
1688    };
1689    let runtime_micros = start.elapsed().as_micros() as f64;
1690
1691    let stdout = String::from_utf8_lossy(&output.stdout);
1692    let envelope_bytes = stdout.trim().len() as f64;
1693    let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
1694
1695    let cache_hit_rate_percent = 0.0;
1696    let raw_read_avoidance = 0.0;
1697    let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
1698
1699    let timestamp = iso_timestamp_now();
1700    let id = format!(
1701        "{surface}-baseline-{}-sample-{sample_index}",
1702        &timestamp[..10]
1703    );
1704    let label = format!(
1705        "token-gate baseline {surface} sample {sample_index} for {}",
1706        path.display()
1707    );
1708
1709    let mut metrics = BTreeMap::new();
1710    metrics.insert("prompt_tokens".to_string(), prompt_tokens);
1711    metrics.insert("envelope_bytes".to_string(), envelope_bytes);
1712    metrics.insert("runtime_micros".to_string(), runtime_micros);
1713    metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
1714    metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
1715    metrics.insert("useful_hit_density".to_string(), useful_hit_density);
1716
1717    let sample = token_gate::TokenGateSample {
1718        label,
1719        id,
1720        timestamp: Some(timestamp),
1721        surface: surface.to_string(),
1722        metrics,
1723    };
1724
1725    println!("{}", serde_json::to_string_pretty(&sample)?);
1726    Ok(())
1727}
1728
1729fn cmd_token_gate_evaluate(
1730    history_path: Option<&Path>,
1731    allowed_regression_percent: f64,
1732    format: &OutputFormat,
1733) -> Result<()> {
1734    let history_path = history_path
1735        .map(PathBuf::from)
1736        .unwrap_or_else(|| {
1737            let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1738            p.push("../../fixtures/token-gate-history.json");
1739            p
1740        });
1741
1742    let raw = std::fs::read_to_string(&history_path)
1743        .with_context(|| format!("failed to read token gate history: {}", history_path.display()))?;
1744    let samples = token_gate::parse_token_history(&raw)?;
1745    let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
1746
1747    if format.json_output {
1748        println!("{}", to_json_schema(&report, format.pretty, format.terse, false, format.schema)?);
1749    } else {
1750        println!("Token Gate Report");
1751        println!("  min_samples: {}", report.min_samples);
1752        println!("  allowed_regression: {:.1}%", report.allowed_regression_percent);
1753        println!("  decision: {:?}", report.decision);
1754        for eval in &report.surface_evaluations {
1755            println!(
1756                "  {} ({} samples): {:?}",
1757                eval.display_name, eval.sample_count, eval.verdict
1758            );
1759            for me in &eval.metric_evaluations {
1760                println!(
1761                    "    {} ({:?}): {}",
1762                    me.metric, me.direction, me.diagnostic
1763                );
1764            }
1765        }
1766        for d in &report.diagnostics {
1767            println!("  ! {}", d);
1768        }
1769    }
1770    Ok(())
1771}
1772
1773fn iso_timestamp_now() -> String {
1774    let dur = SystemTime::now()
1775        .duration_since(UNIX_EPOCH)
1776        .unwrap_or_default();
1777    let total_secs = dur.as_secs();
1778    let days_since_epoch = total_secs / 86400;
1779    let (year, month, day) = days_to_ymd(days_since_epoch);
1780    let time_of_day = total_secs % 86400;
1781    let hour = (time_of_day / 3600) as u8;
1782    let minute = ((time_of_day % 3600) / 60) as u8;
1783    let second = (time_of_day % 60) as u8;
1784    format!(
1785        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1786        year, month, day, hour, minute, second
1787    )
1788}
1789
1790fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
1791    let mut year = 1970u64;
1792    loop {
1793        let days_in_year = if is_leap(year) { 366 } else { 365 };
1794        if days < days_in_year {
1795            break;
1796        }
1797        days -= days_in_year;
1798        year += 1;
1799    }
1800    let leap = is_leap(year);
1801    let month_days: [u8; 12] = if leap {
1802        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1803    } else {
1804        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1805    };
1806    let mut month: u8 = 1;
1807    for &md in &month_days {
1808        if days < md as u64 {
1809            break;
1810        }
1811        days -= md as u64;
1812        month += 1;
1813    }
1814    let day = days as u8 + 1;
1815    (year, month, day)
1816}
1817
1818fn is_leap(year: u64) -> bool {
1819    year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
1820}
1821
1822fn persist_transcript_artifact(
1823    root: &Path,
1824    prefix: &str,
1825    suffix: &str,
1826    key: &str,
1827    body: &str,
1828    expand: String,
1829) -> Result<TranscriptArtifactRef> {
1830    let handle = stable_handle(prefix, key);
1831    let artifacts_dir = root.join(".tsift/artifacts");
1832    fs::create_dir_all(&artifacts_dir).with_context(|| {
1833        format!(
1834            "creating transcript artifacts dir: {}",
1835            artifacts_dir.display()
1836        )
1837    })?;
1838    let file_name = format!("{handle}.{suffix}");
1839    let artifact_path = artifacts_dir.join(file_name);
1840    fs::write(&artifact_path, body)
1841        .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
1842    let rel_path = relativize_pathbuf(&artifact_path, root);
1843    Ok(TranscriptArtifactRef {
1844        handle,
1845        path: rel_path.display().to_string(),
1846        bytes: body.len(),
1847        lines: body.lines().count(),
1848        expand,
1849    })
1850}
1851
1852fn terse_key(key: &str) -> &str {
1853    match key {
1854        "name" => "n",
1855        "kind" => "k",
1856        "file" => "f",
1857        "line" => "l",
1858        "path" => "p",
1859        "from" => "fr",
1860        "type" => "ty",
1861        "text" => "tx",
1862        "new" => "nw",
1863        "run" => "r",
1864        "use" => "u",
1865        "score" => "sc",
1866        "language" => "la",
1867        "status" => "st",
1868        "state" => "stt",
1869        "error" => "err",
1870        "errors" => "ers",
1871        "hops" => "hp",
1872        "tags" => "tg",
1873        "model" => "ml",
1874        "skill" => "sk",
1875        "count" => "ct",
1876        "total" => "tot",
1877        "column" => "col",
1878        "description" => "dsc",
1879        "end_line" => "el",
1880        "signature" => "sig",
1881        "parent_module" => "pm",
1882        "visibility" => "vis",
1883        "match_type" => "mt",
1884        "caller_file" => "cf",
1885        "caller_name" => "cn",
1886        "caller_line" => "cl",
1887        "callee_name" => "en",
1888        "call_site_line" => "csl",
1889        "members" => "m",
1890        "refs" => "refs",
1891        "role" => "rl",
1892        "peer" => "pr",
1893        "modularity" => "q",
1894        "modularity_contribution" => "mc",
1895        "iterations" => "it",
1896        "node_count" => "nc",
1897        "edge_count" => "ec",
1898        "community_count" => "cc",
1899        "communities" => "cms",
1900        "community" => "cm",
1901        "community_diagnostics" => "cd",
1902        "cache_hit" => "cah",
1903        "tagpath_state" => "tps",
1904        "tagpath_stale_reason" => "tsr",
1905        "annotated_community_count" => "acc",
1906        "annotated_member_count" => "amc",
1907        "ambiguous_member_count" => "ambc",
1908        "ambiguous_members" => "amb",
1909        "candidate_count" => "cand",
1910        "tagpath_candidate_count" => "tcand",
1911        "evidence" => "ev",
1912        "chosen_file" => "chf",
1913        "symbol" => "s",
1914        "symbols" => "sy",
1915        "definitions" => "df",
1916        "callers" => "crs",
1917        "callees" => "ces",
1918        "total_tracked" => "tt",
1919        "modified" => "md",
1920        "deleted" => "dl",
1921        "unchanged" => "uc",
1922        "changes" => "ch",
1923        "prune_stats" => "ps",
1924        "hits" => "h",
1925        "rank" => "rk",
1926        "snippet" => "sn",
1927        "confidence" => "co",
1928        "index" => "ix",
1929        "summaries" => "sms",
1930        "recommendations" => "rec",
1931        "total_files" => "tf",
1932        "stale_files" => "sf",
1933        "last_indexed_secs_ago" => "age",
1934        "cached_files" => "caf",
1935        "total_indexed_files" => "tif",
1936        "coverage_pct" => "cov",
1937        "symbol_name" => "syn",
1938        "file_path" => "fp",
1939        "content_hash" => "hsh",
1940        "summary" => "sum",
1941        "tool" => "tl",
1942        "view" => "vw",
1943        "truncated" => "tr",
1944        "follow_up" => "fu",
1945        "report" => "rp",
1946        "metrics" => "ms",
1947        "label" => "lb",
1948        "value" => "v",
1949        "command" => "cmd",
1950        "exit_code" => "xc",
1951        "success" => "ok",
1952        "artifact" => "art",
1953        "digest" => "dg",
1954        "bytes" => "bt",
1955        "lines" => "lns",
1956        "expand" => "xp",
1957        "entities" => "ent",
1958        "relationships" => "rel",
1959        "concept_labels" => "cls",
1960        "extracted_at" => "at",
1961        "tokens_input" => "ti",
1962        "tokens_output" => "tout",
1963        "total_summaries" => "ts",
1964        "stale_count" => "stc",
1965        "total_tokens_input" => "tti",
1966        "total_tokens_output" => "tto",
1967        "estimated_tokens_saved" => "ets",
1968        "files_processed" => "fps",
1969        "symbols_extracted" => "se",
1970        "skills_dir" => "sd",
1971        "healthy" => "ok",
1972        "broken" => "brk",
1973        "skills" => "sks",
1974        "manifest_diffs" => "mdf",
1975        "similar_pairs" => "sim",
1976        "usage" => "usg",
1977        "cleanup" => "cln",
1978        "has_skill_md" => "hsm",
1979        "is_symlink" => "isl",
1980        "issues" => "iss",
1981        "invocation_count" => "inv",
1982        "reasons" => "rsn",
1983        "token_estimate" => "te",
1984        "skill_a" => "sa",
1985        "skill_b" => "sb",
1986        "desc_a" => "da",
1987        "desc_b" => "db",
1988        "annotations" => "ann",
1989        "entity" => "ety",
1990        "suggestion" => "sug",
1991        "columns" => "cols",
1992        "row_count" => "rc",
1993        "notnull" => "nn",
1994        "default_value" => "dv",
1995        "replace_all" => "ra",
1996        other => other,
1997    }
1998}
1999
2000fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2001    match val {
2002        serde_json::Value::Object(map) => {
2003            let mut new_map = serde_json::Map::new();
2004            for (k, v) in map {
2005                new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2006            }
2007            serde_json::Value::Object(new_map)
2008        }
2009        serde_json::Value::Array(arr) => {
2010            serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2011        }
2012        other => other,
2013    }
2014}
2015
2016fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2017    match val {
2018        serde_json::Value::Object(mut map) => {
2019            let is_graph_node =
2020                map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2021            let is_graph_edge =
2022                map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2023            if is_graph_node || is_graph_edge {
2024                map.remove("properties");
2025                map.remove("provenance");
2026                map.remove("freshness");
2027            }
2028            if is_graph_edge
2029                && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2030                    *s = abbreviate_edge_kind(s).to_string();
2031                }
2032            let is_coverage = map.contains_key("mode")
2033                && (map.contains_key("total_sector_count")
2034                    || map.contains_key("dirty_sector_count"));
2035            if is_coverage {
2036                map.remove("active_rebuild");
2037                map.remove("completed_dirty_sector_count");
2038                map.remove("mounted_sector_count");
2039                map.remove("rebuilding_sector_count");
2040                map.remove("resumed_sector_count");
2041                map.remove("reused_sector_count");
2042            }
2043            if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2044                *s = truncate_for_ultra_terse(s, 80);
2045            }
2046            if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2047                *s = truncate_for_ultra_terse(s, 80);
2048            }
2049            let new_map: serde_json::Map<String, serde_json::Value> = map
2050                .into_iter()
2051                .map(|(k, v)| (k, ultra_terse_transform(v)))
2052                .collect();
2053            serde_json::Value::Object(new_map)
2054        }
2055        serde_json::Value::Array(arr) => {
2056            serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2057        }
2058        other => other,
2059    }
2060}
2061
2062fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2063    match val {
2064        serde_json::Value::Object(mut map) => {
2065            let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2066                nodes.as_array().map(|arr| {
2067                    arr.iter()
2068                        .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2069                        .collect()
2070                })
2071            });
2072            if let Some(ref ids) = node_ids {
2073                let id_map: std::collections::HashMap<&str, usize> = ids
2074                    .iter()
2075                    .enumerate()
2076                    .map(|(i, id)| (id.as_str(), i))
2077                    .collect();
2078                if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2079                    for edge in edges.iter_mut() {
2080                        if let serde_json::Value::Object(edge_map) = edge {
2081                            if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id") {
2082                                if let Some(&idx) = id_map.get(fid.as_str()) {
2083                                    edge_map.insert("from".to_string(), serde_json::Value::Number(idx.into()));
2084                                } else {
2085                                    edge_map.insert("from_id".to_string(), serde_json::Value::String(fid));
2086                                }
2087                            }
2088                            if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2089                                if let Some(&idx) = id_map.get(tid.as_str()) {
2090                                    edge_map.insert("to".to_string(), serde_json::Value::Number(idx.into()));
2091                                } else {
2092                                    edge_map.insert("to_id".to_string(), serde_json::Value::String(tid));
2093                                }
2094                            }
2095                        }
2096                    }
2097                }
2098            }
2099            let new_map: serde_json::Map<String, serde_json::Value> = map
2100                .into_iter()
2101                .map(|(k, v)| (k, edge_index_transform(v)))
2102                .collect();
2103            serde_json::Value::Object(new_map)
2104        }
2105        serde_json::Value::Array(arr) => {
2106            serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2107        }
2108        other => other,
2109    }
2110}
2111
2112fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2113    if s.len() <= max_len {
2114        s.to_string()
2115    } else {
2116        let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2117        format!("{truncated}...")
2118    }
2119}
2120
2121fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2122    let mut keys = HashSet::new();
2123    collect_terse_keys(val, &mut keys);
2124    let mut schema = serde_json::Map::new();
2125    for (long, short) in TERSE_PAIRS {
2126        if keys.contains(*short) {
2127            schema.insert(
2128                short.to_string(),
2129                serde_json::Value::String(long.to_string()),
2130            );
2131        }
2132    }
2133    serde_json::Value::Object(schema)
2134}
2135
2136fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2137    match val {
2138        serde_json::Value::Object(map) => {
2139            for (k, v) in map {
2140                keys.insert(k.clone());
2141                collect_terse_keys(v, keys);
2142            }
2143        }
2144        serde_json::Value::Array(arr) => {
2145            for v in arr {
2146                collect_terse_keys(v, keys);
2147            }
2148        }
2149        _ => {}
2150    }
2151}
2152
2153fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2154    match val {
2155        serde_json::Value::Array(arr) if arr.len() >= 2 => {
2156            if let Some(cols) = homogeneous_keys(&arr) {
2157                let rows: Vec<serde_json::Value> = arr
2158                    .into_iter()
2159                    .map(|item| {
2160                        if let serde_json::Value::Object(map) = item {
2161                            let vals: Vec<serde_json::Value> = cols
2162                                .iter()
2163                                .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2164                                .collect();
2165                            serde_json::Value::Array(vals)
2166                        } else {
2167                            item
2168                        }
2169                    })
2170                    .collect();
2171                let col_vals: Vec<serde_json::Value> =
2172                    cols.into_iter().map(serde_json::Value::String).collect();
2173                serde_json::json!({"_c": col_vals, "_r": rows})
2174            } else {
2175                serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2176            }
2177        }
2178        serde_json::Value::Array(arr) => {
2179            serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2180        }
2181        serde_json::Value::Object(map) => {
2182            let new_map: serde_json::Map<String, serde_json::Value> = map
2183                .into_iter()
2184                .map(|(k, v)| (k, schema_transform(v)))
2185                .collect();
2186            serde_json::Value::Object(new_map)
2187        }
2188        other => other,
2189    }
2190}
2191
2192fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2193    let first = arr.first()?.as_object()?;
2194    let keys: Vec<String> = first.keys().cloned().collect();
2195    for item in &arr[1..] {
2196        let obj = item.as_object()?;
2197        if obj.len() != keys.len() {
2198            return None;
2199        }
2200        for k in &keys {
2201            if !obj.contains_key(k) {
2202                return None;
2203            }
2204        }
2205    }
2206    Some(keys)
2207}
2208
2209const TERSE_PAIRS: &[(&str, &str)] = &[
2210    ("name", "n"),
2211    ("kind", "k"),
2212    ("file", "f"),
2213    ("line", "l"),
2214    ("path", "p"),
2215    ("from", "fr"),
2216    ("type", "ty"),
2217    ("text", "tx"),
2218    ("new", "nw"),
2219    ("run", "r"),
2220    ("use", "u"),
2221    ("score", "sc"),
2222    ("language", "la"),
2223    ("status", "st"),
2224    ("state", "stt"),
2225    ("error", "err"),
2226    ("errors", "ers"),
2227    ("hops", "hp"),
2228    ("tags", "tg"),
2229    ("model", "ml"),
2230    ("skill", "sk"),
2231    ("count", "ct"),
2232    ("total", "tot"),
2233    ("column", "col"),
2234    ("description", "dsc"),
2235    ("end_line", "el"),
2236    ("signature", "sig"),
2237    ("parent_module", "pm"),
2238    ("visibility", "vis"),
2239    ("match_type", "mt"),
2240    ("caller_file", "cf"),
2241    ("caller_name", "cn"),
2242    ("caller_line", "cl"),
2243    ("callee_name", "en"),
2244    ("call_site_line", "csl"),
2245    ("members", "m"),
2246    ("refs", "refs"),
2247    ("role", "rl"),
2248    ("peer", "pr"),
2249    ("modularity", "q"),
2250    ("modularity_contribution", "mc"),
2251    ("iterations", "it"),
2252    ("node_count", "nc"),
2253    ("edge_count", "ec"),
2254    ("community_count", "cc"),
2255    ("communities", "cms"),
2256    ("community", "cm"),
2257    ("community_diagnostics", "cd"),
2258    ("cache_hit", "cah"),
2259    ("tagpath_state", "tps"),
2260    ("tagpath_stale_reason", "tsr"),
2261    ("annotated_community_count", "acc"),
2262    ("annotated_member_count", "amc"),
2263    ("ambiguous_member_count", "ambc"),
2264    ("ambiguous_members", "amb"),
2265    ("candidate_count", "cand"),
2266    ("tagpath_candidate_count", "tcand"),
2267    ("evidence", "ev"),
2268    ("chosen_file", "chf"),
2269    ("symbol", "s"),
2270    ("symbols", "sy"),
2271    ("definitions", "df"),
2272    ("callers", "crs"),
2273    ("callees", "ces"),
2274    ("total_tracked", "tt"),
2275    ("modified", "md"),
2276    ("deleted", "dl"),
2277    ("unchanged", "uc"),
2278    ("changes", "ch"),
2279    ("prune_stats", "ps"),
2280    ("hits", "h"),
2281    ("rank", "rk"),
2282    ("snippet", "sn"),
2283    ("confidence", "co"),
2284    ("index", "ix"),
2285    ("summaries", "sms"),
2286    ("recommendations", "rec"),
2287    ("total_files", "tf"),
2288    ("stale_files", "sf"),
2289    ("last_indexed_secs_ago", "age"),
2290    ("cached_files", "caf"),
2291    ("total_indexed_files", "tif"),
2292    ("coverage_pct", "cov"),
2293    ("symbol_name", "syn"),
2294    ("file_path", "fp"),
2295    ("content_hash", "hsh"),
2296    ("summary", "sum"),
2297    ("tool", "tl"),
2298    ("view", "vw"),
2299    ("truncated", "tr"),
2300    ("follow_up", "fu"),
2301    ("report", "rp"),
2302    ("metrics", "ms"),
2303    ("label", "lb"),
2304    ("value", "v"),
2305    ("command", "cmd"),
2306    ("exit_code", "xc"),
2307    ("success", "ok"),
2308    ("artifact", "art"),
2309    ("digest", "dg"),
2310    ("bytes", "bt"),
2311    ("lines", "lns"),
2312    ("expand", "xp"),
2313    ("entities", "ent"),
2314    ("relationships", "rel"),
2315    ("concept_labels", "cls"),
2316    ("extracted_at", "at"),
2317    ("tokens_input", "ti"),
2318    ("tokens_output", "tout"),
2319    ("total_summaries", "ts"),
2320    ("stale_count", "stc"),
2321    ("total_tokens_input", "tti"),
2322    ("total_tokens_output", "tto"),
2323    ("estimated_tokens_saved", "ets"),
2324    ("files_processed", "fps"),
2325    ("symbols_extracted", "se"),
2326    ("skills_dir", "sd"),
2327    ("healthy", "ok"),
2328    ("broken", "brk"),
2329    ("skills", "sks"),
2330    ("manifest_diffs", "mdf"),
2331    ("similar_pairs", "sim"),
2332    ("usage", "usg"),
2333    ("cleanup", "cln"),
2334    ("has_skill_md", "hsm"),
2335    ("is_symlink", "isl"),
2336    ("issues", "iss"),
2337    ("invocation_count", "inv"),
2338    ("reasons", "rsn"),
2339    ("token_estimate", "te"),
2340    ("skill_a", "sa"),
2341    ("skill_b", "sb"),
2342    ("desc_a", "da"),
2343    ("desc_b", "db"),
2344    ("annotations", "ann"),
2345    ("entity", "ety"),
2346    ("suggestion", "sug"),
2347    ("columns", "cols"),
2348    ("row_count", "rc"),
2349    ("notnull", "nn"),
2350    ("default_value", "dv"),
2351    ("replace_all", "ra"),
2352];
2353
2354pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
2355    let root_str = root.to_string_lossy();
2356    let prefix = format!("{}/", root_str.trim_end_matches('/'));
2357    path.strip_prefix(&prefix).unwrap_or(path).to_string()
2358}
2359
2360fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
2361    let canonical = path
2362        .canonicalize()
2363        .with_context(|| format!("canonicalizing {}", path.display()))?;
2364    let start = if canonical.is_dir() {
2365        canonical.clone()
2366    } else {
2367        canonical
2368            .parent()
2369            .map(Path::to_path_buf)
2370            .unwrap_or_else(|| canonical.clone())
2371    };
2372
2373    for ancestor in start.ancestors() {
2374        if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
2375            return Ok(ancestor.to_path_buf());
2376        }
2377    }
2378
2379    Ok(start)
2380}
2381
2382pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
2383    path.strip_prefix(root)
2384        .map(|p| p.to_path_buf())
2385        .unwrap_or_else(|_| path.to_path_buf())
2386}
2387
2388pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
2389    for edge in edges {
2390        edge.caller_file = relativize(&edge.caller_file, root);
2391    }
2392}
2393
2394pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
2395    for sym in symbols {
2396        sym.file = relativize(&sym.file, root);
2397    }
2398}
2399
2400pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
2401    for hit in hits {
2402        hit.file = relativize(&hit.file, root);
2403    }
2404}
2405
2406
2407/// Which endpoint of a `StoredEdge` is the row's primary symbol — caller
2408/// (caller list) or callee (callee list).
2409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2410pub enum EdgeSide {
2411    Caller,
2412    Callee,
2413}
2414
2415const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
2416
2417pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
2418    let root_str = root.to_string_lossy();
2419    let prefix = format!("{}/", root_str.trim_end_matches('/'));
2420    relativize_json_inner(val, &prefix);
2421}
2422
2423fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
2424    match val {
2425        serde_json::Value::Array(arr) => {
2426            for v in arr {
2427                relativize_json_inner(v, prefix);
2428            }
2429        }
2430        serde_json::Value::Object(map) => {
2431            for (k, v) in map.iter_mut() {
2432                if JSON_PATH_KEYS.contains(&k.as_str())
2433                    && let serde_json::Value::String(s) = v
2434                    && let Some(rest) = s.strip_prefix(prefix)
2435                {
2436                    *s = rest.to_string();
2437                }
2438                relativize_json_inner(v, prefix);
2439            }
2440        }
2441        _ => {}
2442    }
2443}
2444
2445pub(crate) fn format_score(score: f64, compact: bool) -> String {
2446    if compact {
2447        format!("{score:.2}")
2448    } else {
2449        format!("{score:.4}")
2450    }
2451}
2452
2453pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
2454    let trimmed = input.trim();
2455    let count = trimmed.chars().count();
2456    if count <= max_chars {
2457        return trimmed.to_string();
2458    }
2459    let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
2460    format!("{prefix}...")
2461}
2462
2463pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
2464    snippet
2465        .lines()
2466        .find(|line| !line.trim().is_empty())
2467        .map(|line| truncate_for_compact(line, 100))
2468}
2469
2470pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
2471    let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
2472    if names.len() <= limit {
2473        return names.join(", ");
2474    }
2475    format!(
2476        "{} (+{} more)",
2477        names[..limit].join(", "),
2478        names.len() - limit
2479    )
2480}
2481
2482pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
2483    let mut hasher = blake3::Hasher::new();
2484    hasher.update(prefix.as_bytes());
2485    hasher.update(&[0]);
2486    hasher.update(key.as_bytes());
2487    let hex = hasher.finalize().to_hex();
2488    format!("{prefix}-{}", &hex[..10])
2489}
2490
2491#[derive(Clone, Debug, PartialEq, Eq)]
2492struct CanonicalTagFamily {
2493    canonical: String,
2494    tag_alias: String,
2495}
2496
2497fn canonical_family_from_tagpath_family(
2498    family: tagpath_family::TagFamily,
2499) -> Option<CanonicalTagFamily> {
2500    let tag_alias = if family.dimensions.is_empty() {
2501        family.tags.join("/")
2502    } else {
2503        family
2504            .dimensions
2505            .iter()
2506            .filter(|dimension| !dimension.tags.is_empty())
2507            .map(|dimension| dimension.tags.join("."))
2508            .collect::<Vec<_>>()
2509            .join("/")
2510    };
2511
2512    if tag_alias.is_empty() {
2513        None
2514    } else {
2515        Some(CanonicalTagFamily {
2516            canonical: family.canonical,
2517            tag_alias,
2518        })
2519    }
2520}
2521
2522fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
2523    let trimmed = name.trim();
2524    if trimmed.is_empty() {
2525        return None;
2526    }
2527
2528    canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
2529}
2530
2531fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
2532    let canonical = tags
2533        .split(',')
2534        .map(str::trim)
2535        .filter(|tag| !tag.is_empty())
2536        .collect::<Vec<_>>()
2537        .join("_");
2538    if canonical.is_empty() {
2539        None
2540    } else {
2541        canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
2542    }
2543}
2544
2545pub(crate) fn canonical_tag_family_from_symbol(name: &str, tags: Option<&str>) -> Option<CanonicalTagFamily> {
2546    tags.and_then(canonical_tag_family_from_tags)
2547        .or_else(|| canonical_tag_family_from_name(name))
2548}
2549
2550fn tag_alias_from_name(name: &str) -> Option<String> {
2551    canonical_tag_family_from_name(name).map(|family| family.tag_alias)
2552}
2553
2554fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
2555    canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
2556}
2557
2558pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
2559    let query = tag_alias
2560        .split(['/', '.'])
2561        .map(str::trim)
2562        .filter(|part| !part.is_empty())
2563        .collect::<Vec<_>>()
2564        .join(" ");
2565    if query.is_empty() { None } else { Some(query) }
2566}
2567
2568#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2569struct CompactOntologyRefPreview {
2570    handle: String,
2571    tag: String,
2572    path: String,
2573    #[serde(skip_serializing_if = "Option::is_none")]
2574    title: Option<String>,
2575    #[serde(skip_serializing_if = "Option::is_none")]
2576    domain: Option<String>,
2577}
2578
2579#[derive(Clone, Debug)]
2580struct TagOntologyPreviewContext {
2581    project_root: PathBuf,
2582    tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
2583}
2584
2585#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2586struct CompactSymbolRefPreview {
2587    handle: String,
2588    name: String,
2589    #[serde(skip_serializing_if = "Option::is_none")]
2590    tag_alias: Option<String>,
2591    #[serde(skip_serializing_if = "Vec::is_empty", default)]
2592    ontology_refs: Vec<CompactOntologyRefPreview>,
2593}
2594
2595fn build_compact_symbol_ref(
2596    prefix: &str,
2597    key: &str,
2598    name: &str,
2599    tags: Option<&str>,
2600    max_bytes: usize,
2601) -> CompactSymbolRefPreview {
2602    build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
2603}
2604
2605fn build_compact_symbol_ref_with_ontology(
2606    prefix: &str,
2607    key: &str,
2608    name: &str,
2609    tags: Option<&str>,
2610    max_bytes: usize,
2611    ontology: Option<&TagOntologyPreviewContext>,
2612) -> CompactSymbolRefPreview {
2613    let tag_alias = tag_alias_from_tags(name, tags);
2614    let ontology_refs = tag_alias
2615        .as_deref()
2616        .map(|alias| ontology_refs_for_alias(ontology, alias))
2617        .unwrap_or_default();
2618    CompactSymbolRefPreview {
2619        handle: stable_handle(prefix, key),
2620        name: truncate_for_budget(name, max_bytes),
2621        tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
2622        ontology_refs,
2623    }
2624}
2625
2626fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
2627    let report = tagpath_ontology::load_project(root).ok()?;
2628    if report.tags.is_empty() {
2629        return None;
2630    }
2631    Some(TagOntologyPreviewContext {
2632        project_root: report.project_path,
2633        tags: report
2634            .tags
2635            .into_iter()
2636            .map(|tag| (tag.tag.clone(), tag))
2637            .collect(),
2638    })
2639}
2640
2641fn ontology_refs_for_alias(
2642    ontology: Option<&TagOntologyPreviewContext>,
2643    alias: &str,
2644) -> Vec<CompactOntologyRefPreview> {
2645    let Some(ontology) = ontology else {
2646        return Vec::new();
2647    };
2648    let mut seen = BTreeSet::new();
2649    alias
2650        .split('/')
2651        .flat_map(|part| part.split('.'))
2652        .map(str::trim)
2653        .filter(|tag| !tag.is_empty())
2654        .filter_map(|tag| {
2655            let key = tag.to_ascii_lowercase();
2656            if !seen.insert(key.clone()) {
2657                return None;
2658            }
2659            let ontology_tag = ontology.tags.get(&key)?;
2660            let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
2661            Some(CompactOntologyRefPreview {
2662                handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
2663                tag: ontology_tag.tag.clone(),
2664                path,
2665                title: ontology_tag.title.clone(),
2666                domain: ontology_tag.domain.clone(),
2667            })
2668        })
2669        .collect()
2670}
2671
2672fn relativize_ontology_path(path: &Path, root: &Path) -> String {
2673    path.strip_prefix(root)
2674        .unwrap_or(path)
2675        .to_string_lossy()
2676        .replace('\\', "/")
2677}
2678
2679fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
2680    match tag_alias {
2681        Some(alias) => format!("{handle} {name} tag:{alias}"),
2682        None => format!("{handle} {name}"),
2683    }
2684}
2685
2686fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
2687    match summary.tag_alias.as_deref() {
2688        Some(alias) => format!(
2689            "{} {} tag:{} expand:{}",
2690            summary.handle, summary.symbol, alias, summary.expand
2691        ),
2692        None => format!(
2693            "{} {} expand:{}",
2694            summary.handle, summary.symbol, summary.expand
2695        ),
2696    }
2697}
2698
2699fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
2700    match symbol.tag_alias.as_deref() {
2701        Some(alias) => format!("{}@{}", symbol.handle, alias),
2702        None => format!("{}@{}", symbol.handle, symbol.name),
2703    }
2704}
2705
2706pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
2707    let trimmed = input.trim();
2708    if trimmed.len() <= max_bytes {
2709        return trimmed.to_string();
2710    }
2711    if max_bytes <= 3 {
2712        return ".".repeat(max_bytes);
2713    }
2714
2715    let mut end = 0usize;
2716    for (idx, ch) in trimmed.char_indices() {
2717        let next = idx + ch.len_utf8();
2718        if next > max_bytes.saturating_sub(3) {
2719            break;
2720        }
2721        end = next;
2722    }
2723
2724    if end == 0 {
2725        "...".to_string()
2726    } else {
2727        format!("{}...", &trimmed[..end])
2728    }
2729}
2730
2731struct TokenCappedPreview {
2732    preview: Vec<SourceLinePreview>,
2733    capped_end: usize,
2734    was_capped: bool,
2735}
2736
2737fn build_token_capped_preview(
2738    all_lines: &[&str],
2739    start: usize,
2740    end: usize,
2741    max_bytes: usize,
2742    token_cap: usize,
2743) -> TokenCappedPreview {
2744    let mut preview = Vec::new();
2745    let mut accumulated_tokens = 0usize;
2746    let mut capped_end = end;
2747    let mut was_capped = false;
2748
2749    for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
2750        let truncated = truncate_for_budget(line, max_bytes);
2751        let line_tokens = estimated_tokens_from_bytes(truncated.len());
2752        if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
2753            capped_end = start + idx - 1;
2754            was_capped = true;
2755            break;
2756        }
2757        accumulated_tokens += line_tokens;
2758        preview.push(SourceLinePreview {
2759            line: start + idx,
2760            text: truncated,
2761        });
2762    }
2763
2764    TokenCappedPreview {
2765        preview,
2766        capped_end,
2767        was_capped,
2768    }
2769}
2770
2771pub(crate) fn abbreviate_kind(kind: &str) -> &str {
2772    match kind {
2773        "function" => "fn",
2774        "method" => "meth",
2775        "module" | "mod" => "mod",
2776        "struct" => "struct",
2777        "trait" => "trait",
2778        "impl" => "impl",
2779        "class" => "cls",
2780        "interface" => "iface",
2781        "type_alias" => "type",
2782        "data_class" => "data_cls",
2783        "sealed_class" => "sealed_cls",
2784        "enum_class" => "enum_cls",
2785        "companion_object" => "comp_obj",
2786        "object" => "obj",
2787        "heading" => "h",
2788        "code_block" => "code",
2789        "alias" => "alias",
2790        other => other,
2791    }
2792}
2793
2794pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
2795    match kind {
2796        "calls" => "c",
2797        "defines" => "d",
2798        "contains" => "ct",
2799        "imports" => "i",
2800        "mentions" => "m",
2801        "mentions_concept" => "mc",
2802        "mentions_entity" => "me",
2803        "semantic_relation" => "sr",
2804        "belongs_to" => "bt",
2805        "scopes_context" => "sctx",
2806        "scopes_source" => "ssrc",
2807        "requests_context" => "rctx",
2808        "explains_result" => "er",
2809        "tagged_concept" => "tc",
2810        "tagged_entity" => "te",
2811        "related_concept" => "relc",
2812        "handled_by" => "hb",
2813        "defines_route" => "dr",
2814        "handles_route" => "hr",
2815        "targets" => "tgt",
2816        "has_vector_handle" => "hv",
2817        "parent" => "p",
2818        "child" => "ch",
2819        "uses" => "u",
2820        "projects_source" => "psrc",
2821        "records_memory_source" => "rms",
2822        "records_memory_event" => "rme",
2823        "has_ast_span" => "ha",
2824        "represents_symbol" => "rs",
2825        "contains_embedded_symbol" => "ces",
2826        "embedded_in_fence" => "ef",
2827        "contains_markdown_block" => "cmb",
2828        "contains_embedded_code" => "cec",
2829        "enclosing_module" => "em",
2830        "enclosing_section" => "es",
2831        "previous_sibling" => "psib",
2832        "next_sibling" => "nsib",
2833        "explicit_depends_on" => "edo",
2834        "worker_result_follow_up" => "wrf",
2835        "shared_resource" => "shr",
2836        "community_member" => "cm",
2837        other => other,
2838    }
2839}
2840
2841pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
2842    match mt {
2843        "exact_name" => "exact",
2844        "all_tags" => "all_tags",
2845        "partial_tags" => "partial",
2846        other => other,
2847    }
2848}
2849
2850pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
2851    path.iter()
2852        .map(|n| n.name.as_str())
2853        .collect::<Vec<_>>()
2854        .join(" -> ")
2855}
2856
2857const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
2858
2859struct SearchHitGroup {
2860    path: String,
2861    first_rank: usize,
2862    top_score: f64,
2863    confidence: String,
2864    hits: usize,
2865    samples: Vec<String>,
2866}
2867
2868fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
2869    let snippet = compact_snippet(&hit.snippet)?;
2870    Some(match hit.location.as_deref() {
2871        Some(location) => format!("{location}: {snippet}"),
2872        None => snippet,
2873    })
2874}
2875
2876pub(crate) fn group_search_hits(
2877    hits: &[sift::SearchHit],
2878    root: &Path,
2879    absolute: bool,
2880) -> Vec<SearchHitGroup> {
2881    let mut positions = BTreeMap::new();
2882    let mut groups = Vec::new();
2883    for hit in hits {
2884        let path = if absolute {
2885            hit.path.clone()
2886        } else {
2887            relativize(&hit.path, root)
2888        };
2889        let entry = positions.entry(path.clone()).or_insert_with(|| {
2890            groups.push(SearchHitGroup {
2891                path: path.clone(),
2892                first_rank: hit.rank,
2893                top_score: hit.score,
2894                confidence: format!("{:?}", hit.confidence),
2895                hits: 0,
2896                samples: Vec::new(),
2897            });
2898            groups.len() - 1
2899        });
2900        let group = &mut groups[*entry];
2901        group.hits += 1;
2902        if hit.rank < group.first_rank {
2903            group.first_rank = hit.rank;
2904        }
2905        if hit.score > group.top_score {
2906            group.top_score = hit.score;
2907        }
2908        if let Some(sample) = format_search_sample(hit)
2909            && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
2910            && !group.samples.contains(&sample)
2911        {
2912            group.samples.push(sample);
2913        }
2914    }
2915    groups.sort_by_key(|group| group.first_rank);
2916    groups
2917}
2918
2919pub(crate) fn should_collapse_search_hits(
2920    hits: &[sift::SearchHit],
2921    root: &Path,
2922    absolute: bool,
2923) -> bool {
2924    let groups = group_search_hits(hits, root, absolute);
2925    let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
2926    max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
2927}
2928
2929pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
2930    let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
2931    for edge in edges {
2932        let key = edge.caller_file.as_str();
2933        let name = if use_callers {
2934            edge.caller_name.as_str()
2935        } else {
2936            edge.callee_name.as_str()
2937        };
2938        let names = grouped.entry(key).or_default();
2939        if !names.contains(&name) {
2940            names.push(name);
2941        }
2942    }
2943
2944    grouped
2945        .into_iter()
2946        .map(|(file, names)| format!("  {} ({}): {}", file, names.len(), names.join(", ")))
2947        .collect()
2948}
2949
2950pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
2951    let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
2952    for edge in edges {
2953        *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
2954    }
2955    let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
2956    max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
2957}
2958
2959
2960fn resolve_query_index_target(
2961    root: &Path,
2962    path_hint: &Path,
2963    scope: Option<&str>,
2964) -> Result<SearchIndexTarget> {
2965    let cfg = config::Config::load(root)?;
2966    if let Some(scope_name) = scope {
2967        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
2968            return Ok(SearchIndexTarget {
2969                label: format!("submodule `{}` index", scope.id),
2970                db_path: cfg.db_path_for(root, &scope.id),
2971                source_root: scope.source_root.clone(),
2972                scope_name: Some(scope.id.clone()),
2973                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
2974            });
2975        }
2976        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
2977            return Ok(cargo_package_index_target(root, package));
2978        }
2979        config::Config::resolve_submodule(root, scope_name)?;
2980    }
2981
2982    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
2983        return Ok(SearchIndexTarget {
2984            label: format!("submodule `{}` index", scope.id),
2985            db_path: cfg.db_path_for(root, &scope.id),
2986            source_root: scope.source_root.clone(),
2987            scope_name: Some(scope.id.clone()),
2988            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
2989        });
2990    }
2991
2992    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
2993        return Ok(cargo_package_index_target(root, package));
2994    }
2995
2996    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
2997        return Ok(SearchIndexTarget {
2998            label: format!("submodule `{}` index", scope.id),
2999            db_path: cfg.db_path_for(root, &scope.id),
3000            source_root: scope.source_root.clone(),
3001            scope_name: Some(scope.id.clone()),
3002            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3003        });
3004    }
3005
3006    let db_path = root.join(".tsift/index.db");
3007    if db_path.exists() {
3008        return Ok(SearchIndexTarget {
3009            label: "index".to_string(),
3010            db_path,
3011            source_root: root.to_path_buf(),
3012            scope_name: None,
3013            reindex_cmd: format!("tsift index {}", root.display()),
3014        });
3015    }
3016
3017    let scopes = config::Config::submodule_dirs(root)?;
3018    if scopes.is_empty() {
3019        return Ok(SearchIndexTarget {
3020            label: "index".to_string(),
3021            db_path,
3022            source_root: root.to_path_buf(),
3023            scope_name: None,
3024            reindex_cmd: format!("tsift index {}", root.display()),
3025        });
3026    }
3027
3028    let available_scopes = scopes
3029        .iter()
3030        .map(|scope| scope.id.as_str())
3031        .collect::<Vec<_>>()
3032        .join(", ");
3033    let indexed_scopes = scopes
3034        .iter()
3035        .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3036        .map(|scope| scope.id.as_str())
3037        .collect::<Vec<_>>();
3038    let indexed_label = if indexed_scopes.is_empty() {
3039        "none".to_string()
3040    } else {
3041        indexed_scopes.join(", ")
3042    };
3043
3044    bail!(
3045        "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: {}.",
3046        root.display(),
3047        db_path.display(),
3048        available_scopes,
3049        indexed_label
3050    );
3051}
3052
3053pub(crate) fn resolve_query_db_path(root: &Path, path_hint: &Path, scope: Option<&str>) -> Result<PathBuf> {
3054    Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3055}
3056
3057fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3058    let state = inspect_search_index(target)?;
3059    let Some(reason) = index_reason_for_state(state) else {
3060        return Ok(());
3061    };
3062
3063    match apply_search_index_update(root, target) {
3064        Ok(_) => {
3065            index::inspect_scope_invalidate_all();
3066            Ok(())
3067        }
3068        Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3069            eprintln!(
3070                "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3071                 Continuing with the current read-only index snapshot; graph results may lag. \
3072                 Retry `{}` after the active writer finishes for fresh graph results.",
3073                index_reason_detail(target, reason),
3074                target.reindex_cmd
3075            );
3076            Ok(())
3077        }
3078        Err(err) => Err(err),
3079    }
3080}
3081
3082pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3083    let root = lint::resolve_project_root_or_canonical_path(path)?;
3084    let target = resolve_query_index_target(&root, path, scope)?;
3085    ensure_query_index_current(&root, &target)?;
3086    let db_path = target.db_path;
3087    if !db_path.exists() {
3088        bail!(
3089            "no index found at {}. Run `tsift index` first.",
3090            db_path.display()
3091        );
3092    }
3093    index::IndexDb::open_read_only_resilient(&db_path)
3094}
3095
3096pub(crate) fn query_tagpath_root(
3097    root: &std::path::Path,
3098    path_hint: &std::path::Path,
3099    scope: Option<&str>,
3100) -> Result<PathBuf> {
3101    if let Some(scope_name) = scope {
3102        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3103            return Ok(scope.source_root);
3104        }
3105        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3106            return Ok(package.package_root);
3107        }
3108        config::Config::resolve_submodule(root, scope_name)?;
3109    }
3110    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3111        return Ok(scope.source_root);
3112    }
3113    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3114        return Ok(package.package_root);
3115    }
3116    Ok(root.to_path_buf())
3117}
3118
3119#[derive(Clone, Debug, Serialize, PartialEq)]
3120struct TraversalNode {
3121    handle: String,
3122    kind: String,
3123    label: String,
3124    #[serde(skip_serializing_if = "Option::is_none")]
3125    ref_id: Option<String>,
3126    #[serde(skip_serializing_if = "Option::is_none")]
3127    path: Option<String>,
3128    #[serde(skip_serializing_if = "Option::is_none")]
3129    line: Option<i64>,
3130    #[serde(skip_serializing_if = "Option::is_none")]
3131    detail: Option<String>,
3132    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3133    properties: BTreeMap<String, String>,
3134    expand: String,
3135}
3136
3137#[derive(Clone, Debug, Serialize, PartialEq)]
3138struct TraversalEdge {
3139    from: String,
3140    to: String,
3141    relation: String,
3142    #[serde(skip_serializing_if = "Option::is_none")]
3143    label: Option<String>,
3144    weight: usize,
3145}
3146
3147#[derive(Clone, Debug, Default)]
3148struct TraversalGraphBuild {
3149    nodes: BTreeMap<String, TraversalNode>,
3150    edges: Vec<TraversalEdge>,
3151    edge_keys: BTreeSet<(String, String, String)>,
3152    warnings: Vec<String>,
3153}
3154
3155pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3156const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3157const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3158const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3159const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3160    "context-pack-graph-orchestration-v1";
3161const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3162const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3163const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3164const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3165const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3166const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3167const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3168
3169#[derive(Debug, Serialize, PartialEq)]
3170struct TraversalTotals {
3171    nodes: usize,
3172    edges: usize,
3173}
3174
3175#[derive(Debug, Serialize, PartialEq)]
3176struct TraversalPathReport {
3177    from: TraversalNode,
3178    to: TraversalNode,
3179    hops: usize,
3180    nodes: Vec<TraversalNode>,
3181    edges: Vec<TraversalEdge>,
3182}
3183
3184#[derive(Debug, Serialize, PartialEq)]
3185struct TraversalRecommendation {
3186    handle: String,
3187    kind: String,
3188    label: String,
3189    reason: String,
3190    score: usize,
3191    expand: String,
3192}
3193
3194#[derive(Debug, Serialize, PartialEq)]
3195struct TraversalReport {
3196    root: String,
3197    #[serde(skip_serializing_if = "Option::is_none")]
3198    scope: Option<String>,
3199    mode: String,
3200    totals: TraversalTotals,
3201    #[serde(skip_serializing_if = "Option::is_none")]
3202    query: Option<String>,
3203    #[serde(skip_serializing_if = "Option::is_none")]
3204    target: Option<String>,
3205    nodes: Vec<TraversalNode>,
3206    edges: Vec<TraversalEdge>,
3207    #[serde(skip_serializing_if = "Option::is_none")]
3208    shortest_path: Option<TraversalPathReport>,
3209    recommendations: Vec<TraversalRecommendation>,
3210    exploration: ExplorationPacket,
3211    truncated: bool,
3212    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3213    warnings: Vec<String>,
3214}
3215
3216#[derive(Debug, Serialize, PartialEq)]
3217struct SemanticRelatedReport {
3218    root: String,
3219    #[serde(skip_serializing_if = "Option::is_none")]
3220    scope: Option<String>,
3221    query: String,
3222    embedding_model: String,
3223    count: usize,
3224    items: Vec<SemanticRelatedItem>,
3225    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3226    warnings: Vec<String>,
3227}
3228
3229#[derive(Clone, Debug, Serialize, PartialEq)]
3230struct SemanticRelatedItem {
3231    handle: String,
3232    kind: String,
3233    label: String,
3234    score: f64,
3235    #[serde(skip_serializing_if = "Option::is_none")]
3236    file_path: Option<String>,
3237    #[serde(skip_serializing_if = "Option::is_none")]
3238    source_symbol: Option<String>,
3239    #[serde(skip_serializing_if = "Option::is_none")]
3240    detail: Option<String>,
3241    expand: String,
3242}
3243
3244#[derive(Clone)]
3245struct TraversalSymbolIndexEntry {
3246    handle: String,
3247    node: TraversalNode,
3248    tokens: BTreeSet<String>,
3249}
3250
3251#[derive(Clone)]
3252struct TraversalFileIndexEntry {
3253    handle: String,
3254    node: TraversalNode,
3255    tokens: BTreeSet<String>,
3256}
3257
3258#[derive(Clone)]
3259struct TraversalRouteIndexEntry {
3260    handle: String,
3261    node: TraversalNode,
3262    tokens: BTreeSet<String>,
3263}
3264
3265#[derive(Clone)]
3266struct TraversalAstSpanIndexEntry {
3267    handle: String,
3268    symbol_handle: String,
3269    file_handle: Option<String>,
3270    file: String,
3271    name: String,
3272    kind: String,
3273    language: String,
3274    node_kind: String,
3275    start_byte: usize,
3276    end_byte: usize,
3277    parent_module: Option<String>,
3278    markdown: Option<MarkdownSpanMetadata>,
3279}
3280
3281#[derive(Clone)]
3282struct TraversalMultiplicityIndexEntry {
3283    handle: String,
3284    node: TraversalNode,
3285    tokens: BTreeSet<String>,
3286}
3287
3288struct TraversalCodeLookup<'a> {
3289    symbols: &'a [TraversalSymbolIndexEntry],
3290    files: &'a [TraversalFileIndexEntry],
3291    routes: &'a [TraversalRouteIndexEntry],
3292    multiplicities: &'a [TraversalMultiplicityIndexEntry],
3293    symbol_index: HashMap<String, Vec<usize>>,
3294    file_index: HashMap<String, Vec<usize>>,
3295    route_index: HashMap<String, Vec<usize>>,
3296    multiplicity_index: HashMap<String, Vec<usize>>,
3297    file_path_index: HashMap<String, String>,
3298}
3299
3300#[derive(Clone, Debug, Serialize, PartialEq)]
3301struct ExplorationBudget {
3302    project_size: String,
3303    max_source_windows: usize,
3304    lines_per_window: usize,
3305    relationship_limit: usize,
3306}
3307
3308#[derive(Clone, Debug, Serialize, PartialEq)]
3309struct ExplorationRelation {
3310    from: String,
3311    relation: String,
3312    to: String,
3313    #[serde(skip_serializing_if = "Option::is_none")]
3314    label: Option<String>,
3315}
3316
3317#[derive(Clone, Debug, Serialize, PartialEq)]
3318struct ExplorationSourceWindow {
3319    handle: String,
3320    file: String,
3321    start: usize,
3322    end: usize,
3323    reason: String,
3324    expand: String,
3325}
3326
3327#[derive(Clone, Debug, Serialize, PartialEq)]
3328struct ExplorationWorkerContext {
3329    handle: String,
3330    target: String,
3331    summary: String,
3332    expand: String,
3333}
3334
3335#[derive(Clone, Debug, Serialize, PartialEq)]
3336struct ExplorationPacket {
3337    budget: ExplorationBudget,
3338    relationship_map: Vec<ExplorationRelation>,
3339    source_windows: Vec<ExplorationSourceWindow>,
3340    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3341    worker_context: Vec<ExplorationWorkerContext>,
3342    no_reread_guidance: String,
3343}
3344
3345impl TraversalGraphBuild {
3346    fn add_node(&mut self, node: TraversalNode) {
3347        self.nodes.entry(node.handle.clone()).or_insert(node);
3348    }
3349
3350    fn add_edge(
3351        &mut self,
3352        from: &str,
3353        to: &str,
3354        relation: &str,
3355        label: Option<String>,
3356        weight: usize,
3357    ) {
3358        if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
3359            return;
3360        }
3361        let key = (from.to_string(), to.to_string(), relation.to_string());
3362        if self.edge_keys.insert(key) {
3363            self.edges.push(TraversalEdge {
3364                from: from.to_string(),
3365                to: to.to_string(),
3366                relation: relation.to_string(),
3367                label,
3368                weight,
3369            });
3370        }
3371    }
3372}
3373
3374pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
3375    match scope {
3376        Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
3377        None => root.join(".tsift/graph.db"),
3378    }
3379}
3380
3381fn graph_projection_meta_id(scope: Option<&str>) -> String {
3382    format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
3383}
3384
3385pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
3386    let bytes = serde_json::to_vec(value)?;
3387    Ok(blake3::hash(&bytes).to_hex().to_string())
3388}
3389
3390fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
3391    let mut hashable = node.clone();
3392    hashable.freshness = None;
3393    node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3394    Ok(node)
3395}
3396
3397fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
3398    let mut hashable = edge.clone();
3399    hashable.freshness = None;
3400    edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3401    Ok(edge)
3402}
3403
3404const SEMANTIC_EMBEDDING_DIM: usize = 32;
3405const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
3406const CLAUDE_MEM_GRAPH_LIMIT_PER_TABLE: usize = 200;
3407
3408fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
3409    match kind {
3410        SemanticRelatedKind::Concept => "concept",
3411        SemanticRelatedKind::Entity => "entity",
3412        SemanticRelatedKind::All => "all",
3413    }
3414}
3415
3416fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
3417    format!(
3418        "tsift semantic {} --path {} --kind {} --limit 10",
3419        shell_quote(query),
3420        shell_quote(root.to_string_lossy().as_ref()),
3421        semantic_related_kind_name(kind)
3422    )
3423}
3424
3425fn semantic_embedding(input: &str) -> Vec<f64> {
3426    let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
3427    let mut tokens = traversal_tokens(input);
3428    if tokens.is_empty() {
3429        let trimmed = input.trim().to_ascii_lowercase();
3430        if !trimmed.is_empty() {
3431            tokens.insert(trimmed);
3432        }
3433    }
3434
3435    for token in tokens {
3436        let hash = blake3::hash(token.as_bytes());
3437        let bytes = hash.as_bytes();
3438        let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
3439        let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
3440        vector[idx] += sign;
3441    }
3442
3443    let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
3444    if norm > 0.0 {
3445        for value in &mut vector {
3446            *value /= norm;
3447        }
3448    }
3449    vector
3450}
3451
3452fn semantic_embedding_property(input: &str) -> String {
3453    semantic_embedding(input)
3454        .iter()
3455        .map(|value| format!("{value:.6}"))
3456        .collect::<Vec<_>>()
3457        .join(",")
3458}
3459
3460fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
3461    let parsed = value
3462        .split(',')
3463        .map(str::trim)
3464        .map(str::parse::<f64>)
3465        .collect::<std::result::Result<Vec<_>, _>>()
3466        .ok()?;
3467    (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
3468}
3469
3470fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
3471    if left.len() != right.len() {
3472        return 0.0;
3473    }
3474    left.iter()
3475        .zip(right.iter())
3476        .map(|(left, right)| left * right)
3477        .sum::<f64>()
3478}
3479
3480fn semantic_entity_handle(name: &str, kind: &str) -> String {
3481    stable_handle(
3482        "gent",
3483        &format!(
3484            "entity:{}:{}",
3485            kind.trim().to_ascii_lowercase(),
3486            name.trim().to_ascii_lowercase()
3487        ),
3488    )
3489}
3490
3491fn semantic_concept_handle(label: &str) -> String {
3492    stable_handle(
3493        "gcon",
3494        &format!("concept:{}", label.trim().to_ascii_lowercase()),
3495    )
3496}
3497
3498fn summary_source_handles(
3499    summary: &summarize::Summary,
3500    file_node_by_path: &BTreeMap<String, String>,
3501    symbol_node_by_file_label: &BTreeMap<(String, String), String>,
3502) -> Vec<String> {
3503    let mut handles = Vec::new();
3504    if let Some(handle) = file_node_by_path.get(&summary.file_path) {
3505        handles.push(handle.clone());
3506    }
3507    if let Some(handle) =
3508        symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
3509        && !handles.iter().any(|existing| existing == handle)
3510    {
3511        handles.push(handle.clone());
3512    }
3513    handles
3514}
3515
3516fn semantic_entity_node(
3517    root: &Path,
3518    summary: &summarize::Summary,
3519    name: &str,
3520    kind: &str,
3521    description: &str,
3522    provenance: &GraphProvenance,
3523) -> SubstrateGraphNode {
3524    let handle = semantic_entity_handle(name, kind);
3525    let detail = if description.trim().is_empty() {
3526        format!("{kind} entity from cached summaries")
3527    } else {
3528        format!("{kind}: {description}")
3529    };
3530    SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
3531        .with_property("handle", handle)
3532        .with_property("ref_id", name.to_string())
3533        .with_property("detail", detail)
3534        .with_property("entity_kind", kind.to_string())
3535        .with_property("description", description.to_string())
3536        .with_property("source_file", summary.file_path.clone())
3537        .with_property("source_symbol", summary.symbol_name.clone())
3538        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3539        .with_property(
3540            "embedding",
3541            semantic_embedding_property(&format!("{name} {kind} {description}")),
3542        )
3543        .with_property(
3544            "expand",
3545            semantic_related_command(root, name, SemanticRelatedKind::Entity),
3546        )
3547        .with_provenance(provenance.clone())
3548}
3549
3550fn semantic_concept_node(
3551    root: &Path,
3552    summary: &summarize::Summary,
3553    label: &str,
3554    provenance: &GraphProvenance,
3555) -> SubstrateGraphNode {
3556    let handle = semantic_concept_handle(label);
3557    SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
3558        .with_property("handle", handle)
3559        .with_property("ref_id", label.to_string())
3560        .with_property("detail", "concept label from cached summaries".to_string())
3561        .with_property("source_file", summary.file_path.clone())
3562        .with_property("source_symbol", summary.symbol_name.clone())
3563        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3564        .with_property("embedding", semantic_embedding_property(label))
3565        .with_property(
3566            "expand",
3567            semantic_related_command(root, label, SemanticRelatedKind::Concept),
3568        )
3569        .with_provenance(provenance.clone())
3570}
3571
3572fn insert_semantic_edge(
3573    edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
3574    edge: SubstrateGraphEdge,
3575) {
3576    edge_map
3577        .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
3578        .or_insert(edge);
3579}
3580
3581fn memory_event_key(event: &MemoryEvent) -> String {
3582    match (event.imported_from.as_deref(), event.imported_id.as_deref()) {
3583        (Some(imported_from), Some(imported_id)) => {
3584            format!("{imported_from}:{imported_id}")
3585        }
3586        _ => event.stable_id(),
3587    }
3588}
3589
3590fn memory_event_label(event: &MemoryEvent) -> String {
3591    let first_line = event
3592        .text
3593        .lines()
3594        .map(str::trim)
3595        .find(|line| !line.is_empty())
3596        .unwrap_or(event.kind.as_str());
3597    match event.kind.as_str() {
3598        "imported_observation" => {
3599            let observation_type = event
3600                .metadata
3601                .get("observation_type")
3602                .map(String::as_str)
3603                .unwrap_or("observation");
3604            truncate_for_compact(&format!("{observation_type}: {first_line}"), 80)
3605        }
3606        "imported_session_summary" => truncate_for_compact(&format!("summary: {first_line}"), 80),
3607        "imported_user_prompt" => truncate_for_compact(&format!("prompt: {first_line}"), 80),
3608        _ => truncate_for_compact(first_line, 80),
3609    }
3610}
3611
3612fn append_tsift_memory_graph_projection_rows(
3613    root: &Path,
3614    nodes: &mut Vec<SubstrateGraphNode>,
3615    edges: &mut Vec<SubstrateGraphEdge>,
3616) -> Result<()> {
3617    let memory_db = default_memory_db_path(root);
3618    if !memory_db.exists() {
3619        return Ok(());
3620    }
3621    let events = match read_memory_events(&memory_db, CLAUDE_MEM_GRAPH_LIMIT_PER_TABLE * 3) {
3622        Ok(events) => events,
3623        Err(_) => return Ok(()),
3624    };
3625    if events.is_empty() {
3626        return Ok(());
3627    }
3628
3629    let mut seen_sessions = BTreeSet::new();
3630    let mut edge_map = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
3631
3632    for event in &events {
3633        let event_id = event.stable_id();
3634        let event_key = memory_event_key(event);
3635        let source_handle = stable_handle("tmemsrc", &event_key);
3636        let semantic_handle = stable_handle("tmemsem", &event_key);
3637        let provenance = GraphProvenance::new("tsift-memory", &event.source_ref);
3638        let imported_from = event.imported_from.as_deref().unwrap_or("native");
3639
3640        if let Some(session_id) = &event.session_id {
3641            let session_handle =
3642                format!("memsess:{}", blake3::hash(session_id.as_bytes()).to_hex());
3643            if seen_sessions.insert(session_id.clone()) {
3644                let session_node = SubstrateGraphNode::new(
3645                    session_handle.clone(),
3646                    "memory_session",
3647                    truncate_for_compact(session_id, 80),
3648                )
3649                .with_property("handle", session_handle.clone())
3650                .with_property("ref_id", session_id.clone())
3651                .with_property("session_id", session_id.clone())
3652                .with_property("provider", "tsift-memory")
3653                .with_property(
3654                    "expand",
3655                    format!(
3656                        "tsift memory status {} --json",
3657                        shell_quote(root.to_string_lossy().as_ref())
3658                    ),
3659                )
3660                .with_provenance(provenance.clone());
3661                nodes.push(node_with_content_freshness(session_node)?);
3662            }
3663
3664            insert_semantic_edge(
3665                &mut edge_map,
3666                SubstrateGraphEdge::new(
3667                    session_handle.clone(),
3668                    event_id.clone(),
3669                    "records_memory_event",
3670                )
3671                .with_property("label", "tsift-memory session event")
3672                .with_provenance(provenance.clone()),
3673            );
3674            insert_semantic_edge(
3675                &mut edge_map,
3676                SubstrateGraphEdge::new(
3677                    session_handle,
3678                    source_handle.clone(),
3679                    "records_memory_source",
3680                )
3681                .with_property("label", "tsift-memory session source")
3682                .with_provenance(provenance.clone()),
3683            );
3684        }
3685
3686        let label = memory_event_label(event);
3687        let mut event_node =
3688            SubstrateGraphNode::new(event_id.clone(), "memory_event", event.kind.as_str())
3689                .with_property("handle", event_id.clone())
3690                .with_property("ref_id", event.source_ref.clone())
3691                .with_property("source_ref", event.source_ref.clone())
3692                .with_property("provider", "tsift-memory")
3693                .with_property("memory_kind", event.kind.as_str())
3694                .with_property("imported_from", imported_from)
3695                .with_property("text_preview", truncate_for_compact(&event.text, 240))
3696                .with_property("token_estimate", event.token_estimate.to_string())
3697                .with_property(
3698                    "expand",
3699                    format!(
3700                        "tsift memory status {} --json",
3701                        shell_quote(root.to_string_lossy().as_ref())
3702                    ),
3703                )
3704                .with_provenance(provenance.clone());
3705        if let Some(session_id) = &event.session_id {
3706            event_node = event_node.with_property("session_id", session_id.clone());
3707        }
3708        if let Some(observed_at_unix) = event.observed_at_unix {
3709            event_node = event_node.with_property("observed_at_unix", observed_at_unix.to_string());
3710        }
3711        if let Some(imported_id) = &event.imported_id {
3712            event_node = event_node.with_property("imported_id", imported_id.clone());
3713        }
3714        nodes.push(node_with_content_freshness(event_node)?);
3715
3716        let mut source_node =
3717            SubstrateGraphNode::new(source_handle.clone(), "source_handle", label.clone())
3718                .with_property("handle", source_handle.clone())
3719                .with_property("ref_id", event.source_ref.clone())
3720                .with_property("source_ref", event.source_ref.clone())
3721                .with_property("provider", "tsift-memory")
3722                .with_property("memory_kind", event.kind.as_str())
3723                .with_property("imported_from", imported_from)
3724                .with_property("text_preview", truncate_for_compact(&event.text, 240))
3725                .with_property("token_estimate", event.token_estimate.to_string())
3726                .with_property(
3727                    "expand",
3728                    format!(
3729                        "tsift memory status {} --json",
3730                        shell_quote(root.to_string_lossy().as_ref())
3731                    ),
3732                )
3733                .with_provenance(provenance.clone());
3734        if let Some(session_id) = &event.session_id {
3735            source_node = source_node.with_property("session_id", session_id.clone());
3736        }
3737        if let Some(observed_at_unix) = event.observed_at_unix {
3738            source_node =
3739                source_node.with_property("observed_at_unix", observed_at_unix.to_string());
3740        }
3741        if let Some(imported_id) = &event.imported_id {
3742            source_node = source_node.with_property("imported_id", imported_id.clone());
3743        }
3744        nodes.push(node_with_content_freshness(source_node)?);
3745
3746        insert_semantic_edge(
3747            &mut edge_map,
3748            SubstrateGraphEdge::new(event_id.clone(), source_handle.clone(), "projects_source")
3749                .with_property("label", "tsift-memory source projection")
3750                .with_provenance(provenance.clone()),
3751        );
3752
3753        let semantic_text = format!("{} {}", label, event.text);
3754        let semantic_node =
3755            SubstrateGraphNode::new(semantic_handle.clone(), "semantic_concept", label.clone())
3756                .with_property("handle", semantic_handle.clone())
3757                .with_property("ref_id", event.source_ref.clone())
3758                .with_property("detail", "semantic row from tsift-memory")
3759                .with_property("source_ref", event.source_ref.clone())
3760                .with_property("provider", "tsift-memory")
3761                .with_property("memory_kind", event.kind.as_str())
3762                .with_property("imported_from", imported_from)
3763                .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3764                .with_property("embedding", semantic_embedding_property(&semantic_text))
3765                .with_property(
3766                    "expand",
3767                    semantic_related_command(root, &label, SemanticRelatedKind::Concept),
3768                )
3769                .with_provenance(provenance.clone());
3770        nodes.push(node_with_content_freshness(semantic_node)?);
3771
3772        insert_semantic_edge(
3773            &mut edge_map,
3774            SubstrateGraphEdge::new(
3775                source_handle.clone(),
3776                semantic_handle.clone(),
3777                "mentions_concept",
3778            )
3779            .with_property("label", "tsift-memory semantic source")
3780            .with_provenance(provenance.clone()),
3781        );
3782    }
3783
3784    for edge in edge_map.into_values() {
3785        edges.push(edge_with_content_freshness(edge)?);
3786    }
3787
3788    Ok(())
3789}
3790
3791fn append_summary_semantic_projection_rows(
3792    root: &Path,
3793    graph: &TraversalGraphBuild,
3794    provenance: &GraphProvenance,
3795    nodes: &mut Vec<SubstrateGraphNode>,
3796    edges: &mut Vec<SubstrateGraphEdge>,
3797) -> Result<()> {
3798    let summaries_db = root.join(".tsift/summaries.db");
3799    if !summaries_db.exists() {
3800        return Ok(());
3801    }
3802
3803    let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
3804    let summaries = summary_db.all()?;
3805    if summaries.is_empty() {
3806        return Ok(());
3807    }
3808
3809    let file_node_by_path = graph
3810        .nodes
3811        .values()
3812        .filter(|node| node.kind == "file")
3813        .filter_map(|node| {
3814            node.path
3815                .as_ref()
3816                .map(|path| (path.clone(), node.handle.clone()))
3817        })
3818        .collect::<BTreeMap<_, _>>();
3819    let symbol_node_by_file_label = graph
3820        .nodes
3821        .values()
3822        .filter(|node| node.kind == "symbol")
3823        .filter_map(|node| {
3824            Some((
3825                (node.path.clone()?, node.label.clone()),
3826                node.handle.clone(),
3827            ))
3828        })
3829        .collect::<BTreeMap<_, _>>();
3830
3831    let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
3832    let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
3833
3834    for summary in &summaries {
3835        let source_handles =
3836            summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
3837        let mut entity_ids_by_name = BTreeMap::<String, String>::new();
3838
3839        if let Some(entities) = &summary.entities {
3840            for entity in entities {
3841                let node = semantic_entity_node(
3842                    root,
3843                    summary,
3844                    &entity.name,
3845                    &entity.kind,
3846                    &entity.description,
3847                    provenance,
3848                );
3849                let entity_id = node.id.clone();
3850                entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
3851                semantic_nodes.entry(entity_id.clone()).or_insert(node);
3852
3853                for source_handle in &source_handles {
3854                    insert_semantic_edge(
3855                        &mut semantic_edges,
3856                        SubstrateGraphEdge::new(
3857                            source_handle.clone(),
3858                            entity_id.clone(),
3859                            "mentions_entity",
3860                        )
3861                        .with_property("label", format!("summary entity: {}", entity.name))
3862                        .with_property("source_file", summary.file_path.clone())
3863                        .with_provenance(provenance.clone()),
3864                    );
3865                }
3866            }
3867        }
3868
3869        let mut concept_ids = Vec::new();
3870        if let Some(labels) = &summary.concept_labels {
3871            for label in labels
3872                .iter()
3873                .map(|label| label.trim())
3874                .filter(|label| !label.is_empty())
3875            {
3876                let node = semantic_concept_node(root, summary, label, provenance);
3877                let concept_id = node.id.clone();
3878                semantic_nodes.entry(concept_id.clone()).or_insert(node);
3879                concept_ids.push(concept_id.clone());
3880
3881                for source_handle in &source_handles {
3882                    insert_semantic_edge(
3883                        &mut semantic_edges,
3884                        SubstrateGraphEdge::new(
3885                            source_handle.clone(),
3886                            concept_id.clone(),
3887                            "mentions_concept",
3888                        )
3889                        .with_property("label", format!("summary concept: {label}"))
3890                        .with_property("source_file", summary.file_path.clone())
3891                        .with_provenance(provenance.clone()),
3892                    );
3893                }
3894            }
3895        }
3896
3897        for entity_id in entity_ids_by_name.values() {
3898            for concept_id in &concept_ids {
3899                insert_semantic_edge(
3900                    &mut semantic_edges,
3901                    SubstrateGraphEdge::new(
3902                        entity_id.clone(),
3903                        concept_id.clone(),
3904                        "tagged_concept",
3905                    )
3906                    .with_property("label", "entity concept label".to_string())
3907                    .with_property("source_file", summary.file_path.clone())
3908                    .with_provenance(provenance.clone()),
3909                );
3910            }
3911        }
3912
3913        for idx in 0..concept_ids.len() {
3914            for next_idx in (idx + 1)..concept_ids.len() {
3915                insert_semantic_edge(
3916                    &mut semantic_edges,
3917                    SubstrateGraphEdge::new(
3918                        concept_ids[idx].clone(),
3919                        concept_ids[next_idx].clone(),
3920                        "related_concept",
3921                    )
3922                    .with_property("label", format!("co-occurs in {}", summary.symbol_name))
3923                    .with_property("source_file", summary.file_path.clone())
3924                    .with_provenance(provenance.clone()),
3925                );
3926            }
3927        }
3928
3929        if let Some(relationships) = &summary.relationships {
3930            for relationship in relationships {
3931                let from_id = entity_ids_by_name
3932                    .get(&relationship.from.to_ascii_lowercase())
3933                    .cloned()
3934                    .unwrap_or_else(|| {
3935                        let node = semantic_entity_node(
3936                            root,
3937                            summary,
3938                            &relationship.from,
3939                            "unknown",
3940                            "",
3941                            provenance,
3942                        );
3943                        let id = node.id.clone();
3944                        semantic_nodes.entry(id.clone()).or_insert(node);
3945                        id
3946                    });
3947                let to_id = entity_ids_by_name
3948                    .get(&relationship.to.to_ascii_lowercase())
3949                    .cloned()
3950                    .unwrap_or_else(|| {
3951                        let node = semantic_entity_node(
3952                            root,
3953                            summary,
3954                            &relationship.to,
3955                            "unknown",
3956                            "",
3957                            provenance,
3958                        );
3959                        let id = node.id.clone();
3960                        semantic_nodes.entry(id.clone()).or_insert(node);
3961                        id
3962                    });
3963                insert_semantic_edge(
3964                    &mut semantic_edges,
3965                    SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
3966                        .with_property("relationship_kind", relationship.kind.clone())
3967                        .with_property("label", relationship.kind.clone())
3968                        .with_property("source_file", summary.file_path.clone())
3969                        .with_property("source_symbol", summary.symbol_name.clone())
3970                        .with_provenance(provenance.clone()),
3971                );
3972            }
3973        }
3974    }
3975
3976    for node in semantic_nodes.into_values() {
3977        nodes.push(node_with_content_freshness(node)?);
3978    }
3979    for edge in semantic_edges.into_values() {
3980        edges.push(edge_with_content_freshness(edge)?);
3981    }
3982
3983    Ok(())
3984}
3985
3986fn projection_content_hash(
3987    nodes: &[SubstrateGraphNode],
3988    edges: &[SubstrateGraphEdge],
3989) -> Result<String> {
3990    #[derive(Serialize)]
3991    struct Payload<'a> {
3992        version: &'static str,
3993        nodes: &'a [SubstrateGraphNode],
3994        edges: &'a [SubstrateGraphEdge],
3995    }
3996
3997    content_hash(&Payload {
3998        version: GRAPH_PROJECTION_VERSION,
3999        nodes,
4000        edges,
4001    })
4002}
4003
4004pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
4005    projection
4006        .nodes
4007        .iter()
4008        .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
4009        .and_then(|node| node.properties.get("content_hash").cloned())
4010}
4011
4012fn traversal_projection_from_graph(
4013    root: &Path,
4014    scope: Option<&str>,
4015    graph: &TraversalGraphBuild,
4016) -> Result<GraphProjection> {
4017    let provenance = GraphProvenance::new(
4018        "tsift.traverse",
4019        format!("{}:{}", root.display(), scope.unwrap_or("root")),
4020    );
4021    let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
4022    for node in graph.nodes.values() {
4023        let mut projected =
4024            SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
4025                .with_property("handle", node.handle.clone())
4026                .with_property("expand", node.expand.clone())
4027                .with_provenance(provenance.clone());
4028        if let Some(ref_id) = &node.ref_id {
4029            projected = projected.with_property("ref_id", ref_id.clone());
4030        }
4031        if let Some(path) = &node.path {
4032            projected = projected.with_property("path", path.clone());
4033        }
4034        if let Some(line) = node.line {
4035            projected = projected.with_property("line", line.to_string());
4036        }
4037        if let Some(detail) = &node.detail {
4038            projected = projected.with_property("detail", detail.clone());
4039        }
4040        for (key, value) in &node.properties {
4041            projected = projected.with_property(key.clone(), value.clone());
4042        }
4043        nodes.push(node_with_content_freshness(projected)?);
4044    }
4045
4046    let mut edges = Vec::with_capacity(graph.edges.len());
4047    for edge in &graph.edges {
4048        let mut projected =
4049            SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
4050                .with_property("weight", edge.weight.to_string())
4051                .with_provenance(provenance.clone());
4052        if let Some(label) = &edge.label {
4053            projected = projected.with_property("label", label.clone());
4054        }
4055        edges.push(edge_with_content_freshness(projected)?);
4056    }
4057
4058    append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4059    append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4060    append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
4061
4062    let projection_hash = projection_content_hash(&nodes, &edges)?;
4063    let meta = SubstrateGraphNode::new(
4064        graph_projection_meta_id(scope),
4065        GRAPH_PROJECTION_META_KIND,
4066        "tsift traversal projection",
4067    )
4068    .with_property("projection_version", GRAPH_PROJECTION_VERSION)
4069    .with_property("content_hash", projection_hash.clone())
4070    .with_property("root", root.to_string_lossy().to_string())
4071    .with_property("scope", scope.unwrap_or("root"))
4072    .with_property("node_count", graph.nodes.len().to_string())
4073    .with_property("edge_count", graph.edges.len().to_string())
4074    .with_provenance(provenance)
4075    .with_freshness(GraphFreshness::content_hash(projection_hash));
4076    nodes.push(meta);
4077
4078    Ok(GraphProjection { nodes, edges })
4079}
4080
4081#[allow(clippy::too_many_arguments)]
4082fn ensure_traversal_source_handle(
4083    root: &Path,
4084    provenance: &GraphProvenance,
4085    file_node_by_path: &BTreeMap<String, String>,
4086    node: &TraversalNode,
4087    budget: &ExplorationBudget,
4088    source_handle_by_node: &mut BTreeMap<String, String>,
4089    seen_windows: &mut BTreeMap<(String, usize, usize), String>,
4090    nodes: &mut Vec<SubstrateGraphNode>,
4091    edges: &mut Vec<SubstrateGraphEdge>,
4092) -> Result<Option<String>> {
4093    if let Some(handle) = source_handle_by_node.get(&node.handle) {
4094        return Ok(Some(handle.clone()));
4095    }
4096    let Some(window) = exploration_source_window_for_node(root, node, budget) else {
4097        return Ok(None);
4098    };
4099    let window_key = (window.file.clone(), window.start, window.end);
4100    let handle = if let Some(handle) = seen_windows.get(&window_key) {
4101        handle.clone()
4102    } else {
4103        let label = format!("{}:{}-{}", window.file, window.start, window.end);
4104        let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
4105            .with_property("handle", window.handle.clone())
4106            .with_property("file", window.file.clone())
4107            .with_property("start", window.start.to_string())
4108            .with_property("end", window.end.to_string())
4109            .with_property("reason", window.reason.clone())
4110            .with_property("expand", window.expand.clone())
4111            .with_provenance(provenance.clone());
4112        nodes.push(node_with_content_freshness(projected)?);
4113
4114        if let Some(file_handle) = file_node_by_path.get(&window.file) {
4115            let edge = SubstrateGraphEdge::new(
4116                window.handle.clone(),
4117                file_handle.clone(),
4118                "expands_source",
4119            )
4120            .with_property("label", window.reason.clone())
4121            .with_provenance(provenance.clone());
4122            edges.push(edge_with_content_freshness(edge)?);
4123        }
4124        if node.kind != "file" {
4125            let edge = SubstrateGraphEdge::new(
4126                window.handle.clone(),
4127                node.handle.clone(),
4128                "anchors_source",
4129            )
4130            .with_property("label", window.reason.clone())
4131            .with_provenance(provenance.clone());
4132            edges.push(edge_with_content_freshness(edge)?);
4133        }
4134        seen_windows.insert(window_key, window.handle.clone());
4135        window.handle
4136    };
4137    source_handle_by_node.insert(node.handle.clone(), handle.clone());
4138    Ok(Some(handle))
4139}
4140
4141fn push_traversal_backlog_target_handles<'a>(
4142    backlog: &TraversalNode,
4143    edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
4144    node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
4145    max_handles: usize,
4146    seen_target_nodes: &mut BTreeSet<String>,
4147    target_node_handles: &mut Vec<String>,
4148) {
4149    for edge in edges_by_from
4150        .get(backlog.handle.as_str())
4151        .into_iter()
4152        .flatten()
4153        .filter(|edge| edge.relation == "mentions")
4154    {
4155        let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
4156            continue;
4157        };
4158        if !matches!(
4159            target_node.kind.as_str(),
4160            "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
4161        ) {
4162            continue;
4163        }
4164        if target_node
4165            .path
4166            .as_deref()
4167            .zip(backlog.path.as_deref())
4168            .is_some_and(|(target_path, backlog_path)| {
4169                target_path == backlog_path && target_path.ends_with(".md")
4170            })
4171        {
4172            continue;
4173        }
4174        if seen_target_nodes.insert(target_node.handle.clone()) {
4175            target_node_handles.push(target_node.handle.clone());
4176        }
4177        if target_node_handles.len() >= max_handles {
4178            break;
4179        }
4180    }
4181}
4182
4183fn append_traversal_context_projection_rows(
4184    root: &Path,
4185    graph: &TraversalGraphBuild,
4186    provenance: &GraphProvenance,
4187    nodes: &mut Vec<SubstrateGraphNode>,
4188    edges: &mut Vec<SubstrateGraphEdge>,
4189) -> Result<()> {
4190    let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4191    let file_node_by_path = graph
4192        .nodes
4193        .values()
4194        .filter(|node| node.kind == "file")
4195        .filter_map(|node| {
4196            node.path
4197                .as_ref()
4198                .map(|path| (path.clone(), node.handle.clone()))
4199        })
4200        .collect::<BTreeMap<_, _>>();
4201
4202    let node_by_handle = graph
4203        .nodes
4204        .values()
4205        .map(|node| (node.handle.as_str(), node))
4206        .collect::<BTreeMap<_, _>>();
4207    let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4208    for edge in &graph.edges {
4209        edges_by_from
4210            .entry(edge.from.as_str())
4211            .or_default()
4212            .push(edge);
4213    }
4214    for rows in edges_by_from.values_mut() {
4215        rows.sort_by(|left, right| {
4216            right
4217                .weight
4218                .cmp(&left.weight)
4219                .then(left.relation.cmp(&right.relation))
4220                .then(left.to.cmp(&right.to))
4221        });
4222    }
4223
4224    let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4225    let mut source_handle_by_node = BTreeMap::<String, String>::new();
4226
4227    let mut code_context_count = 0usize;
4228    let code_context_limit = budget.relationship_limit.min(8);
4229    for node in graph.nodes.values() {
4230        if !matches!(
4231            node.kind.as_str(),
4232            "backlog" | "job_packet" | "worker_result"
4233        ) {
4234            continue;
4235        }
4236        let mut target_node_handles = Vec::new();
4237        let mut fallback_target_handles = Vec::new();
4238        let mut seen_target_nodes = BTreeSet::new();
4239        if node.kind == "backlog" || node.kind == "worker_result" {
4240            push_traversal_backlog_target_handles(
4241                node,
4242                &edges_by_from,
4243                &node_by_handle,
4244                budget.max_source_windows,
4245                &mut seen_target_nodes,
4246                &mut target_node_handles,
4247            );
4248            fallback_target_handles.push(node.handle.clone());
4249        } else {
4250            for edge in edges_by_from
4251                .get(node.handle.as_str())
4252                .into_iter()
4253                .flatten()
4254                .filter(|edge| edge.relation == "targets")
4255            {
4256                let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4257                    continue;
4258                };
4259                fallback_target_handles.push(backlog.handle.clone());
4260                push_traversal_backlog_target_handles(
4261                    backlog,
4262                    &edges_by_from,
4263                    &node_by_handle,
4264                    budget.max_source_windows,
4265                    &mut seen_target_nodes,
4266                    &mut target_node_handles,
4267                );
4268                if target_node_handles.len() >= budget.max_source_windows {
4269                    break;
4270                }
4271            }
4272            if fallback_target_handles.is_empty() {
4273                continue;
4274            }
4275        }
4276        let code_context = !target_node_handles.is_empty();
4277        if target_node_handles.is_empty() {
4278            target_node_handles = dedupe_preserve_order(fallback_target_handles);
4279        } else if code_context_count >= code_context_limit {
4280            continue;
4281        }
4282
4283        let mut worker_source_handles = Vec::new();
4284        let mut seen_worker_handles = BTreeSet::new();
4285        for target_handle in target_node_handles {
4286            if worker_source_handles.len() >= budget.max_source_windows {
4287                break;
4288            }
4289            let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4290                continue;
4291            };
4292            let Some(handle) = ensure_traversal_source_handle(
4293                root,
4294                provenance,
4295                &file_node_by_path,
4296                target_node,
4297                &budget,
4298                &mut source_handle_by_node,
4299                &mut seen_windows,
4300                nodes,
4301                edges,
4302            )?
4303            else {
4304                continue;
4305            };
4306            if seen_worker_handles.insert(handle.clone()) {
4307                worker_source_handles.push(handle);
4308            }
4309        }
4310        if worker_source_handles.is_empty() {
4311            continue;
4312        }
4313        let target = node
4314            .path
4315            .clone()
4316            .unwrap_or_else(|| root.to_string_lossy().to_string());
4317        let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4318        let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4319        let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4320            .with_property("handle", handle.clone())
4321            .with_property("target", target.clone())
4322            .with_property("summary", summary)
4323            .with_property(
4324                "source_handle_count",
4325                worker_source_handles.len().to_string(),
4326            )
4327            .with_property(
4328                "expand",
4329                format!(
4330                    "tsift --envelope context-pack {} --budget normal",
4331                    shell_quote(&target)
4332                ),
4333            )
4334            .with_provenance(provenance.clone());
4335        nodes.push(node_with_content_freshness(projected)?);
4336
4337        let request_edge =
4338            SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4339                .with_property("label", "bounded worker context".to_string())
4340                .with_provenance(provenance.clone());
4341        edges.push(edge_with_content_freshness(request_edge)?);
4342
4343        for source_handle in &worker_source_handles {
4344            let scope_edge =
4345                SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4346                    .with_property("label", "bounded worker source window".to_string())
4347                    .with_provenance(provenance.clone());
4348            edges.push(edge_with_content_freshness(scope_edge)?);
4349        }
4350        if code_context {
4351            code_context_count += 1;
4352        }
4353    }
4354
4355    Ok(())
4356}
4357
4358fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4359    let handle = node
4360        .properties
4361        .get("handle")
4362        .cloned()
4363        .unwrap_or_else(|| node.id.clone());
4364    TraversalNode {
4365        expand: node
4366            .properties
4367            .get("expand")
4368            .cloned()
4369            .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4370        handle,
4371        kind: node.kind,
4372        label: node.label,
4373        ref_id: node.properties.get("ref_id").cloned(),
4374        path: node.properties.get("path").cloned(),
4375        line: node
4376            .properties
4377            .get("line")
4378            .and_then(|value| value.parse::<i64>().ok()),
4379        detail: node.properties.get("detail").cloned(),
4380        properties: node.properties,
4381    }
4382}
4383
4384fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4385    let mut graph = TraversalGraphBuild::default();
4386    for node in store.all_nodes()? {
4387        if node.kind == GRAPH_PROJECTION_META_KIND {
4388            continue;
4389        }
4390        graph.add_node(traversal_node_from_graph_node(root, node));
4391    }
4392    for edge in store.all_edges()? {
4393        graph.add_edge(
4394            &edge.from_id,
4395            &edge.to_id,
4396            &edge.kind,
4397            edge.properties.get("label").cloned(),
4398            edge.properties
4399                .get("weight")
4400                .and_then(|value| value.parse::<usize>().ok())
4401                .unwrap_or(1),
4402        );
4403    }
4404    Ok(graph)
4405}
4406
4407pub(crate) fn convex_rows_from_graph_store(
4408    store: &impl GraphStore,
4409) -> Result<ConvexProjectionRows> {
4410    Ok(GraphProjection {
4411        nodes: store.all_nodes()?,
4412        edges: store.all_edges()?,
4413    }
4414    .to_convex_rows())
4415}
4416
4417#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4418struct ConvexRequiredIndex {
4419    table: String,
4420    name: String,
4421    fields: Vec<String>,
4422}
4423
4424#[derive(Clone, Debug, Serialize, PartialEq)]
4425struct ConvexSyncChunk {
4426    operation: String,
4427    chunk: usize,
4428    count: usize,
4429    keys: Vec<String>,
4430    max_attempts: usize,
4431    retry_policy: String,
4432}
4433
4434#[derive(Clone, Debug, Serialize, PartialEq)]
4435struct ConvexTransportSummary {
4436    endpoint_env: String,
4437    endpoint_configured: bool,
4438    auth_token_env: String,
4439    auth_configured: bool,
4440    remote_snapshot: bool,
4441    applied_chunks: usize,
4442}
4443
4444#[derive(Clone, Debug, Serialize, PartialEq)]
4445struct ConvexTransportReceipt {
4446    operation: String,
4447    chunk: usize,
4448    attempt: usize,
4449    status: String,
4450    message: Option<String>,
4451}
4452
4453#[derive(Serialize)]
4454#[serde(rename_all = "camelCase")]
4455struct ConvexTransportRequest<'a> {
4456    operation: &'a str,
4457    chunk: usize,
4458    projection_version: &'a str,
4459    projection_hash: Option<&'a str>,
4460    #[serde(skip_serializing_if = "Option::is_none")]
4461    projection_meta_id: Option<&'a str>,
4462    node_rows: Vec<ConvexNodeRow>,
4463    edge_rows: Vec<ConvexEdgeRow>,
4464    keys: Vec<String>,
4465    #[serde(skip_serializing_if = "Option::is_none")]
4466    cursor: Option<String>,
4467    #[serde(skip_serializing_if = "Option::is_none")]
4468    limit: Option<usize>,
4469}
4470
4471#[derive(Deserialize)]
4472#[serde(rename_all = "camelCase")]
4473struct ConvexTransportResponse {
4474    status: Option<String>,
4475    message: Option<String>,
4476    rows: Option<ConvexProjectionRows>,
4477    #[serde(default)]
4478    meta: Option<ConvexSnapshotMeta>,
4479    #[serde(default)]
4480    page: Option<ConvexSnapshotPage>,
4481}
4482
4483#[derive(Deserialize, Debug, Clone)]
4484#[serde(rename_all = "camelCase")]
4485struct ConvexSnapshotMeta {
4486    // Captured for completeness/debugging; not currently consumed by the
4487    // freshness diff (indexes are already validated against the required set
4488    // via `convex_required_indexes`, and `page_size` is informational only).
4489    #[serde(default)]
4490    #[allow(dead_code)]
4491    indexes: Vec<ConvexRequiredIndex>,
4492    #[serde(default)]
4493    #[allow(dead_code)]
4494    node_count: Option<usize>,
4495    #[serde(default)]
4496    #[allow(dead_code)]
4497    edge_count: Option<usize>,
4498    #[serde(default)]
4499    projection_hash: Option<String>,
4500    #[serde(default)]
4501    #[allow(dead_code)]
4502    page_size: Option<usize>,
4503}
4504
4505/// Paginated snapshot page response. `rows` is either node rows or edge rows
4506/// depending on which operation was called; we deserialize as raw values to
4507/// keep the transport struct shared between both shapes, then narrow per call
4508/// site.
4509#[derive(Deserialize, Debug, Clone)]
4510#[serde(rename_all = "camelCase")]
4511struct ConvexSnapshotPage {
4512    rows: Vec<serde_json::Value>,
4513    #[serde(default)]
4514    next_cursor: Option<String>,
4515}
4516
4517#[derive(Clone, Debug, Serialize, PartialEq)]
4518struct ConvexProjectionFreshness {
4519    status: String,
4520    fail_closed: bool,
4521    local_hash: Option<String>,
4522    snapshot_hash: Option<String>,
4523    missing_nodes: Vec<String>,
4524    stale_nodes: Vec<String>,
4525    missing_edges: Vec<String>,
4526    stale_edges: Vec<String>,
4527    diagnostics: Vec<String>,
4528}
4529
4530const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
4531
4532impl ConvexProjectionFreshness {
4533    fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
4534        Self {
4535            status: "current".to_string(),
4536            fail_closed: false,
4537            local_hash,
4538            snapshot_hash,
4539            missing_nodes: Vec::new(),
4540            stale_nodes: Vec::new(),
4541            missing_edges: Vec::new(),
4542            stale_edges: Vec::new(),
4543            diagnostics: Vec::new(),
4544        }
4545    }
4546}
4547
4548#[derive(Clone, Debug, Serialize, PartialEq)]
4549struct ConvexSyncReport {
4550    root: String,
4551    #[serde(skip_serializing_if = "Option::is_none")]
4552    scope: Option<String>,
4553    graph_db: String,
4554    dry_run: bool,
4555    projection_version: String,
4556    projection_hash: Option<String>,
4557    required_indexes: Vec<ConvexRequiredIndex>,
4558    node_upserts: Vec<ConvexNodeRow>,
4559    edge_upserts: Vec<ConvexEdgeRow>,
4560    node_tombstones: Vec<String>,
4561    edge_tombstones: Vec<String>,
4562    chunks: Vec<ConvexSyncChunk>,
4563    freshness: ConvexProjectionFreshness,
4564    transport: Option<ConvexTransportSummary>,
4565    receipts: Vec<ConvexTransportReceipt>,
4566    diagnostics: Vec<String>,
4567    warnings: Vec<String>,
4568}
4569
4570fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
4571    vec![
4572        ConvexRequiredIndex {
4573            table: "nodes".to_string(),
4574            name: "by_external_id".to_string(),
4575            fields: vec!["externalId".to_string()],
4576        },
4577        ConvexRequiredIndex {
4578            table: "nodes".to_string(),
4579            name: "by_kind".to_string(),
4580            fields: vec!["kind".to_string()],
4581        },
4582        ConvexRequiredIndex {
4583            table: "edges".to_string(),
4584            name: "by_edge_key".to_string(),
4585            fields: vec!["edgeKey".to_string()],
4586        },
4587        ConvexRequiredIndex {
4588            table: "edges".to_string(),
4589            name: "by_from_kind".to_string(),
4590            fields: vec!["fromExternalId".to_string(), "kind".to_string()],
4591        },
4592        ConvexRequiredIndex {
4593            table: "edges".to_string(),
4594            name: "by_to_kind".to_string(),
4595            fields: vec!["toExternalId".to_string(), "kind".to_string()],
4596        },
4597    ]
4598}
4599
4600pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
4601    let content = fs::read_to_string(path)
4602        .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
4603    serde_json::from_str(&content)
4604        .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
4605}
4606
4607fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
4608    let mut diagnostics = Vec::new();
4609    let mut node_counts = BTreeMap::<&str, usize>::new();
4610    for row in &rows.nodes {
4611        *node_counts.entry(row.external_id.as_str()).or_default() += 1;
4612    }
4613    for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
4614        diagnostics.push(format!(
4615            "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
4616        ));
4617    }
4618
4619    let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
4620    let mut edge_counts = BTreeMap::<&str, usize>::new();
4621    for edge in &rows.edges {
4622        *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
4623        if !node_ids.contains(edge.from_external_id.as_str()) {
4624            diagnostics.push(format!(
4625                "Convex snapshot edge {} references missing from node {}",
4626                edge.edge_key, edge.from_external_id
4627            ));
4628        }
4629        if !node_ids.contains(edge.to_external_id.as_str()) {
4630            diagnostics.push(format!(
4631                "Convex snapshot edge {} references missing to node {}",
4632                edge.edge_key, edge.to_external_id
4633            ));
4634        }
4635        let expected_key =
4636            ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
4637        if edge.edge_key != expected_key {
4638            diagnostics.push(format!(
4639                "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
4640                edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
4641            ));
4642        }
4643    }
4644    for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
4645        diagnostics.push(format!(
4646            "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
4647        ));
4648    }
4649    diagnostics
4650}
4651
4652pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
4653    let diagnostics = convex_projection_row_diagnostics(rows);
4654    if diagnostics.is_empty() {
4655        Ok(())
4656    } else {
4657        bail!("{}", diagnostics.join("; "))
4658    }
4659}
4660
4661pub(crate) struct ConvexHttpTransport {
4662    endpoint: String,
4663    auth_token_env: String,
4664    auth_token: Option<String>,
4665}
4666
4667impl ConvexHttpTransport {
4668    fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
4669        let endpoint = endpoint
4670            .map(str::to_string)
4671            .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
4672            .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
4673        let auth_token = env::var(auth_token_env)
4674            .ok()
4675            .filter(|value| !value.trim().is_empty());
4676        Ok(Self {
4677            endpoint,
4678            auth_token_env: auth_token_env.to_string(),
4679            auth_token,
4680        })
4681    }
4682
4683    fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
4684        ConvexTransportSummary {
4685            endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
4686            endpoint_configured: true,
4687            auth_token_env: self.auth_token_env.clone(),
4688            auth_configured: self.auth_token.is_some(),
4689            remote_snapshot,
4690            applied_chunks,
4691        }
4692    }
4693
4694    fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
4695        let mut builder = ureq::post(&self.endpoint);
4696        if let Some(token) = &self.auth_token {
4697            builder = builder.header("Authorization", &format!("Bearer {token}"));
4698        }
4699        builder
4700            .send_json(request)
4701            .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
4702            .body_mut()
4703            .read_json::<ConvexTransportResponse>()
4704            .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
4705    }
4706
4707    /// Fetch a full snapshot of the Convex graph backend.
4708    ///
4709    /// Uses the paginated `snapshot_meta` + `snapshot_nodes_page` +
4710    /// `snapshot_edges_page` triplet so the call works on tables larger than
4711    /// ~5k rows (the single-shot `snapshot` query hits Convex's 15s per-request
4712    /// syscall budget at that scale; see `#convexsnapshotscale`).
4713    ///
4714    /// Falls back to the legacy single-shot `snapshot` operation if the
4715    /// backend doesn't recognize `snapshot_meta` (older deployments that
4716    /// haven't redeployed the new schema).
4717    fn fetch_snapshot(
4718        &self,
4719        projection_version: &str,
4720        scope: Option<&str>,
4721        local_hash: Option<&str>,
4722        local_rows: Option<&ConvexProjectionRows>,
4723    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4724        match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
4725            Ok(rows) => Ok(rows),
4726            Err(err) => {
4727                // Only fall through to the legacy path if the failure looks
4728                // like "operation unknown" (older backend). Any other failure
4729                // (HTTP timeout, deserialization mismatch) should surface so
4730                // the operator sees the real cause.
4731                let msg = format!("{err:#}");
4732                let is_unknown_op = msg.contains("unknown operation")
4733                    || msg.contains("snapshot_meta")
4734                    || msg.contains("404");
4735                if !is_unknown_op {
4736                    return Err(err);
4737                }
4738                self.fetch_snapshot_legacy(projection_version)
4739                    .map(|rows| (rows, Vec::new()))
4740            }
4741        }
4742    }
4743
4744    fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
4745        let response = self.post(&ConvexTransportRequest {
4746            operation: "snapshot",
4747            chunk: 0,
4748            projection_version,
4749            projection_hash: None,
4750            projection_meta_id: None,
4751            node_rows: Vec::new(),
4752            edge_rows: Vec::new(),
4753            keys: Vec::new(),
4754            cursor: None,
4755            limit: None,
4756        })?;
4757        response
4758            .rows
4759            .context("Convex snapshot response did not include rows")
4760    }
4761
4762    fn fetch_snapshot_paginated(
4763        &self,
4764        projection_version: &str,
4765        scope: Option<&str>,
4766        local_hash: Option<&str>,
4767        local_rows: Option<&ConvexProjectionRows>,
4768    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4769        let projection_meta_id = graph_projection_meta_id(scope);
4770        let meta_response = self.post(&ConvexTransportRequest {
4771            operation: "snapshot_meta",
4772            chunk: 0,
4773            projection_version,
4774            projection_hash: None,
4775            projection_meta_id: Some(&projection_meta_id),
4776            node_rows: Vec::new(),
4777            edge_rows: Vec::new(),
4778            keys: Vec::new(),
4779            cursor: None,
4780            limit: None,
4781        })?;
4782        if matches!(meta_response.status.as_deref(), Some("error")) {
4783            anyhow::bail!(
4784                "Convex snapshot_meta returned error: {}",
4785                meta_response.message.unwrap_or_default()
4786            );
4787        }
4788        let meta = meta_response
4789            .meta
4790            .context("Convex snapshot_meta response did not include meta")?;
4791        if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
4792            (meta.projection_hash.as_deref(), local_hash, local_rows)
4793            && remote_hash == local_hash
4794        {
4795            return Ok((
4796                local_rows.clone(),
4797                vec![
4798                    "remote projection hash matched local graph; skipped full row-page snapshot diff"
4799                        .to_string(),
4800                ],
4801            ));
4802        }
4803
4804        let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
4805        let mut node_cursor: Option<String> = None;
4806        loop {
4807            let response = self.post(&ConvexTransportRequest {
4808                operation: "snapshot_nodes_page",
4809                chunk: 0,
4810                projection_version,
4811                projection_hash: None,
4812                projection_meta_id: None,
4813                node_rows: Vec::new(),
4814                edge_rows: Vec::new(),
4815                keys: Vec::new(),
4816                cursor: node_cursor.clone(),
4817                limit: None,
4818            })?;
4819            let page = response
4820                .page
4821                .context("Convex snapshot_nodes_page response did not include page")?;
4822            for raw in page.rows {
4823                let row: ConvexNodeRow =
4824                    serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
4825                nodes.push(row);
4826            }
4827            match page.next_cursor {
4828                Some(next) => node_cursor = Some(next),
4829                None => break,
4830            }
4831        }
4832
4833        let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
4834        let mut edge_cursor: Option<String> = None;
4835        loop {
4836            let response = self.post(&ConvexTransportRequest {
4837                operation: "snapshot_edges_page",
4838                chunk: 0,
4839                projection_version,
4840                projection_hash: None,
4841                projection_meta_id: None,
4842                node_rows: Vec::new(),
4843                edge_rows: Vec::new(),
4844                keys: Vec::new(),
4845                cursor: edge_cursor.clone(),
4846                limit: None,
4847            })?;
4848            let page = response
4849                .page
4850                .context("Convex snapshot_edges_page response did not include page")?;
4851            for raw in page.rows {
4852                let row: ConvexEdgeRow =
4853                    serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
4854                edges.push(row);
4855            }
4856            match page.next_cursor {
4857                Some(next) => edge_cursor = Some(next),
4858                None => break,
4859            }
4860        }
4861
4862        Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
4863    }
4864
4865    fn apply_chunk(
4866        &self,
4867        report: &ConvexSyncReport,
4868        chunk: &ConvexSyncChunk,
4869    ) -> Result<ConvexTransportReceipt> {
4870        let node_rows = if chunk.operation == "upsert_nodes" {
4871            report
4872                .node_upserts
4873                .iter()
4874                .filter(|row| chunk.keys.contains(&row.external_id))
4875                .cloned()
4876                .collect()
4877        } else {
4878            Vec::new()
4879        };
4880        let edge_rows = if chunk.operation == "upsert_edges" {
4881            report
4882                .edge_upserts
4883                .iter()
4884                .filter(|row| chunk.keys.contains(&row.edge_key))
4885                .cloned()
4886                .collect()
4887        } else {
4888            Vec::new()
4889        };
4890        let request = ConvexTransportRequest {
4891            operation: &chunk.operation,
4892            chunk: chunk.chunk,
4893            projection_version: &report.projection_version,
4894            projection_hash: report.projection_hash.as_deref(),
4895            projection_meta_id: None,
4896            node_rows,
4897            edge_rows,
4898            keys: chunk.keys.clone(),
4899            cursor: None,
4900            limit: None,
4901        };
4902        let mut last_error = None;
4903        for attempt in 1..=chunk.max_attempts {
4904            match self.post(&request) {
4905                Ok(response) => {
4906                    return Ok(ConvexTransportReceipt {
4907                        operation: chunk.operation.clone(),
4908                        chunk: chunk.chunk,
4909                        attempt,
4910                        status: response.status.unwrap_or_else(|| "ok".to_string()),
4911                        message: response.message,
4912                    });
4913                }
4914                Err(err) => {
4915                    last_error = Some(err);
4916                    if attempt < chunk.max_attempts {
4917                        std::thread::sleep(Duration::from_millis(100 * attempt as u64));
4918                    }
4919                }
4920            }
4921        }
4922        Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
4923            .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
4924    }
4925}
4926
4927fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
4928    let meta_id = graph_projection_meta_id(scope);
4929    rows.nodes
4930        .iter()
4931        .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
4932        .and_then(|row| row.properties.get("content_hash").cloned())
4933}
4934
4935fn convex_projection_freshness(
4936    local: &ConvexProjectionRows,
4937    snapshot: Option<&ConvexProjectionRows>,
4938    scope: Option<&str>,
4939) -> ConvexProjectionFreshness {
4940    let local_hash = convex_projection_hash(local, scope);
4941    let Some(snapshot) = snapshot else {
4942        return ConvexProjectionFreshness {
4943            status: "unchecked".to_string(),
4944            fail_closed: false,
4945            local_hash,
4946            snapshot_hash: None,
4947            missing_nodes: Vec::new(),
4948            stale_nodes: Vec::new(),
4949            missing_edges: Vec::new(),
4950            stale_edges: Vec::new(),
4951            diagnostics: vec![
4952                "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
4953            ],
4954        };
4955    };
4956
4957    let snapshot_hash = convex_projection_hash(snapshot, scope);
4958    let snapshot_nodes = snapshot
4959        .nodes
4960        .iter()
4961        .map(|row| (row.external_id.as_str(), row))
4962        .collect::<BTreeMap<_, _>>();
4963    let snapshot_edges = snapshot
4964        .edges
4965        .iter()
4966        .map(|row| (row.edge_key.as_str(), row))
4967        .collect::<BTreeMap<_, _>>();
4968
4969    let mut missing_nodes = Vec::new();
4970    let mut stale_nodes = Vec::new();
4971    for row in &local.nodes {
4972        match snapshot_nodes.get(row.external_id.as_str()) {
4973            Some(snapshot_row) if *snapshot_row == row => {}
4974            Some(_) => stale_nodes.push(row.external_id.clone()),
4975            None => missing_nodes.push(row.external_id.clone()),
4976        }
4977    }
4978
4979    let mut missing_edges = Vec::new();
4980    let mut stale_edges = Vec::new();
4981    for row in &local.edges {
4982        match snapshot_edges.get(row.edge_key.as_str()) {
4983            Some(snapshot_row) if *snapshot_row == row => {}
4984            Some(_) => stale_edges.push(row.edge_key.clone()),
4985            None => missing_edges.push(row.edge_key.clone()),
4986        }
4987    }
4988
4989    let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
4990    let rows_current = missing_nodes.is_empty()
4991        && stale_nodes.is_empty()
4992        && missing_edges.is_empty()
4993        && stale_edges.is_empty();
4994    if hash_current && rows_current {
4995        return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
4996    }
4997
4998    let mut diagnostics = Vec::new();
4999    if local_hash != snapshot_hash {
5000        diagnostics.push(format!(
5001            "projection hash mismatch: local={} snapshot={}",
5002            local_hash.as_deref().unwrap_or("missing"),
5003            snapshot_hash.as_deref().unwrap_or("missing")
5004        ));
5005    }
5006    if !missing_nodes.is_empty() || !missing_edges.is_empty() {
5007        diagnostics.push(format!(
5008            "Convex snapshot is missing {} node(s) and {} edge(s)",
5009            missing_nodes.len(),
5010            missing_edges.len()
5011        ));
5012    }
5013    if !stale_nodes.is_empty() || !stale_edges.is_empty() {
5014        diagnostics.push(format!(
5015            "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
5016            stale_nodes.len(),
5017            stale_edges.len()
5018        ));
5019    }
5020
5021    ConvexProjectionFreshness {
5022        status: "stale".to_string(),
5023        fail_closed: true,
5024        local_hash,
5025        snapshot_hash,
5026        missing_nodes,
5027        stale_nodes,
5028        missing_edges,
5029        stale_edges,
5030        diagnostics,
5031    }
5032}
5033
5034pub(crate) fn verify_convex_projection_snapshot(
5035    root: &Path,
5036    scope: Option<&str>,
5037    snapshot_path: &Path,
5038) -> Result<()> {
5039    let graph_db = graph_substrate_db_path(root, scope);
5040    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5041    let local = convex_rows_from_graph_store(&store)?;
5042    let snapshot = load_convex_projection_rows(snapshot_path)?;
5043    validate_convex_projection_rows(&snapshot)?;
5044    let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
5045    if freshness.fail_closed {
5046        bail!(
5047            "Convex graph projection is not current for {}: {}",
5048            root.display(),
5049            freshness.diagnostics.join("; ")
5050        );
5051    }
5052    Ok(())
5053}
5054
5055fn convex_rows_diff(
5056    local: &ConvexProjectionRows,
5057    snapshot: Option<&ConvexProjectionRows>,
5058) -> (
5059    Vec<ConvexNodeRow>,
5060    Vec<ConvexEdgeRow>,
5061    Vec<String>,
5062    Vec<String>,
5063) {
5064    let Some(snapshot) = snapshot else {
5065        return (
5066            local.nodes.clone(),
5067            local.edges.clone(),
5068            Vec::new(),
5069            Vec::new(),
5070        );
5071    };
5072    let local_nodes = local
5073        .nodes
5074        .iter()
5075        .map(|row| (row.external_id.as_str(), row))
5076        .collect::<BTreeMap<_, _>>();
5077    let local_edges = local
5078        .edges
5079        .iter()
5080        .map(|row| (row.edge_key.as_str(), row))
5081        .collect::<BTreeMap<_, _>>();
5082    let snapshot_nodes = snapshot
5083        .nodes
5084        .iter()
5085        .map(|row| (row.external_id.as_str(), row))
5086        .collect::<BTreeMap<_, _>>();
5087    let snapshot_edges = snapshot
5088        .edges
5089        .iter()
5090        .map(|row| (row.edge_key.as_str(), row))
5091        .collect::<BTreeMap<_, _>>();
5092
5093    let node_upserts = local
5094        .nodes
5095        .iter()
5096        .filter(|row| {
5097            snapshot_nodes
5098                .get(row.external_id.as_str())
5099                .is_none_or(|snapshot_row| *snapshot_row != *row)
5100        })
5101        .cloned()
5102        .collect::<Vec<_>>();
5103    let edge_upserts = local
5104        .edges
5105        .iter()
5106        .filter(|row| {
5107            snapshot_edges
5108                .get(row.edge_key.as_str())
5109                .is_none_or(|snapshot_row| *snapshot_row != *row)
5110        })
5111        .cloned()
5112        .collect::<Vec<_>>();
5113    let node_tombstones = snapshot
5114        .nodes
5115        .iter()
5116        .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
5117        .map(|row| row.external_id.clone())
5118        .collect::<Vec<_>>();
5119    let edge_tombstones = snapshot
5120        .edges
5121        .iter()
5122        .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
5123        .map(|row| row.edge_key.clone())
5124        .collect::<Vec<_>>();
5125
5126    (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
5127}
5128
5129fn push_sync_chunks(
5130    chunks: &mut Vec<ConvexSyncChunk>,
5131    operation: &str,
5132    keys: Vec<String>,
5133    size: usize,
5134) {
5135    if keys.is_empty() {
5136        return;
5137    }
5138    for (idx, chunk) in keys.chunks(size).enumerate() {
5139        chunks.push(ConvexSyncChunk {
5140            operation: operation.to_string(),
5141            chunk: idx + 1,
5142            count: chunk.len(),
5143            keys: chunk.to_vec(),
5144            max_attempts: 3,
5145            retry_policy:
5146                "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
5147                    .to_string(),
5148        });
5149    }
5150}
5151
5152pub(crate) fn build_convex_sync_report_with_snapshot(
5153    path: &Path,
5154    scope: Option<&str>,
5155    snapshot: Option<ConvexProjectionRows>,
5156    chunk_size: usize,
5157    dry_run: bool,
5158) -> Result<ConvexSyncReport> {
5159    if chunk_size == 0 {
5160        bail!("--chunk-size must be greater than zero");
5161    }
5162    let root = lint::resolve_project_root_or_canonical_path(path)?;
5163    let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
5164    let graph_db = graph_substrate_db_path(&root, scope);
5165    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5166    let local = convex_rows_from_graph_store(&store)?;
5167    let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
5168    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
5169        convex_rows_diff(&local, snapshot.as_ref());
5170
5171    let mut chunks = Vec::new();
5172    push_sync_chunks(
5173        &mut chunks,
5174        "delete_edges",
5175        edge_tombstones.clone(),
5176        chunk_size,
5177    );
5178    push_sync_chunks(
5179        &mut chunks,
5180        "upsert_nodes",
5181        node_upserts
5182            .iter()
5183            .map(|row| row.external_id.clone())
5184            .collect(),
5185        chunk_size,
5186    );
5187    push_sync_chunks(
5188        &mut chunks,
5189        "upsert_edges",
5190        edge_upserts
5191            .iter()
5192            .map(|row| row.edge_key.clone())
5193            .collect(),
5194        chunk_size,
5195    );
5196    push_sync_chunks(
5197        &mut chunks,
5198        "delete_nodes",
5199        node_tombstones.clone(),
5200        chunk_size,
5201    );
5202
5203    let mut diagnostics = vec![
5204        "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5205            .to_string(),
5206    ];
5207    if dry_run {
5208        diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5209    }
5210    if freshness.fail_closed {
5211        diagnostics.push(
5212            "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5213                .to_string(),
5214        );
5215    }
5216
5217    Ok(ConvexSyncReport {
5218        root: root.to_string_lossy().to_string(),
5219        scope: scope.map(str::to_string),
5220        graph_db: graph_db.to_string_lossy().to_string(),
5221        dry_run,
5222        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5223        projection_hash: convex_projection_hash(&local, scope),
5224        required_indexes: convex_required_indexes(),
5225        node_upserts,
5226        edge_upserts,
5227        node_tombstones,
5228        edge_tombstones,
5229        chunks,
5230        freshness,
5231        transport: None,
5232        receipts: Vec::new(),
5233        diagnostics,
5234        warnings: graph.warnings,
5235    })
5236}
5237
5238#[cfg(test)]
5239fn build_convex_sync_report(
5240    path: &Path,
5241    scope: Option<&str>,
5242    snapshot_path: Option<&Path>,
5243    chunk_size: usize,
5244) -> Result<ConvexSyncReport> {
5245    let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5246    build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5247}
5248
5249pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5250    if compact {
5251        println!(
5252            "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5253            report.node_upserts.len(),
5254            report.node_tombstones.len(),
5255            report.edge_upserts.len(),
5256            report.edge_tombstones.len(),
5257            report.chunks.len(),
5258            report.freshness.status
5259        );
5260        return;
5261    }
5262
5263    println!(
5264        "Convex graph sync {}",
5265        if report.dry_run { "dry-run" } else { "apply" }
5266    );
5267    println!("root: {}", report.root);
5268    println!("graph_db: {}", report.graph_db);
5269    println!(
5270        "upserts: {} node(s), {} edge(s)",
5271        report.node_upserts.len(),
5272        report.edge_upserts.len()
5273    );
5274    println!(
5275        "tombstones: {} node(s), {} edge(s)",
5276        report.node_tombstones.len(),
5277        report.edge_tombstones.len()
5278    );
5279    println!("chunks: {}", report.chunks.len());
5280    println!("freshness: {}", report.freshness.status);
5281    if let Some(transport) = &report.transport {
5282        println!(
5283            "transport: endpoint_env={} auth_env={} applied_chunks={}",
5284            transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5285        );
5286    }
5287    for receipt in &report.receipts {
5288        println!(
5289            "receipt: {} chunk {} attempt {} {}",
5290            receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5291        );
5292    }
5293    for diagnostic in report
5294        .diagnostics
5295        .iter()
5296        .chain(report.freshness.diagnostics.iter())
5297    {
5298        println!("- {}", diagnostic);
5299    }
5300}
5301
5302pub(crate) struct ConvexSyncOptions<'a> {
5303    path: &'a Path,
5304    scope: Option<&'a str>,
5305    snapshot: Option<&'a Path>,
5306    chunk_size: usize,
5307    remote_snapshot: bool,
5308    apply: bool,
5309    endpoint: Option<&'a str>,
5310    auth_token_env: &'a str,
5311}
5312
5313#[derive(Serialize)]
5314struct GraphDbSchemaField {
5315    name: &'static str,
5316    value_type: &'static str,
5317    description: &'static str,
5318}
5319
5320#[derive(Serialize)]
5321struct GraphDbSchemaOperation {
5322    command: &'static str,
5323    description: &'static str,
5324}
5325
5326#[derive(Serialize)]
5327struct GraphDbSchemaContract {
5328    name: &'static str,
5329    version: &'static str,
5330    description: &'static str,
5331}
5332
5333#[derive(Serialize)]
5334struct GraphDbSchema {
5335    contract_versions: Vec<GraphDbSchemaContract>,
5336    node_fields: Vec<GraphDbSchemaField>,
5337    edge_fields: Vec<GraphDbSchemaField>,
5338    operations: Vec<GraphDbSchemaOperation>,
5339}
5340
5341#[derive(Clone, Serialize, Deserialize)]
5342struct GraphDbFreshnessReport {
5343    status: String,
5344    fail_closed: bool,
5345    projection_version: Option<String>,
5346    content_hash: Option<String>,
5347    source_watermark: Option<String>,
5348    diagnostics: Vec<String>,
5349}
5350
5351#[derive(Clone, Debug, Serialize)]
5352pub(crate) struct GraphEffectivenessReadiness {
5353    pub(crate) status: String,
5354    pub(crate) fail_closed: bool,
5355    pub(crate) reason: String,
5356    pub(crate) diagnostics: Vec<String>,
5357    pub(crate) next_commands: Vec<String>,
5358}
5359
5360#[derive(Clone, Debug, Serialize, PartialEq)]
5361struct GraphDbPropertyFilter {
5362    key: String,
5363    value: String,
5364}
5365
5366#[derive(Clone, Debug, Default)]
5367struct GraphDbQueryOptions {
5368    cursor: Option<String>,
5369    limit: Option<usize>,
5370    property_filters: Vec<GraphDbPropertyFilter>,
5371}
5372
5373#[derive(Clone, Debug, Serialize, PartialEq)]
5374struct GraphDbPageReport {
5375    #[serde(skip_serializing_if = "Option::is_none")]
5376    cursor: Option<String>,
5377    #[serde(skip_serializing_if = "Option::is_none")]
5378    limit: Option<usize>,
5379    #[serde(skip_serializing_if = "Option::is_none")]
5380    next_cursor: Option<String>,
5381    returned_nodes: usize,
5382    returned_edges: usize,
5383    truncated: bool,
5384    property_filters: Vec<GraphDbPropertyFilter>,
5385    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5386    diagnostics: Vec<String>,
5387}
5388
5389type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5390
5391#[derive(Clone, Debug, Serialize)]
5392struct CommunityTruncationSummary {
5393    total_communities: usize,
5394    fully_kept: usize,
5395    partially_pruned: usize,
5396    fully_pruned: usize,
5397    pruned_community_kinds: Vec<String>,
5398    pruned_community_top_labels: Vec<String>,
5399}
5400
5401#[derive(Clone, Debug, Serialize)]
5402struct GraphDbRankedNeighborhoodComparison {
5403    traversal_nodes: usize,
5404    traversal_edges: usize,
5405    pruned_count: usize,
5406    total_discovered: usize,
5407    latency_micros: u128,
5408    overlap_with_unranked_pct: f64,
5409    useful_hit_density_ranked: f64,
5410    useful_hit_density_unranked: f64,
5411    duplicate_name_count_ranked: usize,
5412    duplicate_name_count_unranked: usize,
5413    handle_coverage_ranked_pct: f64,
5414    handle_coverage_unranked_pct: f64,
5415    #[serde(skip_serializing_if = "Option::is_none")]
5416    community_truncation_summary: Option<CommunityTruncationSummary>,
5417    diagnostics: Vec<String>,
5418}
5419
5420#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5421struct GraphDbDroppedByBudget {
5422    item: String,
5423    kind: String,
5424    dropped: usize,
5425    reason: String,
5426}
5427
5428#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5429struct GraphDbOutputBudgetReport {
5430    max_tokens: usize,
5431    estimated_tokens: usize,
5432    selected_nodes: usize,
5433    selected_edges: usize,
5434    candidate_nodes: usize,
5435    candidate_edges: usize,
5436    dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5437    diagnostics: Vec<String>,
5438}
5439
5440#[derive(Clone, Debug, Serialize, PartialEq)]
5441struct GraphDbKnowledgeRetrieval {
5442    mode: String,
5443    query: String,
5444    seed_kind: String,
5445    seed_limit: usize,
5446    seed_count: usize,
5447    depth: usize,
5448    limit: usize,
5449    node_count: usize,
5450    edge_count: usize,
5451    truncated: bool,
5452    traversal: String,
5453    freshness_boundary: String,
5454    privacy_boundary: String,
5455    diagnostics: Vec<String>,
5456}
5457
5458struct GraphDbSemanticSeededSubgraph {
5459    nodes: Vec<SubstrateGraphNode>,
5460    edges: Vec<SubstrateGraphEdge>,
5461    truncated: bool,
5462    diagnostics: Vec<String>,
5463}
5464
5465type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5466
5467#[derive(Serialize)]
5468struct GraphDbReport {
5469    root: String,
5470    #[serde(skip_serializing_if = "Option::is_none")]
5471    scope: Option<String>,
5472    backend: String,
5473    query: String,
5474    freshness: GraphDbFreshnessReport,
5475    #[serde(skip_serializing_if = "Option::is_none")]
5476    readiness: Option<GraphEffectivenessReadiness>,
5477    #[serde(skip_serializing_if = "Option::is_none")]
5478    schema: Option<GraphDbSchema>,
5479    #[serde(skip_serializing_if = "Option::is_none")]
5480    node: Option<SubstrateTerseGraphNode>,
5481    #[serde(skip_serializing_if = "Option::is_none")]
5482    edge: Option<SubstrateTerseGraphEdge>,
5483    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5484    nodes: Vec<SubstrateTerseGraphNode>,
5485    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5486    edges: Vec<SubstrateTerseGraphEdge>,
5487    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5488    ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5489    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5490    semantic_related: Vec<SemanticRelatedItem>,
5491    #[serde(skip_serializing_if = "Option::is_none")]
5492    neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5493    #[serde(skip_serializing_if = "Option::is_none")]
5494    ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5495    #[serde(skip_serializing_if = "Option::is_none")]
5496    knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5497    #[serde(skip_serializing_if = "Option::is_none")]
5498    output_budget: Option<GraphDbOutputBudgetReport>,
5499    #[serde(skip_serializing_if = "Option::is_none")]
5500    path: Option<substrate::GraphPath>,
5501    #[serde(skip_serializing_if = "Option::is_none")]
5502    page: Option<GraphDbPageReport>,
5503    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5504    warnings: Vec<String>,
5505}
5506
5507struct ExperimentalReadOnlyGraphStore {
5508    backend: GraphDbExperimentalBackend,
5509    nodes: BTreeMap<String, SubstrateGraphNode>,
5510    edges: BTreeMap<String, SubstrateGraphEdge>,
5511    node_ids_by_kind: BTreeMap<String, Vec<String>>,
5512    outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5513}
5514
5515impl ExperimentalReadOnlyGraphStore {
5516    fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5517        validate_convex_projection_rows(rows)?;
5518        let nodes = rows
5519            .nodes
5520            .iter()
5521            .map(|row| {
5522                let node = SubstrateGraphNode {
5523                    id: row.external_id.clone(),
5524                    kind: row.kind.clone(),
5525                    label: row.label.clone(),
5526                    properties: row.properties.clone(),
5527                    provenance: row.provenance.clone(),
5528                    freshness: row.freshness.clone(),
5529                };
5530                (node.id.clone(), node)
5531            })
5532            .collect::<BTreeMap<_, _>>();
5533        let edges = rows
5534            .edges
5535            .iter()
5536            .map(|row| {
5537                let edge = SubstrateGraphEdge {
5538                    id: row.edge_key.clone(),
5539                    from_id: row.from_external_id.clone(),
5540                    to_id: row.to_external_id.clone(),
5541                    kind: row.kind.clone(),
5542                    properties: row.properties.clone(),
5543                    provenance: row.provenance.clone(),
5544                    freshness: row.freshness.clone(),
5545                };
5546                (graph_db_edge_key(&edge), edge)
5547            })
5548            .collect::<BTreeMap<_, _>>();
5549        let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
5550        for node in nodes.values() {
5551            node_ids_by_kind
5552                .entry(node.kind.clone())
5553                .or_default()
5554                .push(node.id.clone());
5555        }
5556        for ids in node_ids_by_kind.values_mut() {
5557            ids.sort();
5558        }
5559        let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
5560        for edge in edges.values() {
5561            outgoing_edge_keys_by_from
5562                .entry(edge.from_id.clone())
5563                .or_default()
5564                .push(graph_db_edge_key(edge));
5565        }
5566        for edge_keys in outgoing_edge_keys_by_from.values_mut() {
5567            edge_keys.sort_by(|left_key, right_key| {
5568                let left = &edges[left_key];
5569                let right = &edges[right_key];
5570                left.to_id
5571                    .cmp(&right.to_id)
5572                    .then(left.kind.cmp(&right.kind))
5573                    .then(left_key.cmp(right_key))
5574            });
5575        }
5576        Ok(Self {
5577            backend,
5578            nodes,
5579            edges,
5580            node_ids_by_kind,
5581            outgoing_edge_keys_by_from,
5582        })
5583    }
5584}
5585
5586impl GraphStore for ExperimentalReadOnlyGraphStore {
5587    fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
5588        bail!("{} backend-eval adapter is read-only", self.backend.name())
5589    }
5590
5591    fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
5592        bail!("{} backend-eval adapter is read-only", self.backend.name())
5593    }
5594
5595    fn delete_node(&self, _id: &str) -> Result<usize> {
5596        bail!("{} backend-eval adapter is read-only", self.backend.name())
5597    }
5598
5599    fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
5600        bail!("{} backend-eval adapter is read-only", self.backend.name())
5601    }
5602
5603    fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
5604        Ok(self.nodes.get(id).cloned())
5605    }
5606
5607    fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
5608        Ok(self.nodes.values().cloned().collect())
5609    }
5610
5611    fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
5612        let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
5613        edges.sort_by(|left, right| {
5614            left.from_id
5615                .cmp(&right.from_id)
5616                .then(left.kind.cmp(&right.kind))
5617                .then(left.to_id.cmp(&right.to_id))
5618        });
5619        Ok(edges)
5620    }
5621
5622    fn graph_counts(&self) -> Result<(usize, usize)> {
5623        Ok((self.nodes.len(), self.edges.len()))
5624    }
5625
5626    fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
5627        let mut edges = self
5628            .edges
5629            .values()
5630            .filter(|edge| edge.from_id != edge.to_id)
5631            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5632            .cloned()
5633            .collect::<Vec<_>>();
5634        edges.sort_by(|left, right| {
5635            left.from_id
5636                .cmp(&right.from_id)
5637                .then(left.kind.cmp(&right.kind))
5638                .then(left.to_id.cmp(&right.to_id))
5639        });
5640        Ok(edges.into_iter().next())
5641    }
5642
5643    fn sample_edge_with_property(
5644        &self,
5645    ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
5646        Ok(self
5647            .edges
5648            .values()
5649            .filter(|edge| edge.from_id != edge.to_id)
5650            .filter_map(|edge| {
5651                edge.properties.iter().next().map(|(key, value)| {
5652                    (
5653                        edge,
5654                        GraphPropertyFilter {
5655                            key: key.clone(),
5656                            value: value.clone(),
5657                        },
5658                    )
5659                })
5660            })
5661            .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
5662                left_filter
5663                    .key
5664                    .cmp(&right_filter.key)
5665                    .then(left_filter.value.cmp(&right_filter.value))
5666                    .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
5667            })
5668            .map(|(edge, filter)| (edge.clone(), filter)))
5669    }
5670
5671    fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
5672        Ok(self
5673            .node_ids_by_kind
5674            .get(kind)
5675            .into_iter()
5676            .flatten()
5677            .filter_map(|id| self.nodes.get(id).cloned())
5678            .collect())
5679    }
5680
5681    fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
5682        Ok(self
5683            .outgoing_edge_keys_by_from
5684            .get(from_id)
5685            .into_iter()
5686            .flatten()
5687            .filter_map(|key| self.edges.get(key))
5688            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5689            .cloned()
5690            .collect())
5691    }
5692
5693    fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
5694        Ok(self
5695            .edges
5696            .values()
5697            .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
5698            .cloned()
5699            .collect())
5700    }
5701
5702    fn shortest_path(
5703        &self,
5704        from_id: &str,
5705        to_id: &str,
5706        kind: Option<&str>,
5707    ) -> Result<Option<substrate::GraphPath>> {
5708        if from_id == to_id {
5709            return Ok(Some(substrate::GraphPath {
5710                nodes: vec![from_id.to_string()],
5711                hops: 0,
5712            }));
5713        }
5714
5715        let mut queue = VecDeque::new();
5716        let mut parent = BTreeMap::<String, String>::new();
5717        parent.insert(from_id.to_string(), String::new());
5718        queue.push_back(from_id.to_string());
5719
5720        while let Some(current) = queue.pop_front() {
5721            for edge in self.outgoing_edges(&current, kind)? {
5722                if parent.contains_key(&edge.to_id) {
5723                    continue;
5724                }
5725                parent.insert(edge.to_id.clone(), current.clone());
5726                if edge.to_id == to_id {
5727                    let mut nodes = vec![to_id.to_string()];
5728                    let mut cursor = to_id;
5729                    while let Some(previous) = parent.get(cursor) {
5730                        if previous.is_empty() {
5731                            break;
5732                        }
5733                        nodes.push(previous.clone());
5734                        cursor = previous;
5735                    }
5736                    nodes.reverse();
5737                    return Ok(Some(substrate::GraphPath {
5738                        hops: nodes.len().saturating_sub(1),
5739                        nodes,
5740                    }));
5741                }
5742                queue.push_back(edge.to_id);
5743            }
5744        }
5745
5746        Ok(None)
5747    }
5748
5749    fn reachable_nodes_by_kinds(
5750        &self,
5751        from_id: &str,
5752        kinds: &[&str],
5753        depth: usize,
5754        limit: usize,
5755    ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
5756        let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
5757        let mut rows = requested
5758            .iter()
5759            .map(|kind| {
5760                (
5761                    (*kind).to_string(),
5762                    BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
5763                )
5764            })
5765            .collect::<BTreeMap<_, _>>();
5766        if requested.is_empty() {
5767            return Ok(BTreeMap::new());
5768        }
5769
5770        let mut seen = BTreeSet::from([from_id.to_string()]);
5771        let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
5772        while let Some((current, path)) = queue.pop_front() {
5773            let current_depth = path.len().saturating_sub(1);
5774            if current_depth >= depth {
5775                continue;
5776            }
5777            for edge in self.outgoing_edges(&current, None)? {
5778                if !seen.insert(edge.to_id.clone()) {
5779                    continue;
5780                }
5781                let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
5782                    continue;
5783                };
5784                let mut next_path = path.clone();
5785                next_path.push(edge.to_id.clone());
5786                let graph_path = substrate::GraphPath {
5787                    hops: next_path.len().saturating_sub(1),
5788                    nodes: next_path.clone(),
5789                };
5790                if requested.contains(node.kind.as_str()) {
5791                    rows.entry(node.kind.clone())
5792                        .or_default()
5793                        .entry(node.id.clone())
5794                        .or_insert((node.clone(), graph_path));
5795                }
5796                queue.push_back((edge.to_id, next_path));
5797            }
5798        }
5799
5800        Ok(rows
5801            .into_iter()
5802            .map(|(kind, values)| {
5803                let mut values = values.into_values().collect::<Vec<_>>();
5804                values.sort_by(|(left_node, left_path), (right_node, right_path)| {
5805                    left_path
5806                        .hops
5807                        .cmp(&right_path.hops)
5808                        .then(left_node.label.cmp(&right_node.label))
5809                        .then(left_node.id.cmp(&right_node.id))
5810                });
5811                if limit > 0 && values.len() > limit {
5812                    values.truncate(limit);
5813                }
5814                (kind, values)
5815            })
5816            .collect())
5817    }
5818}
5819
5820pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
5821pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
5822pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
5823const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
5824pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
5825const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
5826const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
5827const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
5828const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
5829
5830#[derive(Clone, Serialize, Deserialize)]
5831pub(crate) struct GraphDbBackendEvalPhaseTiming {
5832    name: String,
5833    duration_micros: u128,
5834    detail: String,
5835}
5836
5837#[derive(Serialize, Deserialize)]
5838struct GraphDbBackendEvalFullProjectionCache {
5839    version: String,
5840    key: String,
5841    source_watermark: String,
5842    projection: GraphProjection,
5843    warnings: Vec<String>,
5844}
5845
5846#[derive(Clone, Default)]
5847struct GraphDbBackendEvalFullProjectionCacheStats {
5848    hit: bool,
5849    disk_bytes: u64,
5850    json_bytes: u64,
5851    pruned_files: usize,
5852    pruned_bytes: u64,
5853}
5854
5855#[derive(Serialize)]
5856struct GraphDbBackendEvalRawSourceWatermarkRow {
5857    path: String,
5858    bytes: u64,
5859    content_hash: String,
5860}
5861
5862#[derive(Clone)]
5863struct GraphDbBackendEvalFullProjectionSourceWatermark {
5864    value: String,
5865    detail: String,
5866}
5867
5868#[derive(Serialize)]
5869pub(crate) struct GraphDbBackendEvalConfig {
5870    high_degree_nodes: usize,
5871    high_degree_fanout: usize,
5872    deep_chain_nodes: usize,
5873    deep_chain_fanout: usize,
5874    depth: usize,
5875    limit: usize,
5876    impact_limit: usize,
5877    path_max_hops: usize,
5878    path_direct_hop_budget: usize,
5879    path_deep_chain_hop_budget: usize,
5880    path_extended_hop_budgets: Vec<usize>,
5881    path_hop_policy: String,
5882    path_probe_strategy: String,
5883    path_query_plan_checks: Vec<String>,
5884    full_projection_enabled: bool,
5885    full_projection_profile: String,
5886    normalization_row_unit: usize,
5887}
5888
5889#[derive(Clone)]
5890struct GraphDbBackendEvalSignature {
5891    operation: String,
5892    value: serde_json::Value,
5893}
5894
5895#[derive(Serialize)]
5896struct GraphDbBackendEvalOperation {
5897    name: String,
5898    supported: bool,
5899    status: String,
5900    duration_micros: u128,
5901    #[serde(skip_serializing_if = "Option::is_none")]
5902    rows: Option<usize>,
5903    #[serde(skip_serializing_if = "Option::is_none")]
5904    error: Option<String>,
5905}
5906
5907#[derive(Serialize)]
5908struct GraphDbBackendEvalParity {
5909    matches_sqlite: bool,
5910    diagnostics: Vec<String>,
5911}
5912
5913#[derive(Serialize)]
5914struct GraphDbBackendEvalBackendReport {
5915    backend: String,
5916    adapter: String,
5917    read_only: bool,
5918    projection_load: String,
5919    operations: Vec<GraphDbBackendEvalOperation>,
5920    total_micros: u128,
5921    parity: GraphDbBackendEvalParity,
5922    lock_behavior: String,
5923    install_portability: String,
5924}
5925
5926#[derive(Serialize)]
5927struct GraphDbBackendEvalDataset {
5928    name: String,
5929    target_count: usize,
5930    nodes: usize,
5931    edges: usize,
5932    backends: Vec<GraphDbBackendEvalBackendReport>,
5933}
5934
5935#[derive(Serialize)]
5936struct GraphDbBackendPromotionDecision {
5937    backend: String,
5938    decision: String,
5939    reasons: Vec<String>,
5940    gate: GraphDbBackendPromotionGate,
5941}
5942
5943#[derive(Serialize)]
5944struct GraphDbBackendEvalPerformanceGate {
5945    baseline_fixture: String,
5946    ci_profile: String,
5947    opt_in_real_profile: String,
5948    full_projection_cache_hit_gate: String,
5949    allowed_regression_percent: f64,
5950    minimum_sample_runs: usize,
5951    normalized_metric_unit: String,
5952    required_metrics: Vec<String>,
5953    digest_command: String,
5954    repeated_sample_command: String,
5955    hop_cap_promotion: GraphDbHopCapPromotionGate,
5956    backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
5957}
5958
5959#[derive(Serialize)]
5960struct GraphDbHopCapPromotionGate {
5961    status: String,
5962    current_default_hops: usize,
5963    candidate_hop_tiers: Vec<usize>,
5964    required_backend: String,
5965    required_workloads: Vec<String>,
5966    required_metrics: Vec<String>,
5967    allowed_regression_percent: f64,
5968    minimum_sample_runs: usize,
5969    decision_rule: String,
5970}
5971
5972#[derive(Serialize)]
5973struct GraphDbBackendAdapterSpikeGate {
5974    status: String,
5975    candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
5976    required_workloads: Vec<String>,
5977    required_checks: Vec<String>,
5978    decision_rule: String,
5979    evidence_plan: String,
5980}
5981
5982#[derive(Serialize)]
5983struct GraphDbBackendAdapterSpikeCandidate {
5984    backend: String,
5985    adapter_label: String,
5986    projection_load: String,
5987    lock_behavior: String,
5988    install_portability: String,
5989}
5990
5991#[derive(Serialize)]
5992pub(crate) struct GraphDbBackendEvalReport {
5993    root: String,
5994    #[serde(skip_serializing_if = "Option::is_none")]
5995    scope: Option<String>,
5996    label: String,
5997    baseline_backend: String,
5998    candidates: Vec<String>,
5999    targets: Vec<String>,
6000    config: GraphDbBackendEvalConfig,
6001    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6002    datasets: Vec<GraphDbBackendEvalDataset>,
6003    promotion: Vec<GraphDbBackendPromotionDecision>,
6004    performance_gate: GraphDbBackendEvalPerformanceGate,
6005    metrics: BTreeMap<String, f64>,
6006    metric_digest_command: String,
6007    warnings: Vec<String>,
6008}
6009
6010#[derive(Clone, Debug, Serialize)]
6011struct GraphDbDoctorCheck {
6012    name: String,
6013    status: String,
6014    fail_closed: bool,
6015    diagnostics: Vec<String>,
6016    repair_commands: Vec<String>,
6017}
6018
6019#[derive(Serialize)]
6020pub(crate) struct GraphDbDoctorReport {
6021    root: String,
6022    #[serde(skip_serializing_if = "Option::is_none")]
6023    scope: Option<String>,
6024    backend: String,
6025    graph_db: String,
6026    #[serde(skip_serializing_if = "Option::is_none")]
6027    convex_snapshot: Option<String>,
6028    status: String,
6029    fail_closed: bool,
6030    checks: Vec<GraphDbDoctorCheck>,
6031    repair_commands: Vec<String>,
6032    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6033    required_indexes: Vec<ConvexRequiredIndex>,
6034}
6035
6036#[derive(Serialize)]
6037struct GraphDbDriftSummary {
6038    node_upserts: usize,
6039    edge_upserts: usize,
6040    node_tombstones: usize,
6041    edge_tombstones: usize,
6042    stale_nodes: usize,
6043    stale_edges: usize,
6044    stale_projection_metadata: usize,
6045    duplicate_failures: usize,
6046    orphan_failures: usize,
6047    missing_required_indexes: usize,
6048}
6049
6050#[derive(Serialize)]
6051struct GraphDbDriftReport {
6052    root: String,
6053    #[serde(skip_serializing_if = "Option::is_none")]
6054    scope: Option<String>,
6055    graph_db: String,
6056    convex_snapshot: String,
6057    status: String,
6058    graph_reads_allowed: bool,
6059    projection_version: String,
6060    local_hash: Option<String>,
6061    snapshot_hash: Option<String>,
6062    summary: GraphDbDriftSummary,
6063    node_upserts: Vec<String>,
6064    edge_upserts: Vec<String>,
6065    node_tombstones: Vec<String>,
6066    edge_tombstones: Vec<String>,
6067    stale_nodes: Vec<String>,
6068    stale_edges: Vec<String>,
6069    diagnostics: Vec<String>,
6070    next_commands: Vec<String>,
6071    required_indexes: Vec<ConvexRequiredIndex>,
6072    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6073    warnings: Vec<String>,
6074}
6075
6076#[derive(Clone, Serialize)]
6077struct GraphDbTombstoneCounts {
6078    nodes: usize,
6079    edges: usize,
6080    total: usize,
6081}
6082
6083#[derive(Clone, Serialize)]
6084struct GraphDbOperatorCounts {
6085    nodes: usize,
6086    edges: usize,
6087    tombstones: GraphDbTombstoneCounts,
6088    #[serde(skip_serializing_if = "Option::is_none")]
6089    file_size_bytes: Option<u64>,
6090    #[serde(skip_serializing_if = "Option::is_none")]
6091    freelist_bytes: Option<u64>,
6092}
6093
6094#[derive(Clone, Serialize)]
6095struct GraphDbCompactionPolicy {
6096    status: String,
6097    tombstone_scan_rows: usize,
6098    live_rows: usize,
6099    file_size_bytes: Option<u64>,
6100    freelist_bytes: Option<u64>,
6101    safe_to_prune_tombstones: bool,
6102    requires_convex_reconciliation: bool,
6103    recommendations: Vec<String>,
6104    proof: Vec<String>,
6105}
6106
6107#[derive(Serialize)]
6108pub(crate) struct GraphDbRefreshSummary {
6109    scope: String,
6110    projection_version: String,
6111    mode: String,
6112    #[serde(skip_serializing_if = "Option::is_none")]
6113    source_watermark: Option<String>,
6114    tombstoned_nodes: usize,
6115    tombstoned_edges: usize,
6116    upserted_nodes: usize,
6117    upserted_edges: usize,
6118    unchanged_nodes: usize,
6119    unchanged_edges: usize,
6120    upserted_properties: usize,
6121    unchanged_properties: usize,
6122    deleted_properties: usize,
6123    deleted_nodes: usize,
6124    deleted_edges: usize,
6125    pruned_tombstones: usize,
6126    #[serde(skip_serializing_if = "Option::is_none")]
6127    file_size_bytes_before: Option<u64>,
6128    #[serde(skip_serializing_if = "Option::is_none")]
6129    file_size_bytes_after: Option<u64>,
6130    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6131    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6132}
6133
6134#[derive(Serialize)]
6135struct GraphDbOperatorReport {
6136    root: String,
6137    #[serde(skip_serializing_if = "Option::is_none")]
6138    scope: Option<String>,
6139    graph_db: String,
6140    operation: String,
6141    status: String,
6142    materialized: bool,
6143    freshness: GraphDbFreshnessReport,
6144    readiness: GraphEffectivenessReadiness,
6145    counts: GraphDbOperatorCounts,
6146    #[serde(skip_serializing_if = "Option::is_none")]
6147    refresh: Option<GraphDbRefreshSummary>,
6148    compaction: GraphDbCompactionPolicy,
6149    #[serde(skip_serializing_if = "Option::is_none")]
6150    recovery: Option<index::ReadOnlyRecovery>,
6151    next_commands: Vec<String>,
6152    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6153    warnings: Vec<String>,
6154}
6155
6156#[derive(Serialize)]
6157pub(crate) struct GraphDbCompactionReport {
6158    root: String,
6159    #[serde(skip_serializing_if = "Option::is_none")]
6160    scope: Option<String>,
6161    graph_db: String,
6162    applied: bool,
6163    pruned_tombstones: usize,
6164    counts_before: GraphDbOperatorCounts,
6165    counts_after: GraphDbOperatorCounts,
6166    compaction_before: GraphDbCompactionPolicy,
6167    compaction_after: GraphDbCompactionPolicy,
6168    reclaimed_bytes: i64,
6169    next_commands: Vec<String>,
6170    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6171    warnings: Vec<String>,
6172}
6173
6174#[derive(Clone, Serialize, Deserialize)]
6175struct GraphDbEvidencePath {
6176    to: String,
6177    kind: String,
6178    label: String,
6179    #[serde(skip_serializing_if = "Option::is_none")]
6180    path: Option<substrate::GraphPath>,
6181    #[serde(skip_serializing_if = "Option::is_none")]
6182    expand: Option<String>,
6183}
6184
6185#[derive(Clone, Serialize, Deserialize)]
6186struct GraphDbFixtureCoverage {
6187    test: String,
6188    fixture: String,
6189    assertions: Vec<String>,
6190}
6191
6192#[derive(Clone, Serialize, Deserialize)]
6193struct GraphDbEvidenceReport {
6194    root: String,
6195    #[serde(skip_serializing_if = "Option::is_none")]
6196    scope: Option<String>,
6197    backend: String,
6198    contract_version: String,
6199    target: String,
6200    packet_id: String,
6201    #[serde(skip_serializing_if = "Option::is_none")]
6202    projection_hash: Option<String>,
6203    freshness: GraphDbFreshnessReport,
6204    target_node: SubstrateTerseGraphNode,
6205    worker_context: Vec<SubstrateTerseGraphNode>,
6206    source_handles: Vec<SubstrateTerseGraphNode>,
6207    worker_results: Vec<SubstrateTerseGraphNode>,
6208    semantic_related: Vec<SubstrateTerseGraphNode>,
6209    shortest_paths: Vec<GraphDbEvidencePath>,
6210    #[serde(skip_serializing_if = "Option::is_none")]
6211    output_budget: Option<GraphDbOutputBudgetReport>,
6212    #[serde(default)]
6213    truncated: bool,
6214    #[serde(skip_serializing_if = "Option::is_none")]
6215    next_cursor: Option<String>,
6216    next_commands: Vec<String>,
6217    replay_commands: Vec<String>,
6218    repair_commands: Vec<String>,
6219    fixture_coverage: GraphDbFixtureCoverage,
6220    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6221    warnings: Vec<String>,
6222}
6223
6224pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6225    root: &'a Path,
6226    scope: Option<&'a str>,
6227    backend: &'a str,
6228    target: &'a str,
6229    depth: usize,
6230    limit: usize,
6231    cursor: Option<&'a str>,
6232    store: &'a S,
6233    freshness: GraphDbFreshnessReport,
6234    warnings: Vec<String>,
6235}
6236
6237impl GraphDbDoctorReport {
6238    fn new(
6239        root: &Path,
6240        scope: Option<&str>,
6241        backend: &str,
6242        graph_db: &Path,
6243        convex_snapshot: Option<&Path>,
6244    ) -> Self {
6245        Self {
6246            root: root.to_string_lossy().to_string(),
6247            scope: scope.map(str::to_string),
6248            backend: backend.to_string(),
6249            graph_db: graph_db.to_string_lossy().to_string(),
6250            convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6251            status: "ok".to_string(),
6252            fail_closed: false,
6253            checks: Vec::new(),
6254            repair_commands: Vec::new(),
6255            required_indexes: Vec::new(),
6256        }
6257    }
6258
6259    fn push_check(&mut self, check: GraphDbDoctorCheck) {
6260        self.checks.push(check);
6261    }
6262
6263    fn finalize(&mut self) {
6264        self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6265        self.status = if self.fail_closed {
6266            "fail_closed"
6267        } else {
6268            "ok"
6269        }
6270        .to_string();
6271        let mut commands = BTreeSet::new();
6272        for check in &self.checks {
6273            commands.extend(check.repair_commands.iter().cloned());
6274        }
6275        self.repair_commands = commands.into_iter().collect();
6276    }
6277
6278    fn summary(&self) -> String {
6279        self.checks
6280            .iter()
6281            .filter(|check| check.fail_closed)
6282            .flat_map(|check| check.diagnostics.iter())
6283            .take(3)
6284            .cloned()
6285            .collect::<Vec<_>>()
6286            .join("; ")
6287    }
6288}
6289
6290fn graph_db_doctor_check(
6291    name: impl Into<String>,
6292    diagnostics: Vec<String>,
6293    repair_commands: Vec<String>,
6294) -> GraphDbDoctorCheck {
6295    let fail_closed = !diagnostics.is_empty();
6296    GraphDbDoctorCheck {
6297        name: name.into(),
6298        status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6299        fail_closed,
6300        diagnostics,
6301        repair_commands: if fail_closed {
6302            repair_commands
6303        } else {
6304            Vec::new()
6305        },
6306    }
6307}
6308
6309pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6310    scope
6311        .map(|scope| format!(" --scope {}", shell_quote(scope)))
6312        .unwrap_or_default()
6313}
6314
6315fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6316    format!(
6317        "tsift graph-db --path {}{} refresh --json",
6318        shell_quote(root.to_string_lossy().as_ref()),
6319        graph_db_scope_arg(scope)
6320    )
6321}
6322
6323fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6324    graph_db_refresh_command(root, scope)
6325}
6326
6327fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6328    let backup = format!("{}.bak", graph_db.to_string_lossy());
6329    format!(
6330        "mv {} {} && {}",
6331        shell_quote(graph_db.to_string_lossy().as_ref()),
6332        shell_quote(&backup),
6333        graph_db_rebuild_command(root, scope)
6334    )
6335}
6336
6337fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6338    format!(
6339        "tsift convex-sync {}{} --remote-snapshot --apply --json",
6340        shell_quote(root.to_string_lossy().as_ref()),
6341        graph_db_scope_arg(scope)
6342    )
6343}
6344
6345fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6346    substrate::open_graph_read_only_connection_resilient(graph_db)
6347}
6348
6349fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6350    conn.query_row(
6351        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6352        [table],
6353        |row| row.get::<_, bool>(0),
6354    )
6355    .map_err(Into::into)
6356}
6357
6358fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6359    let sql = match table {
6360        "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6361        "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6362        "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6363        other => bail!("unsupported graph count table {other}"),
6364    };
6365    conn.query_row(sql, [], |row| row.get::<_, usize>(0))
6366        .map_err(Into::into)
6367}
6368
6369fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6370    if !sqlite_table_exists(conn, "graph_tombstones")? {
6371        return Ok(GraphDbTombstoneCounts {
6372            nodes: 0,
6373            edges: 0,
6374            total: 0,
6375        });
6376    }
6377    let mut stmt =
6378        conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6379    let mut rows = stmt.query([])?;
6380    let mut nodes = 0usize;
6381    let mut edges = 0usize;
6382    while let Some(row) = rows.next()? {
6383        let row_kind: String = row.get(0)?;
6384        let count: usize = row.get(1)?;
6385        match row_kind.as_str() {
6386            "node" => nodes = count,
6387            "edge" => edges = count,
6388            _ => {}
6389        }
6390    }
6391    Ok(GraphDbTombstoneCounts {
6392        nodes,
6393        edges,
6394        total: nodes + edges,
6395    })
6396}
6397
6398fn sqlite_graph_counts_from_cache(
6399    conn: &Connection,
6400    scope: &str,
6401) -> Result<Option<GraphDbOperatorCounts>> {
6402    if !sqlite_table_exists(conn, "graph_operator_stats")? {
6403        return Ok(None);
6404    }
6405    let row = conn
6406        .query_row(
6407            r#"
6408        SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6409        FROM graph_operator_stats
6410        WHERE scope = ?1
6411        "#,
6412            [scope],
6413            |row| {
6414                Ok((
6415                    row.get::<_, usize>(0)?,
6416                    row.get::<_, usize>(1)?,
6417                    row.get::<_, usize>(2)?,
6418                    row.get::<_, usize>(3)?,
6419                    row.get::<_, Option<i64>>(4)?,
6420                    row.get::<_, Option<i64>>(5)?,
6421                ))
6422            },
6423        )
6424        .optional()?;
6425    Ok(row.map(
6426        |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6427            GraphDbOperatorCounts {
6428                nodes,
6429                edges,
6430                tombstones: GraphDbTombstoneCounts {
6431                    nodes: tombstone_nodes,
6432                    edges: tombstone_edges,
6433                    total: tombstone_nodes + tombstone_edges,
6434                },
6435                file_size_bytes: file_size_bytes
6436                    .and_then(|value| u64::try_from(value).ok())
6437                    .or_else(|| sqlite_database_size_bytes(conn).ok()),
6438                freelist_bytes: freelist_bytes
6439                    .and_then(|value| u64::try_from(value).ok())
6440                    .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6441            }
6442        },
6443    ))
6444}
6445
6446fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6447    if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6448        return Ok(counts);
6449    }
6450    let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6451        sqlite_known_table_count(conn, "graph_nodes")?
6452    } else {
6453        0
6454    };
6455    let edges = if sqlite_table_exists(conn, "graph_edges")? {
6456        sqlite_known_table_count(conn, "graph_edges")?
6457    } else {
6458        0
6459    };
6460    Ok(GraphDbOperatorCounts {
6461        nodes,
6462        edges,
6463        tombstones: sqlite_tombstone_counts(conn)?,
6464        file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6465        freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6466    })
6467}
6468
6469fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6470    if !sqlite_table_exists(conn, "graph_nodes")? {
6471        return Ok(0);
6472    }
6473    let count: i64 = conn.query_row(
6474        "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6475        [],
6476        |row| row.get(0),
6477    )?;
6478    Ok(count as usize)
6479}
6480
6481pub(crate) fn graph_db_compaction_policy(
6482    root: &Path,
6483    scope: Option<&str>,
6484    counts: &GraphDbOperatorCounts,
6485    prune_confirmed: bool,
6486) -> GraphDbCompactionPolicy {
6487    let live_rows = counts.nodes + counts.edges;
6488    let tombstone_scan_rows = counts.tombstones.total;
6489    let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6490    let freelist_heavy = counts
6491        .file_size_bytes
6492        .zip(counts.freelist_bytes)
6493        .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6494    let status = if tombstone_heavy || freelist_heavy {
6495        "recommended"
6496    } else {
6497        "not_needed"
6498    }
6499    .to_string();
6500    let mut recommendations = vec![
6501        convex_refresh_command(root, scope),
6502        graph_db_refresh_command(root, scope),
6503        format!(
6504            "tsift graph-db --path {}{} compact --apply --json",
6505            shell_quote(root.to_string_lossy().as_ref()),
6506            graph_db_scope_arg(scope)
6507        ),
6508    ];
6509    if prune_confirmed {
6510        recommendations.push(format!(
6511            "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6512            shell_quote(root.to_string_lossy().as_ref()),
6513            graph_db_scope_arg(scope)
6514        ));
6515    }
6516    let proof = vec![
6517        format!("{live_rows} live graph row(s)"),
6518        format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
6519        format!(
6520            "graph.db file_size={} byte(s), freelist={} byte(s)",
6521            counts.file_size_bytes.unwrap_or(0),
6522            counts.freelist_bytes.unwrap_or(0)
6523        ),
6524    ];
6525    GraphDbCompactionPolicy {
6526        status,
6527        tombstone_scan_rows,
6528        live_rows,
6529        file_size_bytes: counts.file_size_bytes,
6530        freelist_bytes: counts.freelist_bytes,
6531        safe_to_prune_tombstones: prune_confirmed,
6532        requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
6533        recommendations,
6534        proof,
6535    }
6536}
6537
6538fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
6539    let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
6540    let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6541    Ok(page_count.saturating_mul(page_size))
6542}
6543
6544fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
6545    let freelist_count: u64 = conn.query_row("PRAGMA freelist_count", [], |row| row.get(0))?;
6546    let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6547    Ok(freelist_count.saturating_mul(page_size))
6548}
6549
6550fn sqlite_graph_tombstone_retention_diagnostics(
6551    conn: &Connection,
6552    scope: &str,
6553) -> Result<Vec<String>> {
6554    if !sqlite_table_exists(conn, "graph_tombstones")? {
6555        return Ok(Vec::new());
6556    }
6557    let cached = sqlite_graph_counts_from_cache(conn, scope)?;
6558    let counts = match cached.clone() {
6559        Some(counts) => counts,
6560        None => sqlite_graph_counts(conn, scope)?,
6561    };
6562    let live_rows = counts.nodes + counts.edges;
6563    let file_size = counts.file_size_bytes.unwrap_or(0);
6564    let freelist = counts.freelist_bytes.unwrap_or(0);
6565    let stale_live_tombstones = if cached.is_some() {
6566        0
6567    } else {
6568        let mut live_keys = BTreeSet::new();
6569        if sqlite_table_exists(conn, "graph_nodes")? {
6570            let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
6571            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6572                live_keys.insert(format!("node:{}", row?));
6573            }
6574        }
6575        if sqlite_table_exists(conn, "graph_edges")? {
6576            let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
6577            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6578                live_keys.insert(format!("edge:{}", row?));
6579            }
6580        }
6581        let mut stale_live_tombstones = 0usize;
6582        let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
6583        for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6584            if live_keys.contains(&row?) {
6585                stale_live_tombstones += 1;
6586            }
6587        }
6588        stale_live_tombstones
6589    };
6590
6591    let mut diagnostics = Vec::new();
6592    if stale_live_tombstones > 0 {
6593        diagnostics.push(format!(
6594            "{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"
6595        ));
6596    }
6597    if counts.tombstones.total > live_rows.max(1) {
6598        let source = if cached.is_some() {
6599            "cached refresh stats"
6600        } else {
6601            "live row scan"
6602        };
6603        diagnostics.push(format!(
6604            "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.",
6605            counts.tombstones.total,
6606            live_rows,
6607            source,
6608            file_size,
6609            freelist,
6610            counts.tombstones.total
6611        ));
6612    }
6613    Ok(diagnostics)
6614}
6615
6616fn sqlite_graph_freshness_from_conn(
6617    conn: &Connection,
6618    scope: &str,
6619) -> Result<GraphDbFreshnessReport> {
6620    if !sqlite_table_exists(conn, "graph_projection_versions")? {
6621        return Ok(GraphDbFreshnessReport {
6622            status: "missing".to_string(),
6623            fail_closed: true,
6624            projection_version: None,
6625            content_hash: None,
6626            source_watermark: None,
6627            diagnostics: vec![
6628                "graph projection metadata table is missing; refresh graph.db before trusting reads"
6629                    .to_string(),
6630            ],
6631        });
6632    }
6633    let version = conn
6634        .query_row(
6635            r#"
6636            SELECT projection_version, content_hash, source_watermark
6637            FROM graph_projection_versions
6638            WHERE scope = ?1
6639            "#,
6640            [scope],
6641            |row| {
6642                Ok((
6643                    row.get::<_, String>(0)?,
6644                    row.get::<_, Option<String>>(1)?,
6645                    row.get::<_, Option<String>>(2)?,
6646                ))
6647            },
6648        )
6649        .optional()?;
6650    let Some((projection_version, content_hash, source_watermark)) = version else {
6651        return Ok(GraphDbFreshnessReport {
6652            status: "missing".to_string(),
6653            fail_closed: true,
6654            projection_version: None,
6655            content_hash: None,
6656            source_watermark: None,
6657            diagnostics: vec![
6658                "graph projection metadata is missing; refresh graph.db before trusting reads"
6659                    .to_string(),
6660            ],
6661        });
6662    };
6663
6664    let mut diagnostics = Vec::new();
6665    if projection_version != GRAPH_PROJECTION_VERSION {
6666        diagnostics.push(format!(
6667            "projection version mismatch: expected {} got {}",
6668            GRAPH_PROJECTION_VERSION, projection_version
6669        ));
6670    }
6671    if content_hash.is_none() {
6672        diagnostics.push("projection content hash is missing".to_string());
6673    }
6674    let fail_closed = !diagnostics.is_empty();
6675    Ok(GraphDbFreshnessReport {
6676        status: if fail_closed { "stale" } else { "current" }.to_string(),
6677        fail_closed,
6678        projection_version: Some(projection_version),
6679        content_hash,
6680        source_watermark,
6681        diagnostics,
6682    })
6683}
6684
6685fn graph_db_operator_next_commands(
6686    root: &Path,
6687    scope: Option<&str>,
6688    include_refresh: bool,
6689) -> Vec<String> {
6690    let mut commands = Vec::new();
6691    if include_refresh {
6692        commands.push(graph_db_refresh_command(root, scope));
6693    }
6694    commands.push(format!(
6695        "tsift graph-db --path {}{} doctor --json",
6696        shell_quote(root.to_string_lossy().as_ref()),
6697        graph_db_scope_arg(scope)
6698    ));
6699    commands.push(format!(
6700        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
6701        shell_quote(root.to_string_lossy().as_ref()),
6702        graph_db_scope_arg(scope)
6703    ));
6704    commands.push(format!(
6705        "tsift convex-sync {}{} --remote-snapshot --apply --json",
6706        shell_quote(root.to_string_lossy().as_ref()),
6707        graph_db_scope_arg(scope)
6708    ));
6709    commands
6710}
6711
6712pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
6713    match recovery {
6714        index::ReadOnlyRecovery::SnapshotFallback => {
6715            "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
6716        }
6717        index::ReadOnlyRecovery::SnapshotFallbackWal => {
6718            "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
6719        }
6720    }
6721}
6722
6723fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
6724    let mut stmt = conn.prepare(sql)?;
6725    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6726    let mut values = BTreeSet::new();
6727    for row in rows {
6728        values.insert(row?);
6729    }
6730    Ok(values)
6731}
6732
6733fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
6734    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
6735    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
6736    let mut columns = BTreeSet::new();
6737    for row in rows {
6738        columns.insert(row?);
6739    }
6740    Ok(columns)
6741}
6742
6743fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6744    let mut diagnostics = Vec::new();
6745    let user_version: i64 =
6746        conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
6747    if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
6748        diagnostics.push(format!(
6749            "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6750        ));
6751    } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
6752        diagnostics.push(format!(
6753            "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6754        ));
6755    }
6756
6757    let tables = sqlite_string_set(
6758        conn,
6759        "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
6760    )?;
6761    let required_tables = [
6762        (
6763            "graph_nodes",
6764            vec![
6765                "id",
6766                "kind",
6767                "label",
6768                "properties_json",
6769                "provenance_json",
6770                "freshness_json",
6771                "row_hash",
6772                "source_watermark",
6773            ],
6774        ),
6775        (
6776            "graph_edges",
6777            vec![
6778                "edge_key",
6779                "from_id",
6780                "to_id",
6781                "kind",
6782                "properties_json",
6783                "provenance_json",
6784                "freshness_json",
6785                "row_hash",
6786                "source_watermark",
6787            ],
6788        ),
6789        (
6790            "graph_projection_versions",
6791            vec![
6792                "scope",
6793                "projection_version",
6794                "content_hash",
6795                "source_watermark",
6796                "observed_at_unix",
6797            ],
6798        ),
6799        (
6800            "graph_tombstones",
6801            vec!["row_key", "row_kind", "deleted_at_unix"],
6802        ),
6803        ("graph_node_properties", vec!["node_id", "key", "value"]),
6804        ("graph_edge_properties", vec!["edge_key", "key", "value"]),
6805    ];
6806    for (table, required_columns) in required_tables {
6807        if !tables.contains(table) {
6808            diagnostics.push(format!("graph.db schema drift: missing table {table}"));
6809            continue;
6810        }
6811        let columns = sqlite_column_names(conn, table)?;
6812        for column in required_columns {
6813            if !columns.contains(column) {
6814                diagnostics.push(format!(
6815                    "graph.db schema drift: missing column {table}.{column}"
6816                ));
6817            }
6818        }
6819    }
6820
6821    let indexes = sqlite_string_set(
6822        conn,
6823        "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
6824    )?;
6825    for index in [
6826        "idx_graph_nodes_kind",
6827        "idx_graph_edges_from_kind",
6828        "idx_graph_edges_to_kind",
6829        "idx_graph_edges_edge_key",
6830        "idx_graph_node_properties_key_value_node",
6831        "idx_graph_edge_properties_key_value_edge",
6832    ] {
6833        if !indexes.contains(index) {
6834            diagnostics.push(format!("graph.db schema drift: missing index {index}"));
6835        }
6836    }
6837
6838    if tables.contains("graph_edges") {
6839        let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
6840        let rows = stmt.query_map([], |row| {
6841            Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
6842        })?;
6843        let mut fks = BTreeSet::new();
6844        for row in rows {
6845            fks.insert(row?);
6846        }
6847        for expected in [
6848            ("from_id".to_string(), "id".to_string()),
6849            ("to_id".to_string(), "id".to_string()),
6850        ] {
6851            if !fks.contains(&expected) {
6852                diagnostics.push(format!(
6853                    "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
6854                    expected.0, expected.1
6855                ));
6856            }
6857        }
6858    }
6859
6860    Ok(diagnostics)
6861}
6862
6863fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
6864    let mut stmt = conn.prepare(sql)?;
6865    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6866    let mut diagnostics = Vec::new();
6867    for row in rows {
6868        diagnostics.push(row?);
6869    }
6870    Ok(diagnostics)
6871}
6872
6873fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6874    let mut diagnostics = sqlite_query_diagnostics(
6875        conn,
6876        r#"
6877        SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
6878        FROM graph_nodes
6879        GROUP BY id
6880        HAVING COUNT(*) > 1
6881        ORDER BY id
6882        "#,
6883    )?;
6884    diagnostics.extend(sqlite_query_diagnostics(
6885        conn,
6886        r#"
6887        SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
6888        FROM graph_edges
6889        GROUP BY from_id, to_id, kind
6890        HAVING COUNT(*) > 1
6891        ORDER BY from_id, kind, to_id
6892        "#,
6893    )?);
6894    diagnostics.extend(sqlite_query_diagnostics(
6895        conn,
6896        r#"
6897        SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
6898        FROM graph_edges
6899        GROUP BY edge_key
6900        HAVING COUNT(*) > 1
6901        ORDER BY edge_key
6902        "#,
6903    )?);
6904    Ok(diagnostics)
6905}
6906
6907fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6908    sqlite_query_diagnostics(
6909        conn,
6910        r#"
6911        SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6912        FROM graph_edges e
6913        LEFT JOIN graph_nodes n ON n.id = e.from_id
6914        WHERE n.id IS NULL
6915        UNION ALL
6916        SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6917        FROM graph_edges e
6918        LEFT JOIN graph_nodes n ON n.id = e.to_id
6919        WHERE n.id IS NULL
6920        ORDER BY 1
6921        "#,
6922    )
6923}
6924
6925fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6926    let mut diagnostics = Vec::new();
6927    let mut node_stmt = conn.prepare(
6928        "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6929    )?;
6930    let node_rows = node_stmt.query_map([], |row| {
6931        Ok((
6932            row.get::<_, String>(0)?,
6933            row.get::<_, String>(1)?,
6934            row.get::<_, String>(2)?,
6935            row.get::<_, Option<String>>(3)?,
6936        ))
6937    })?;
6938    for row in node_rows {
6939        let (id, properties_json, provenance_json, freshness_json) = row?;
6940        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6941            diagnostics.push(format!(
6942                "graph_nodes {id} properties_json is invalid: {err}"
6943            ));
6944        }
6945        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6946            diagnostics.push(format!(
6947                "graph_nodes {id} provenance_json is invalid: {err}"
6948            ));
6949        }
6950        if let Some(freshness_json) = freshness_json
6951            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6952        {
6953            diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
6954        }
6955    }
6956
6957    let mut edge_stmt = conn.prepare(
6958        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6959    )?;
6960    let edge_rows = edge_stmt.query_map([], |row| {
6961        Ok((
6962            row.get::<_, String>(0)?,
6963            row.get::<_, String>(1)?,
6964            row.get::<_, String>(2)?,
6965            row.get::<_, String>(3)?,
6966            row.get::<_, String>(4)?,
6967            row.get::<_, String>(5)?,
6968            row.get::<_, Option<String>>(6)?,
6969        ))
6970    })?;
6971    for row in edge_rows {
6972        let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
6973            row?;
6974        let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
6975        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6976            diagnostics.push(format!(
6977                "graph_edges {edge} properties_json is invalid: {err}"
6978            ));
6979        }
6980        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6981            diagnostics.push(format!(
6982                "graph_edges {edge} provenance_json is invalid: {err}"
6983            ));
6984        }
6985        if let Some(freshness_json) = freshness_json
6986            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6987        {
6988            diagnostics.push(format!(
6989                "graph_edges {edge} freshness_json is invalid: {err}"
6990            ));
6991        }
6992    }
6993    Ok(diagnostics)
6994}
6995
6996fn sqlite_graph_projection_metadata_diagnostics(
6997    conn: &Connection,
6998    scope: Option<&str>,
6999) -> Result<Vec<String>> {
7000    let mut diagnostics = Vec::new();
7001    let scope_key = scope.unwrap_or("root");
7002    let version = conn
7003        .query_row(
7004            r#"
7005            SELECT projection_version, content_hash, source_watermark
7006            FROM graph_projection_versions
7007            WHERE scope = ?1
7008            "#,
7009            [scope_key],
7010            |row| {
7011                Ok((
7012                    row.get::<_, String>(0)?,
7013                    row.get::<_, Option<String>>(1)?,
7014                    row.get::<_, Option<String>>(2)?,
7015                ))
7016            },
7017        )
7018        .optional()?;
7019    let Some((projection_version, content_hash, _source_watermark)) = version else {
7020        diagnostics.push(format!(
7021            "graph projection metadata is missing for scope {scope_key}"
7022        ));
7023        return Ok(diagnostics);
7024    };
7025    if projection_version != GRAPH_PROJECTION_VERSION {
7026        diagnostics.push(format!(
7027            "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
7028        ));
7029    }
7030    if content_hash.is_none() {
7031        diagnostics.push("projection content hash is missing".to_string());
7032    }
7033
7034    let meta_id = graph_projection_meta_id(scope);
7035    let meta_properties = conn
7036        .query_row(
7037            "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
7038            (&meta_id, GRAPH_PROJECTION_META_KIND),
7039            |row| row.get::<_, String>(0),
7040        )
7041        .optional()?;
7042    let Some(meta_properties) = meta_properties else {
7043        diagnostics.push(format!("projection_meta node {meta_id} is missing"));
7044        return Ok(diagnostics);
7045    };
7046    let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
7047        .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
7048    if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
7049        diagnostics.push(format!(
7050            "projection_meta node {meta_id} has stale projection_version"
7051        ));
7052    }
7053    if properties.get("content_hash") != content_hash.as_ref() {
7054        diagnostics.push(format!(
7055            "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
7056        ));
7057    }
7058    Ok(diagnostics)
7059}
7060
7061pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
7062    let mut node_stmt = conn.prepare(
7063        "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7064    )?;
7065    let node_rows = node_stmt.query_map([], |row| {
7066        let properties_json: String = row.get(3)?;
7067        let provenance_json: String = row.get(4)?;
7068        let freshness_json: Option<String> = row.get(5)?;
7069        Ok((
7070            row.get::<_, String>(0)?,
7071            row.get::<_, String>(1)?,
7072            row.get::<_, String>(2)?,
7073            properties_json,
7074            provenance_json,
7075            freshness_json,
7076        ))
7077    })?;
7078    let mut nodes = Vec::new();
7079    for row in node_rows {
7080        let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
7081        nodes.push(ConvexNodeRow {
7082            external_id,
7083            kind,
7084            label,
7085            properties: serde_json::from_str(&properties_json)?,
7086            provenance: serde_json::from_str(&provenance_json)?,
7087            freshness: freshness_json
7088                .map(|value| serde_json::from_str(&value))
7089                .transpose()?,
7090        });
7091    }
7092
7093    let mut edge_stmt = conn.prepare(
7094        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7095    )?;
7096    let edge_rows = edge_stmt.query_map([], |row| {
7097        let properties_json: String = row.get(4)?;
7098        let provenance_json: String = row.get(5)?;
7099        let freshness_json: Option<String> = row.get(6)?;
7100        Ok((
7101            row.get::<_, String>(0)?,
7102            row.get::<_, String>(1)?,
7103            row.get::<_, String>(2)?,
7104            row.get::<_, String>(3)?,
7105            properties_json,
7106            provenance_json,
7107            freshness_json,
7108        ))
7109    })?;
7110    let mut edges = Vec::new();
7111    for row in edge_rows {
7112        let (
7113            edge_key,
7114            from_external_id,
7115            to_external_id,
7116            kind,
7117            properties_json,
7118            provenance_json,
7119            freshness_json,
7120        ) = row?;
7121        edges.push(ConvexEdgeRow {
7122            edge_key,
7123            from_external_id,
7124            to_external_id,
7125            kind,
7126            properties: serde_json::from_str(&properties_json)?,
7127            provenance: serde_json::from_str(&provenance_json)?,
7128            freshness: freshness_json
7129                .map(|value| serde_json::from_str(&value))
7130                .transpose()?,
7131        });
7132    }
7133    Ok(ConvexProjectionRows { nodes, edges })
7134}
7135
7136fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
7137    format!("{}.{}({})", index.table, index.name, index.fields.join(","))
7138}
7139
7140fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
7141    value
7142        .get("indexes")
7143        .or_else(|| value.get("requiredIndexes"))
7144        .or_else(|| {
7145            value
7146                .get("metadata")
7147                .and_then(|metadata| metadata.get("indexes"))
7148        })
7149}
7150
7151fn convex_snapshot_declared_indexes(
7152    value: &serde_json::Value,
7153) -> Result<Option<Vec<ConvexRequiredIndex>>> {
7154    convex_snapshot_index_value(value)
7155        .map(|indexes| {
7156            serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
7157                .context("parsing Convex snapshot index metadata")
7158        })
7159        .transpose()
7160}
7161
7162fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
7163    let required = convex_required_indexes();
7164    let Some(declared) = convex_snapshot_declared_indexes(value)? else {
7165        return Ok(vec![format!(
7166            "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
7167            required
7168                .iter()
7169                .map(convex_required_index_label)
7170                .collect::<Vec<_>>()
7171                .join(", ")
7172        )]);
7173    };
7174    let declared = declared.into_iter().collect::<BTreeSet<_>>();
7175    let missing = required
7176        .iter()
7177        .filter(|index| !declared.contains(*index))
7178        .map(convex_required_index_label)
7179        .collect::<Vec<_>>();
7180    if missing.is_empty() {
7181        Ok(Vec::new())
7182    } else {
7183        Ok(vec![format!(
7184            "Convex snapshot is missing required index metadata: {}",
7185            missing.join(", ")
7186        )])
7187    }
7188}
7189
7190pub(crate) fn load_convex_projection_snapshot_value(
7191    snapshot_path: &Path,
7192) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7193    let content = fs::read_to_string(snapshot_path).with_context(|| {
7194        format!(
7195            "reading Convex projection snapshot {}",
7196            snapshot_path.display()
7197        )
7198    })?;
7199    let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7200        format!(
7201            "parsing Convex projection snapshot {}",
7202            snapshot_path.display()
7203        )
7204    })?;
7205    let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7206        .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7207    Ok((rows, value))
7208}
7209
7210pub(crate) fn append_sqlite_graph_doctor_checks(
7211    report: &mut GraphDbDoctorReport,
7212    root: &Path,
7213    scope: Option<&str>,
7214    graph_db: &Path,
7215) -> Option<substrate::SqliteReadOnlyConnection> {
7216    let rebuild = graph_db_rebuild_command(root, scope);
7217    let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7218    if !graph_db.exists() {
7219        report.push_check(graph_db_doctor_check(
7220            "sqlite_graph_db_exists",
7221            vec![format!("graph.db is missing at {}", graph_db.display())],
7222            vec![rebuild],
7223        ));
7224        return None;
7225    }
7226    report.push_check(graph_db_doctor_check(
7227        "sqlite_graph_db_exists",
7228        Vec::new(),
7229        vec![rebuild.clone()],
7230    ));
7231
7232    let conn = match open_sqlite_graph_db_readonly(graph_db) {
7233        Ok(conn) => conn,
7234        Err(err) => {
7235            report.push_check(graph_db_doctor_check(
7236                "sqlite_graph_db_open",
7237                vec![err.to_string()],
7238                vec![backup_rebuild],
7239            ));
7240            return None;
7241        }
7242    };
7243    report.push_check(graph_db_doctor_check(
7244        "sqlite_graph_db_open",
7245        Vec::new(),
7246        vec![rebuild.clone()],
7247    ));
7248    if let Some(recovery) = conn.recovery() {
7249        report.push_check(GraphDbDoctorCheck {
7250            name: "sqlite_graph_db_read_recovery".to_string(),
7251            status: "recovered".to_string(),
7252            fail_closed: false,
7253            diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7254            repair_commands: Vec::new(),
7255        });
7256    }
7257
7258    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7259        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7260    report.push_check(graph_db_doctor_check(
7261        "sqlite_schema",
7262        schema_diagnostics,
7263        vec![backup_rebuild.clone()],
7264    ));
7265
7266    let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7267        .unwrap_or_else(|err| {
7268            vec![format!(
7269                "graph projection metadata inspection failed: {err}"
7270            )]
7271        });
7272    report.push_check(graph_db_doctor_check(
7273        "sqlite_projection_metadata",
7274        metadata_diagnostics,
7275        vec![rebuild.clone()],
7276    ));
7277
7278    let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7279        .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7280    report.push_check(graph_db_doctor_check(
7281        "sqlite_duplicate_ids",
7282        duplicate_diagnostics,
7283        vec![backup_rebuild.clone()],
7284    ));
7285
7286    let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7287        .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7288    report.push_check(graph_db_doctor_check(
7289        "sqlite_orphan_edges",
7290        orphan_diagnostics,
7291        vec![rebuild.clone()],
7292    ));
7293
7294    let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7295        .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7296    report.push_check(graph_db_doctor_check(
7297        "sqlite_row_json",
7298        json_diagnostics,
7299        vec![backup_rebuild],
7300    ));
7301
7302    let tombstone_diagnostics =
7303        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7304            .unwrap_or_else(|err| {
7305                vec![format!(
7306                    "graph tombstone retention inspection failed: {err}"
7307                )]
7308            });
7309    report.push_check(GraphDbDoctorCheck {
7310        name: "sqlite_tombstone_retention".to_string(),
7311        status: if tombstone_diagnostics.is_empty() {
7312            "ok".to_string()
7313        } else {
7314            "warning".to_string()
7315        },
7316        fail_closed: false,
7317        diagnostics: tombstone_diagnostics,
7318        repair_commands: Vec::new(),
7319    });
7320    let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7321        Ok(counts) => {
7322            let policy = graph_db_compaction_policy(root, scope, &counts, false);
7323            GraphDbDoctorCheck {
7324                name: "sqlite_compaction_policy".to_string(),
7325                status: policy.status.clone(),
7326                fail_closed: false,
7327                diagnostics: policy.proof,
7328                repair_commands: if policy.status == "recommended" {
7329                    policy.recommendations
7330                } else {
7331                    Vec::new()
7332                },
7333            }
7334        }
7335        Err(err) => GraphDbDoctorCheck {
7336            name: "sqlite_compaction_policy".to_string(),
7337            status: "warning".to_string(),
7338            fail_closed: false,
7339            diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7340            repair_commands: Vec::new(),
7341        },
7342    };
7343    report.push_check(compaction_check);
7344
7345    Some(conn)
7346}
7347
7348pub(crate) fn append_convex_snapshot_doctor_checks(
7349    report: &mut GraphDbDoctorReport,
7350    root: &Path,
7351    scope: Option<&str>,
7352    local_rows: Option<&ConvexProjectionRows>,
7353    snapshot_path: Option<&Path>,
7354) {
7355    let repair = convex_refresh_command(root, scope);
7356    let Some(snapshot_path) = snapshot_path else {
7357        report.push_check(graph_db_doctor_check(
7358            "convex_snapshot_present",
7359            vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7360            vec![format!(
7361                "tsift convex-sync {}{} --json > convex-rows.json",
7362                shell_quote(root.to_string_lossy().as_ref()),
7363                graph_db_scope_arg(scope)
7364            )],
7365        ));
7366        return;
7367    };
7368    report.push_check(graph_db_doctor_check(
7369        "convex_snapshot_present",
7370        Vec::new(),
7371        vec![repair.clone()],
7372    ));
7373
7374    let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7375        Ok(snapshot) => snapshot,
7376        Err(err) => {
7377            report.push_check(graph_db_doctor_check(
7378                "convex_snapshot_parse",
7379                vec![err.to_string()],
7380                vec![repair],
7381            ));
7382            return;
7383        }
7384    };
7385    report.push_check(graph_db_doctor_check(
7386        "convex_snapshot_parse",
7387        Vec::new(),
7388        vec![repair.clone()],
7389    ));
7390
7391    let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7392    report.push_check(graph_db_doctor_check(
7393        "convex_snapshot_rows",
7394        row_diagnostics,
7395        vec![repair.clone()],
7396    ));
7397
7398    let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7399        .unwrap_or_else(|err| vec![err.to_string()]);
7400    report.required_indexes = convex_required_indexes();
7401    report.push_check(graph_db_doctor_check(
7402        "convex_required_indexes",
7403        index_diagnostics,
7404        vec![
7405            "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7406                .to_string(),
7407        ],
7408    ));
7409
7410    if let Some(local_rows) = local_rows {
7411        let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7412        report.push_check(graph_db_doctor_check(
7413            "convex_projection_freshness",
7414            freshness.diagnostics,
7415            vec![repair],
7416        ));
7417    } else {
7418        report.push_check(graph_db_doctor_check(
7419            "convex_projection_freshness",
7420            vec![
7421                "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7422                    .to_string(),
7423            ],
7424            vec![graph_db_rebuild_command(root, scope)],
7425        ));
7426    }
7427}
7428
7429fn graph_db_convex_snapshot_doctor_command(
7430    root: &Path,
7431    scope: Option<&str>,
7432    snapshot_path: &Path,
7433) -> String {
7434    format!(
7435        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7436        shell_quote(root.to_string_lossy().as_ref()),
7437        graph_db_scope_arg(scope),
7438        shell_quote(snapshot_path.to_string_lossy().as_ref())
7439    )
7440}
7441
7442fn graph_db_convex_snapshot_read_command(
7443    root: &Path,
7444    scope: Option<&str>,
7445    snapshot_path: &Path,
7446) -> String {
7447    format!(
7448        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7449        shell_quote(root.to_string_lossy().as_ref()),
7450        graph_db_scope_arg(scope),
7451        shell_quote(snapshot_path.to_string_lossy().as_ref())
7452    )
7453}
7454
7455fn convex_sync_snapshot_diff_command(
7456    root: &Path,
7457    scope: Option<&str>,
7458    snapshot_path: &Path,
7459) -> String {
7460    format!(
7461        "tsift convex-sync {}{} --snapshot {} --json",
7462        shell_quote(root.to_string_lossy().as_ref()),
7463        graph_db_scope_arg(scope),
7464        shell_quote(snapshot_path.to_string_lossy().as_ref())
7465    )
7466}
7467
7468pub(crate) struct GraphDbDriftInput<'a> {
7469    root: &'a Path,
7470    scope: Option<&'a str>,
7471    graph_db: &'a Path,
7472    snapshot_path: &'a Path,
7473    local: &'a ConvexProjectionRows,
7474    snapshot: &'a ConvexProjectionRows,
7475    snapshot_value: &'a serde_json::Value,
7476    warnings: Vec<String>,
7477}
7478
7479pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7480    let GraphDbDriftInput {
7481        root,
7482        scope,
7483        graph_db,
7484        snapshot_path,
7485        local,
7486        snapshot,
7487        snapshot_value,
7488        warnings,
7489    } = input;
7490    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7491    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7492        convex_rows_diff(local, Some(snapshot));
7493    let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7494    let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7495        .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7496    let local_hash = freshness.local_hash.clone();
7497    let snapshot_hash = freshness.snapshot_hash.clone();
7498    let stale_nodes = freshness.stale_nodes.clone();
7499    let stale_edges = freshness.stale_edges.clone();
7500
7501    let duplicate_failures = row_diagnostics
7502        .iter()
7503        .filter(|diagnostic| diagnostic.contains("duplicate"))
7504        .count();
7505    let orphan_failures = row_diagnostics
7506        .iter()
7507        .filter(|diagnostic| diagnostic.contains("references missing"))
7508        .count();
7509    let missing_required_indexes = index_diagnostics.len();
7510    let stale_projection_metadata =
7511        usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7512    let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7513    let has_drift = freshness.fail_closed
7514        || !node_upserts.is_empty()
7515        || !edge_upserts.is_empty()
7516        || !node_tombstones.is_empty()
7517        || !edge_tombstones.is_empty();
7518    let status = if hard_failures > 0 {
7519        "fail_closed"
7520    } else if has_drift {
7521        "drift"
7522    } else {
7523        "current"
7524    }
7525    .to_string();
7526
7527    let mut diagnostics = Vec::new();
7528    diagnostics.extend(row_diagnostics);
7529    diagnostics.extend(index_diagnostics);
7530    diagnostics.extend(freshness.diagnostics.clone());
7531    if has_drift {
7532        diagnostics.push(format!(
7533            "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
7534            node_upserts.len(),
7535            edge_upserts.len(),
7536            node_tombstones.len(),
7537            edge_tombstones.len()
7538        ));
7539    }
7540
7541    let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
7542        root,
7543        scope,
7544        snapshot_path,
7545    )];
7546    if status == "current" {
7547        next_commands.push(graph_db_convex_snapshot_read_command(
7548            root,
7549            scope,
7550            snapshot_path,
7551        ));
7552    } else {
7553        next_commands.push(convex_sync_snapshot_diff_command(
7554            root,
7555            scope,
7556            snapshot_path,
7557        ));
7558        next_commands.push(convex_refresh_command(root, scope));
7559    }
7560
7561    GraphDbDriftReport {
7562        root: root.to_string_lossy().to_string(),
7563        scope: scope.map(str::to_string),
7564        graph_db: graph_db.to_string_lossy().to_string(),
7565        convex_snapshot: snapshot_path.to_string_lossy().to_string(),
7566        status: status.clone(),
7567        graph_reads_allowed: status == "current",
7568        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
7569        local_hash,
7570        snapshot_hash,
7571        summary: GraphDbDriftSummary {
7572            node_upserts: node_upserts.len(),
7573            edge_upserts: edge_upserts.len(),
7574            node_tombstones: node_tombstones.len(),
7575            edge_tombstones: edge_tombstones.len(),
7576            stale_nodes: stale_nodes.len(),
7577            stale_edges: stale_edges.len(),
7578            stale_projection_metadata,
7579            duplicate_failures,
7580            orphan_failures,
7581            missing_required_indexes,
7582        },
7583        node_upserts: node_upserts
7584            .into_iter()
7585            .map(|row| row.external_id)
7586            .collect(),
7587        edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
7588        node_tombstones,
7589        edge_tombstones,
7590        stale_nodes,
7591        stale_edges,
7592        diagnostics,
7593        next_commands,
7594        required_indexes: convex_required_indexes(),
7595        warnings,
7596    }
7597}
7598
7599pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
7600    println!(
7601        "graph-db drift status: {} reads_allowed: {}",
7602        report.status, report.graph_reads_allowed
7603    );
7604    println!("graph_db: {}", report.graph_db);
7605    println!("convex_snapshot: {}", report.convex_snapshot);
7606    println!(
7607        "upserts: {} node(s), {} edge(s)",
7608        report.summary.node_upserts, report.summary.edge_upserts
7609    );
7610    println!(
7611        "tombstones: {} node(s), {} edge(s)",
7612        report.summary.node_tombstones, report.summary.edge_tombstones
7613    );
7614    for diagnostic in &report.diagnostics {
7615        println!("diagnostic: {diagnostic}");
7616    }
7617    for command in &report.next_commands {
7618        println!("next: {command}");
7619    }
7620}
7621
7622pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
7623    println!(
7624        "graph-db doctor backend: {} status: {}",
7625        report.backend, report.status
7626    );
7627    println!("graph_db: {}", report.graph_db);
7628    if let Some(snapshot) = &report.convex_snapshot {
7629        println!("convex_snapshot: {snapshot}");
7630    }
7631    for check in &report.checks {
7632        println!("check: {} {}", check.name, check.status);
7633        for diagnostic in &check.diagnostics {
7634            println!("  diagnostic: {diagnostic}");
7635        }
7636    }
7637    for command in &report.repair_commands {
7638        println!("repair: {command}");
7639    }
7640}
7641
7642pub(crate) fn graph_db_operator_report_from_disk(
7643    root: &Path,
7644    scope: Option<&str>,
7645    graph_db: &Path,
7646    operation: &str,
7647    refresh: Option<GraphDbRefreshSummary>,
7648    warnings: Vec<String>,
7649) -> Result<GraphDbOperatorReport> {
7650    if !graph_db.exists() {
7651        let next_commands = graph_db_operator_next_commands(root, scope, true);
7652        let counts = GraphDbOperatorCounts {
7653            nodes: 0,
7654            edges: 0,
7655            tombstones: GraphDbTombstoneCounts {
7656                nodes: 0,
7657                edges: 0,
7658                total: 0,
7659            },
7660            file_size_bytes: None,
7661            freelist_bytes: None,
7662        };
7663        return Ok(GraphDbOperatorReport {
7664            root: root.to_string_lossy().to_string(),
7665            scope: scope.map(str::to_string),
7666            graph_db: graph_db.to_string_lossy().to_string(),
7667            operation: operation.to_string(),
7668            status: "missing".to_string(),
7669            materialized: false,
7670            freshness: GraphDbFreshnessReport {
7671                status: "missing".to_string(),
7672                fail_closed: true,
7673                projection_version: None,
7674                content_hash: None,
7675                source_watermark: None,
7676                diagnostics: vec![
7677                    "graph.db is missing; run graph-db refresh before trusting graph reads"
7678                        .to_string(),
7679                ],
7680            },
7681            readiness: graph_effectiveness_blocked(
7682                "graph_db_missing",
7683                vec![
7684                    "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
7685                ],
7686                next_commands.clone(),
7687            ),
7688            counts: counts.clone(),
7689            refresh,
7690            compaction: graph_db_compaction_policy(root, scope, &counts, false),
7691            recovery: None,
7692            next_commands,
7693            warnings,
7694        });
7695    }
7696
7697    let conn = open_sqlite_graph_db_readonly(graph_db)?;
7698    let recovery = conn.recovery();
7699    let mut warnings = warnings;
7700    if let Some(recovery) = recovery {
7701        warnings.push(graph_db_read_recovery_diagnostic(recovery));
7702    }
7703    let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
7704    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7705        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7706    if !schema_diagnostics.is_empty() {
7707        freshness.diagnostics.extend(schema_diagnostics);
7708        freshness.fail_closed = true;
7709        freshness.status = "stale".to_string();
7710    }
7711    let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
7712    let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
7713    warnings.extend(
7714        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7715            .unwrap_or_else(|err| {
7716                vec![format!(
7717                    "graph tombstone retention inspection failed: {err}"
7718                )]
7719            }),
7720    );
7721    let status = if freshness.fail_closed {
7722        "stale"
7723    } else {
7724        "current"
7725    }
7726    .to_string();
7727
7728    Ok(GraphDbOperatorReport {
7729        root: root.to_string_lossy().to_string(),
7730        scope: scope.map(str::to_string),
7731        graph_db: graph_db.to_string_lossy().to_string(),
7732        operation: operation.to_string(),
7733        status,
7734        materialized: true,
7735        freshness,
7736        readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
7737        compaction: graph_db_compaction_policy(root, scope, &counts, false),
7738        counts,
7739        refresh,
7740        recovery,
7741        next_commands: graph_db_operator_next_commands(root, scope, false),
7742        warnings,
7743    })
7744}
7745
7746fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
7747    println!(
7748        "graph-db {} status: {} materialized: {}",
7749        report.operation, report.status, report.materialized
7750    );
7751    println!("graph_db: {}", report.graph_db);
7752    println!(
7753        "projection: version={} hash={} watermark={}",
7754        report
7755            .freshness
7756            .projection_version
7757            .as_deref()
7758            .unwrap_or("<missing>"),
7759        report
7760            .freshness
7761            .content_hash
7762            .as_deref()
7763            .unwrap_or("<missing>"),
7764        report
7765            .freshness
7766            .source_watermark
7767            .as_deref()
7768            .unwrap_or("<missing>")
7769    );
7770    println!(
7771        "rows: {} node(s), {} edge(s), {} tombstone(s)",
7772        report.counts.nodes, report.counts.edges, report.counts.tombstones.total
7773    );
7774    println!(
7775        "readiness: {} reason: {} fail_closed: {}",
7776        report.readiness.status, report.readiness.reason, report.readiness.fail_closed
7777    );
7778    if let Some(file_size) = report.counts.file_size_bytes {
7779        println!(
7780            "storage: {} byte(s), {} free byte(s)",
7781            file_size,
7782            report.counts.freelist_bytes.unwrap_or(0)
7783        );
7784    }
7785    if let Some(refresh) = &report.refresh {
7786        println!(
7787            "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
7788            refresh.tombstoned_nodes, refresh.tombstoned_edges
7789        );
7790        println!(
7791            "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)",
7792            refresh.upserted_nodes,
7793            refresh.upserted_edges,
7794            refresh.upserted_properties,
7795            refresh.unchanged_nodes,
7796            refresh.unchanged_edges,
7797            refresh.unchanged_properties,
7798            refresh.deleted_properties,
7799            refresh.pruned_tombstones
7800        );
7801    }
7802    println!(
7803        "compaction: {} tombstone_scan_rows={} live_rows={}",
7804        report.compaction.status,
7805        report.compaction.tombstone_scan_rows,
7806        report.compaction.live_rows
7807    );
7808    for proof in &report.compaction.proof {
7809        println!("compaction proof: {proof}");
7810    }
7811    if let Some(recovery) = report.recovery {
7812        println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
7813    }
7814    for diagnostic in &report.freshness.diagnostics {
7815        println!("diagnostic: {diagnostic}");
7816    }
7817    for diagnostic in &report.readiness.diagnostics {
7818        println!("readiness diagnostic: {diagnostic}");
7819    }
7820    for warning in &report.warnings {
7821        println!("warning: {warning}");
7822    }
7823    for command in &report.readiness.next_commands {
7824        println!("readiness next: {command}");
7825    }
7826    for command in &report.next_commands {
7827        println!("next: {command}");
7828    }
7829}
7830
7831pub(crate) fn print_graph_db_operator_report(
7832    report: &GraphDbOperatorReport,
7833    format: OutputFormat,
7834) -> Result<()> {
7835    if format.json_output {
7836        print_json_or_envelope(
7837            report,
7838            &format,
7839            "graph-db",
7840            &report.operation,
7841            ToolEnvelopeSummary {
7842                text: format!(
7843                    "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
7844                    report.operation,
7845                    report.status,
7846                    report.counts.nodes,
7847                    report.counts.edges,
7848                    report.counts.tombstones.total
7849                ),
7850                metrics: vec![
7851                    envelope_metric("operation", &report.operation),
7852                    envelope_metric("status", &report.status),
7853                    envelope_metric("nodes", report.counts.nodes),
7854                    envelope_metric("edges", report.counts.edges),
7855                    envelope_metric("tombstones", report.counts.tombstones.total),
7856                    envelope_metric("compaction", &report.compaction.status),
7857                    envelope_metric("readiness", &report.readiness.status),
7858                ],
7859            },
7860            false,
7861            report.next_commands.clone(),
7862        )
7863    } else {
7864        print_graph_db_operator_human(report);
7865        Ok(())
7866    }
7867}
7868
7869fn status_run_command_without_notes(run: &str) -> &str {
7870    run.split_once("  (")
7871        .map(|(command, _)| command)
7872        .unwrap_or(run)
7873}
7874
7875fn status_summarize_extract_command(run: &str) -> &str {
7876    let run = status_run_command_without_notes(run);
7877    run.split(" && ")
7878        .find(|command| command.contains("summarize --extract"))
7879        .unwrap_or(run)
7880}
7881
7882fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
7883    report
7884        .recommendations
7885        .run
7886        .as_deref()
7887        .filter(|command| command.contains("summarize --extract"))
7888        .map(status_summarize_extract_command)
7889        .unwrap_or("tsift summarize --extract .")
7890        .to_string()
7891}
7892
7893fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
7894    let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7895    readiness.diagnostics.push(format!(
7896        "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
7897    ));
7898    readiness
7899}
7900
7901fn graph_db_semantic_readiness(
7902    root: &Path,
7903    scope: Option<&str>,
7904    semantic_row_count: Option<usize>,
7905) -> GraphEffectivenessReadiness {
7906    if let Some(row_count) = semantic_row_count
7907        && row_count > 0
7908    {
7909        return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
7910    }
7911
7912    let report = match status::check_status(root) {
7913        Ok(report) => report,
7914        Err(err) => {
7915            return graph_effectiveness_blocked(
7916                "status_check_unavailable",
7917                vec![format!(
7918                    "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
7919                )],
7920                vec![graph_db_refresh_command(root, scope)],
7921            );
7922        }
7923    };
7924
7925    match &report.summaries {
7926        status::SummaryStatus::Available {
7927            cached_files,
7928            total_indexed_files,
7929            coverage_pct,
7930            ..
7931        } => {
7932            let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7933            readiness.diagnostics.push(format!(
7934                "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
7935            ));
7936            readiness
7937        }
7938        status::SummaryStatus::None { .. } => {
7939            let summarize = graph_db_status_summarize_command(&report);
7940            let index_command = report
7941                .recommendations
7942                .run
7943                .as_deref()
7944                .filter(|cmd| cmd.contains("index"))
7945                .map(str::to_string);
7946            let mut repair = Vec::new();
7947            if let Some(cmd) = index_command {
7948                repair.push(cmd);
7949            }
7950            repair.push(summarize.clone());
7951            repair.push(graph_db_refresh_command(root, scope));
7952            graph_effectiveness_blocked(
7953                "summary_cache_empty",
7954                vec![format!(
7955                    "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7956                    summarize,
7957                    root.display(),
7958                    graph_db_refresh_command(root, scope)
7959                )],
7960                repair,
7961            )
7962        }
7963        status::SummaryStatus::Unavailable => {
7964            let mut repair: Vec<String> = report
7965                .recommendations
7966                .run
7967                .clone()
7968                .into_iter()
7969                .collect();
7970            let summarize = "tsift summarize --extract .".to_string();
7971            repair.push(summarize);
7972            repair.push(graph_db_refresh_command(root, scope));
7973            graph_effectiveness_blocked(
7974                "summary_cache_unavailable",
7975                vec![
7976                    "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(),
7977                ],
7978                repair,
7979            )
7980        }
7981    }
7982}
7983
7984pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
7985    let report = match status::check_status(root) {
7986        Ok(report) => report,
7987        Err(err) => {
7988            return vec![format!(
7989                "status check unavailable after graph-db refresh: {err:#}"
7990            )];
7991        }
7992    };
7993
7994    let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7995        Some(graph_db_status_summarize_command(&report))
7996    } else {
7997        None
7998    };
7999    let mut warnings = report.reminders;
8000    if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8001        let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
8002        warnings.push(format!(
8003            "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8004            run,
8005            root.display(),
8006            graph_db_refresh_command(root, scope)
8007        ));
8008    }
8009    dedupe_preserve_order(warnings)
8010}
8011
8012pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
8013    println!(
8014        "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
8015        report.applied, report.pruned_tombstones, report.reclaimed_bytes
8016    );
8017    println!("graph_db: {}", report.graph_db);
8018    println!(
8019        "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8020        report.counts_before.nodes,
8021        report.counts_before.edges,
8022        report.counts_before.tombstones.total,
8023        report.counts_before.file_size_bytes.unwrap_or(0),
8024        report.counts_before.freelist_bytes.unwrap_or(0)
8025    );
8026    println!(
8027        "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8028        report.counts_after.nodes,
8029        report.counts_after.edges,
8030        report.counts_after.tombstones.total,
8031        report.counts_after.file_size_bytes.unwrap_or(0),
8032        report.counts_after.freelist_bytes.unwrap_or(0)
8033    );
8034    for proof in &report.compaction_after.proof {
8035        println!("proof: {proof}");
8036    }
8037    for warning in &report.warnings {
8038        println!("warning: {warning}");
8039    }
8040    for command in &report.next_commands {
8041        println!("next: {command}");
8042    }
8043}
8044
8045fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
8046    raw.iter()
8047        .map(|value| {
8048            let (key, filter_value) = value
8049                .split_once('=')
8050                .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
8051            let key = key.trim();
8052            let filter_value = filter_value.trim();
8053            if key.is_empty() || filter_value.is_empty() {
8054                bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
8055            }
8056            Ok(GraphDbPropertyFilter {
8057                key: key.to_string(),
8058                value: filter_value.to_string(),
8059            })
8060        })
8061        .collect()
8062}
8063
8064fn graph_db_query_options(
8065    cursor: Option<String>,
8066    limit: Option<usize>,
8067    property_filters: &[String],
8068) -> Result<GraphDbQueryOptions> {
8069    Ok(GraphDbQueryOptions {
8070        cursor,
8071        limit: limit.filter(|limit| *limit > 0),
8072        property_filters: parse_graph_db_property_filters(property_filters)?,
8073    })
8074}
8075
8076fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
8077    GraphQueryOptions {
8078        cursor: options.cursor.clone(),
8079        limit: options.limit,
8080        property_filters: options
8081            .property_filters
8082            .iter()
8083            .map(|filter| GraphPropertyFilter {
8084                key: filter.key.clone(),
8085                value: filter.value.clone(),
8086            })
8087            .collect(),
8088    }
8089}
8090
8091fn graph_db_page_report_from_store(
8092    page: GraphQueryPage,
8093    property_filters: Vec<GraphDbPropertyFilter>,
8094) -> GraphDbPageReport {
8095    GraphDbPageReport {
8096        cursor: page.cursor,
8097        limit: page.limit,
8098        next_cursor: page.next_cursor,
8099        returned_nodes: page.returned_nodes,
8100        returned_edges: page.returned_edges,
8101        truncated: page.truncated,
8102        property_filters,
8103        diagnostics: page.diagnostics,
8104    }
8105}
8106
8107fn graph_db_neighborhood_ranking_gate(
8108    ranked_neighbor_cap: usize,
8109) -> GraphDbNeighborhoodRankingGate {
8110    GraphDbNeighborhoodRankingGate {
8111        status: "held_default_order_unchanged".to_string(),
8112        ranked_output_default: false,
8113        default_order: "stable_node_id".to_string(),
8114        default_change_gate: "community_search_quality_metrics".to_string(),
8115        required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
8116            .iter()
8117            .map(|workload| (*workload).to_string())
8118            .collect(),
8119        required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
8120            .iter()
8121            .map(|metric| (*metric).to_string())
8122            .collect(),
8123        max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
8124        min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
8125        min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
8126        min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
8127        diagnostics: vec![
8128            "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
8129            format!(
8130                "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
8131            ),
8132            "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
8133        ],
8134    }
8135}
8136
8137fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
8138    match limit {
8139        Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
8140        Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
8141    }
8142}
8143
8144fn graph_db_ranked_neighbors(
8145    center_id: &str,
8146    nodes: &[SubstrateGraphNode],
8147    edges: &[SubstrateGraphEdge],
8148    cap: usize,
8149) -> Vec<GraphDbRankedNeighbor> {
8150    resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
8151}
8152
8153fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
8154    center_id: &str,
8155    depth: usize,
8156    edge_kind: Option<&str>,
8157    limit: Option<usize>,
8158    unranked_nodes: &[SubstrateGraphNode],
8159    unranked_edges: &[SubstrateGraphEdge],
8160    store: &S,
8161) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
8162    use std::time::Instant;
8163    let max_nodes = match limit {
8164        Some(0) | None => 200,
8165        Some(n) => n.clamp(10, 500),
8166    };
8167    let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
8168        .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
8169    if let Some(kind) = edge_kind {
8170        options = options.with_edge_kind(kind);
8171    }
8172    let start = Instant::now();
8173    let result = store.ranked_neighborhood(center_id, &options)?;
8174    let latency = start.elapsed().as_micros();
8175    let Some(ranked) = result else {
8176        return Ok(None);
8177    };
8178    let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
8179    let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
8180    let overlap_count = ranked_ids.intersection(&unranked_ids).count();
8181    let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
8182        0.0
8183    } else {
8184        (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8185    };
8186    let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8187        let mut name_count = BTreeMap::<&str, usize>::new();
8188        for n in nodes {
8189            *name_count.entry(&n.label).or_default() += 1;
8190        }
8191        name_count.values().filter(|&&c| c > 1).count()
8192    };
8193    let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8194        if nodes.is_empty() {
8195            return 100.0;
8196        }
8197        let with_handle = nodes
8198            .iter()
8199            .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8200            .count();
8201        (with_handle as f64 / nodes.len() as f64) * 100.0
8202    };
8203    let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8204        if nodes.is_empty() {
8205            return 0.0;
8206        }
8207        let semantic_kinds = [
8208            "semantic_concept",
8209            "semantic_entity",
8210            "symbol",
8211            "file",
8212            "source_handle",
8213        ];
8214        let useful = nodes
8215            .iter()
8216            .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8217            .count();
8218        let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8219        let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8220        (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8221            / nodes.len() as f64
8222    };
8223    let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8224        let edge_pairs: Vec<(String, String)> = ranked
8225            .edges
8226            .iter()
8227            .map(|e| (e.from_id.clone(), e.to_id.clone()))
8228            .collect();
8229        let cr = tsift_graph::detect_communities(&edge_pairs);
8230        let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8231        let mut fully_kept = 0usize;
8232        let mut partially_pruned = 0usize;
8233        let mut fully_pruned = 0usize;
8234        let mut pruned_kinds = BTreeSet::new();
8235        let mut pruned_labels = Vec::new();
8236        for comm in &cr.communities {
8237            let kept_in_comm: Vec<&str> = comm
8238                .members
8239                .iter()
8240                .filter(|m| kept_labels.contains(m.name.as_str()))
8241                .map(|m| m.name.as_str())
8242                .collect();
8243            if kept_in_comm.len() == comm.members.len() {
8244                fully_kept += 1;
8245            } else if kept_in_comm.is_empty() {
8246                fully_pruned += 1;
8247                for m in &comm.members {
8248                    if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8249                        pruned_kinds.insert(n.kind.clone());
8250                    }
8251                    pruned_labels.push(m.name.clone());
8252                }
8253            } else {
8254                partially_pruned += 1;
8255            }
8256        }
8257        pruned_labels.truncate(5);
8258        Some(CommunityTruncationSummary {
8259            total_communities: cr.communities.len(),
8260            fully_kept,
8261            partially_pruned,
8262            fully_pruned,
8263            pruned_community_kinds: pruned_kinds.into_iter().collect(),
8264            pruned_community_top_labels: pruned_labels,
8265        })
8266    } else {
8267        None
8268    };
8269    Ok(Some(GraphDbRankedNeighborhoodComparison {
8270        traversal_nodes: ranked.nodes.len(),
8271        traversal_edges: ranked.edges.len(),
8272        pruned_count: ranked.pruned_count,
8273        total_discovered: ranked.total_discovered,
8274        latency_micros: latency,
8275        overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8276        useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8277            / 1000.0,
8278        useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8279            .round()
8280            / 1000.0,
8281        duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8282        duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8283        handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8284        handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8285            / 100.0,
8286        community_truncation_summary,
8287        diagnostics: vec![
8288            format!(
8289                "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8290                ranked.nodes.len(),
8291                ranked.edges.len(),
8292                ranked.pruned_count,
8293                ranked.total_discovered,
8294                latency
8295            ),
8296            format!(
8297                "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8298                overlap_pct,
8299                overlap_count,
8300                unranked_ids.len(),
8301                ranked_ids.len()
8302            ),
8303            "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8304        ],
8305    }))
8306}
8307
8308struct GraphDbBudgetedSubgraph {
8309    nodes: Vec<SubstrateGraphNode>,
8310    edges: Vec<SubstrateGraphEdge>,
8311    report: GraphDbOutputBudgetReport,
8312    truncated: bool,
8313    next_cursor: Option<String>,
8314}
8315
8316const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8317const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8318const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8319
8320fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8321    match limit {
8322        Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8323        Some(limit) => limit
8324            .saturating_mul(320)
8325            .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8326    }
8327}
8328
8329fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8330    if matches!(limit, Some(0) | None) {
8331        return match kind {
8332            "source_handle" => 10,
8333            "worker_context" | "worker_result" => 8,
8334            "semantic_concept" | "semantic_entity" => 10,
8335            "file" | "symbol" | "route" => 12,
8336            _ => 8,
8337        };
8338    }
8339    let base = limit.unwrap_or(0).max(1);
8340    match kind {
8341        "source_handle" => base.saturating_add(4),
8342        "worker_context" | "worker_result" => base.saturating_add(2),
8343        "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8344        "file" | "symbol" | "route" => base.saturating_add(4),
8345        _ => base.saturating_add(1),
8346    }
8347}
8348
8349fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8350    if matches!(limit, Some(0) | None) {
8351        return match kind {
8352            "mentions" | "mentions_concept" | "mentions_entity" => 24,
8353            "semantic_relation" | "calls" | "defines" => 20,
8354            _ => 16,
8355        };
8356    }
8357    let base = limit.unwrap_or(0).max(1);
8358    match kind {
8359        "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8360        "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8361        _ => base.saturating_add(2),
8362    }
8363}
8364
8365fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8366    serde_json::to_vec(value)
8367        .map(|bytes| bytes.len().div_ceil(4).max(1))
8368        .unwrap_or(1)
8369}
8370
8371fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8372    let mut parts = vec![node.kind.clone(), node.label.clone()];
8373    for key in [
8374        "detail",
8375        "description",
8376        "source_ref",
8377        "path",
8378        "source_file",
8379        "source_symbol",
8380        "text_preview",
8381    ] {
8382        if let Some(value) = node.properties.get(key) {
8383            parts.push(value.clone());
8384        }
8385    }
8386    parts.join(" ")
8387}
8388
8389fn graph_db_semantic_scores_for_query(
8390    query: Option<&str>,
8391    nodes: &[SubstrateGraphNode],
8392) -> BTreeMap<String, f64> {
8393    let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8394        return BTreeMap::new();
8395    };
8396    let query_embedding = semantic_embedding(query);
8397    nodes
8398        .iter()
8399        .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8400        .filter_map(|node| {
8401            let embedding = node
8402                .properties
8403                .get("embedding")
8404                .and_then(|value| parse_semantic_embedding_property(value))?;
8405            Some((
8406                node.id.clone(),
8407                semantic_cosine(&query_embedding, &embedding),
8408            ))
8409        })
8410        .collect()
8411}
8412
8413fn graph_db_depth_by_id(
8414    origin_ids: &[String],
8415    edges: &[SubstrateGraphEdge],
8416) -> BTreeMap<String, usize> {
8417    let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8418    for edge in edges {
8419        adjacency
8420            .entry(edge.from_id.clone())
8421            .or_default()
8422            .push(edge.to_id.clone());
8423        adjacency
8424            .entry(edge.to_id.clone())
8425            .or_default()
8426            .push(edge.from_id.clone());
8427    }
8428
8429    let mut depth_by_id = BTreeMap::<String, usize>::new();
8430    let mut queue = VecDeque::<String>::new();
8431    for origin in origin_ids {
8432        if depth_by_id.insert(origin.clone(), 0).is_none() {
8433            queue.push_back(origin.clone());
8434        }
8435    }
8436    while let Some(current) = queue.pop_front() {
8437        let depth = depth_by_id.get(&current).copied().unwrap_or(0);
8438        for next in adjacency.get(&current).into_iter().flatten() {
8439            if depth_by_id.contains_key(next) {
8440                continue;
8441            }
8442            depth_by_id.insert(next.clone(), depth.saturating_add(1));
8443            queue.push_back(next.clone());
8444        }
8445    }
8446    depth_by_id
8447}
8448
8449fn graph_db_source_covered_ids(
8450    nodes: &[SubstrateGraphNode],
8451    edges: &[SubstrateGraphEdge],
8452) -> BTreeSet<String> {
8453    let source_ids = nodes
8454        .iter()
8455        .filter(|node| node.kind == "source_handle")
8456        .map(|node| node.id.as_str())
8457        .collect::<BTreeSet<_>>();
8458    let mut covered = source_ids
8459        .iter()
8460        .map(|id| (*id).to_string())
8461        .collect::<BTreeSet<_>>();
8462    for edge in edges {
8463        if source_ids.contains(edge.from_id.as_str()) {
8464            covered.insert(edge.to_id.clone());
8465        }
8466        if source_ids.contains(edge.to_id.as_str()) {
8467            covered.insert(edge.from_id.clone());
8468        }
8469    }
8470    covered
8471}
8472
8473fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8474    for key in [
8475        "observed_at_unix",
8476        "completed_at_unix",
8477        "created_at_unix",
8478        "started_at_unix",
8479    ] {
8480        if let Some(value) = node.properties.get(key)
8481            && let Ok(epoch) = value.parse::<i64>()
8482        {
8483            return epoch.div_euclid(86_400).clamp(0, 40_000);
8484        }
8485    }
8486    0
8487}
8488
8489fn graph_db_node_kind_score(kind: &str) -> i64 {
8490    match kind {
8491        "source_handle" => 180,
8492        "worker_context" => 170,
8493        "worker_result" => 160,
8494        "semantic_concept" | "semantic_entity" => 150,
8495        "backlog" | "job_packet" => 130,
8496        "symbol" => 120,
8497        "file" => 110,
8498        "route" => 105,
8499        "session" => 90,
8500        _ => 40,
8501    }
8502}
8503
8504fn graph_db_edge_kind_score(kind: &str) -> i64 {
8505    match kind {
8506        "mentions_concept" | "mentions_entity" => 180,
8507        "semantic_relation" => 170,
8508        "mentions" => 165,
8509        "requests_context" | "scopes_context" | "scopes_source" => 155,
8510        "explains_result" => 150,
8511        "calls" => 145,
8512        "defines" | "handled_by" | "defines_route" => 130,
8513        "contains" | "targets" => 120,
8514        "records_memory_source" | "has_vector_handle" => 115,
8515        _ => 40,
8516    }
8517}
8518
8519fn graph_db_node_usefulness_score(
8520    node: &SubstrateGraphNode,
8521    depth_by_id: &BTreeMap<String, usize>,
8522    semantic_scores: &BTreeMap<String, f64>,
8523    source_covered_ids: &BTreeSet<String>,
8524    origin_ids: &[String],
8525) -> i64 {
8526    if origin_ids.iter().any(|origin| origin == &node.id) {
8527        return 1_000_000;
8528    }
8529    let semantic = semantic_scores
8530        .get(&node.id)
8531        .map(|score| (score.max(0.0) * 1_000.0) as i64)
8532        .unwrap_or(0);
8533    let depth_penalty = depth_by_id
8534        .get(&node.id)
8535        .map(|depth| (*depth as i64).saturating_mul(55))
8536        .unwrap_or(180);
8537    let source_coverage = if source_covered_ids.contains(&node.id)
8538        || node.properties.contains_key("source_ref")
8539        || node.properties.contains_key("path")
8540    {
8541        120
8542    } else {
8543        0
8544    };
8545    graph_db_node_kind_score(&node.kind)
8546        + semantic
8547        + source_coverage
8548        + graph_db_recency_score(node).min(80)
8549        - depth_penalty
8550}
8551
8552fn graph_db_edge_usefulness_score(
8553    edge: &SubstrateGraphEdge,
8554    node_score_by_id: &BTreeMap<String, i64>,
8555    depth_by_id: &BTreeMap<String, usize>,
8556) -> i64 {
8557    let endpoint_score = node_score_by_id
8558        .get(&edge.from_id)
8559        .copied()
8560        .unwrap_or_default()
8561        .max(
8562            node_score_by_id
8563                .get(&edge.to_id)
8564                .copied()
8565                .unwrap_or_default(),
8566        );
8567    let depth_penalty = depth_by_id
8568        .get(&edge.from_id)
8569        .into_iter()
8570        .chain(depth_by_id.get(&edge.to_id))
8571        .min()
8572        .map(|depth| (*depth as i64).saturating_mul(35))
8573        .unwrap_or(140);
8574    graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
8575}
8576
8577fn graph_db_push_drop(
8578    drops: &mut BTreeMap<(String, String, String), usize>,
8579    item: &str,
8580    kind: &str,
8581    reason: &str,
8582) {
8583    *drops
8584        .entry((item.to_string(), kind.to_string(), reason.to_string()))
8585        .or_default() += 1;
8586}
8587
8588fn graph_db_budget_drop_report(
8589    drops: BTreeMap<(String, String, String), usize>,
8590) -> Vec<GraphDbDroppedByBudget> {
8591    drops
8592        .into_iter()
8593        .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
8594            item,
8595            kind,
8596            reason,
8597            dropped,
8598        })
8599        .collect()
8600}
8601
8602fn graph_db_apply_output_budget(
8603    origin_ids: &[String],
8604    semantic_scores: &BTreeMap<String, f64>,
8605    nodes: Vec<SubstrateGraphNode>,
8606    edges: Vec<SubstrateGraphEdge>,
8607    limit: Option<usize>,
8608) -> GraphDbBudgetedSubgraph {
8609    graph_db_apply_output_budget_with_depths_and_cursor(
8610        origin_ids,
8611        semantic_scores,
8612        nodes,
8613        edges,
8614        limit,
8615        None,
8616        None,
8617    )
8618}
8619
8620fn graph_db_apply_output_budget_with_depths_and_cursor(
8621    origin_ids: &[String],
8622    semantic_scores: &BTreeMap<String, f64>,
8623    nodes: Vec<SubstrateGraphNode>,
8624    edges: Vec<SubstrateGraphEdge>,
8625    limit: Option<usize>,
8626    depth_overrides: Option<&BTreeMap<String, usize>>,
8627    cursor: Option<&str>,
8628) -> GraphDbBudgetedSubgraph {
8629    let max_tokens = graph_db_output_token_cap(limit);
8630    let candidate_nodes = nodes.len();
8631    let candidate_edges = edges.len();
8632    let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
8633    if let Some(depth_overrides) = depth_overrides {
8634        for (id, depth) in depth_overrides {
8635            depth_by_id
8636                .entry(id.clone())
8637                .and_modify(|current| *current = (*current).min(*depth))
8638                .or_insert(*depth);
8639        }
8640    }
8641    let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
8642    let node_score_by_id = nodes
8643        .iter()
8644        .map(|node| {
8645            (
8646                node.id.clone(),
8647                graph_db_node_usefulness_score(
8648                    node,
8649                    &depth_by_id,
8650                    semantic_scores,
8651                    &source_covered_ids,
8652                    origin_ids,
8653                ),
8654            )
8655        })
8656        .collect::<BTreeMap<_, _>>();
8657
8658    let mut node_candidates = nodes.iter().collect::<Vec<_>>();
8659    node_candidates.sort_by(|left, right| {
8660        node_score_by_id
8661            .get(&right.id)
8662            .cmp(&node_score_by_id.get(&left.id))
8663            .then_with(|| left.kind.cmp(&right.kind))
8664            .then_with(|| left.label.cmp(&right.label))
8665            .then_with(|| left.id.cmp(&right.id))
8666    });
8667
8668    let cursor_skip = if let Some(cursor) = cursor {
8669        node_candidates
8670            .iter()
8671            .position(|node| node.id == cursor)
8672            .map(|pos| pos.saturating_add(1))
8673            .unwrap_or(0)
8674    } else {
8675        0
8676    };
8677    if cursor_skip > 0 {
8678        node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
8679    }
8680
8681    let mut selected_node_ids = BTreeSet::new();
8682    let mut selected_node_counts = BTreeMap::<String, usize>::new();
8683    let mut estimated_tokens = 0usize;
8684    let mut drops = BTreeMap::<(String, String, String), usize>::new();
8685    for node in &node_candidates {
8686        let kind_count = selected_node_counts
8687            .get(&node.kind)
8688            .copied()
8689            .unwrap_or_default();
8690        if !origin_ids.iter().any(|origin| origin == &node.id)
8691            && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
8692        {
8693            graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
8694            continue;
8695        }
8696        let tokens = graph_db_estimated_tokens(node);
8697        if !origin_ids.iter().any(|origin| origin == &node.id)
8698            && estimated_tokens.saturating_add(tokens) > max_tokens
8699        {
8700            graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
8701            continue;
8702        }
8703        selected_node_ids.insert(node.id.clone());
8704        *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
8705        estimated_tokens = estimated_tokens.saturating_add(tokens);
8706    }
8707
8708    let has_remaining_candidates = node_candidates
8709        .iter()
8710        .any(|node| !selected_node_ids.contains(&node.id));
8711
8712    let mut selected_nodes = nodes
8713        .into_iter()
8714        .filter(|node| selected_node_ids.contains(&node.id))
8715        .collect::<Vec<_>>();
8716
8717    let mut edge_candidates = edges
8718        .iter()
8719        .filter(|edge| {
8720            selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
8721        })
8722        .collect::<Vec<_>>();
8723    let edge_score_by_key = edge_candidates
8724        .iter()
8725        .map(|edge| {
8726            (
8727                graph_db_edge_key(edge),
8728                graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
8729            )
8730        })
8731        .collect::<BTreeMap<_, _>>();
8732    edge_candidates.sort_by(|left, right| {
8733        edge_score_by_key
8734            .get(&graph_db_edge_key(right))
8735            .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
8736            .then_with(|| left.kind.cmp(&right.kind))
8737            .then_with(|| left.from_id.cmp(&right.from_id))
8738            .then_with(|| left.to_id.cmp(&right.to_id))
8739    });
8740
8741    let endpoint_dropped_edges = edges
8742        .iter()
8743        .filter(|edge| {
8744            !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
8745        })
8746        .count();
8747    if endpoint_dropped_edges > 0 {
8748        drops.insert(
8749            (
8750                "edge".to_string(),
8751                "*".to_string(),
8752                "endpoint_node_dropped".to_string(),
8753            ),
8754            endpoint_dropped_edges,
8755        );
8756    }
8757
8758    let mut selected_edge_ids = BTreeSet::new();
8759    let mut selected_edge_counts = BTreeMap::<String, usize>::new();
8760    for edge in edge_candidates {
8761        let kind_count = selected_edge_counts
8762            .get(&edge.kind)
8763            .copied()
8764            .unwrap_or_default();
8765        if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
8766            graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
8767            continue;
8768        }
8769        let tokens = graph_db_estimated_tokens(edge);
8770        if estimated_tokens.saturating_add(tokens) > max_tokens {
8771            graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
8772            continue;
8773        }
8774        selected_edge_ids.insert(graph_db_edge_key(edge));
8775        *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
8776        estimated_tokens = estimated_tokens.saturating_add(tokens);
8777    }
8778
8779    let selected_edges = edges
8780        .into_iter()
8781        .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
8782        .collect::<Vec<_>>();
8783    let dropped_by_budget = graph_db_budget_drop_report(drops);
8784    let truncated = has_remaining_candidates;
8785    let next_cursor = if truncated {
8786        selected_nodes.last().map(|node| node.id.clone())
8787    } else {
8788        None
8789    };
8790    let mut diagnostics = vec![
8791        "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
8792            .to_string(),
8793        format!(
8794            "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
8795            selected_nodes.len(),
8796            candidate_nodes,
8797            selected_edges.len(),
8798            candidate_edges,
8799            max_tokens
8800        ),
8801    ];
8802    if cursor.is_some() {
8803        diagnostics.push(format!(
8804            "cursor skipped {} previously returned candidate(s)",
8805            cursor_skip
8806        ));
8807    }
8808    if next_cursor.is_some() {
8809        diagnostics.push(
8810            "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
8811        );
8812    }
8813    selected_nodes.shrink_to_fit();
8814
8815    GraphDbBudgetedSubgraph {
8816        nodes: selected_nodes,
8817        edges: selected_edges,
8818        report: GraphDbOutputBudgetReport {
8819            max_tokens,
8820            estimated_tokens,
8821            selected_nodes: selected_node_ids.len(),
8822            selected_edges: selected_edge_ids.len(),
8823            candidate_nodes,
8824            candidate_edges,
8825            dropped_by_budget,
8826            diagnostics,
8827        },
8828        truncated,
8829        next_cursor,
8830    }
8831}
8832
8833fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
8834    if edge.id.is_empty() {
8835        substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
8836    } else {
8837        edge.id.clone()
8838    }
8839}
8840
8841fn graph_db_schema() -> GraphDbSchema {
8842    GraphDbSchema {
8843        contract_versions: vec![
8844            GraphDbSchemaContract {
8845                name: "graph_db_evidence",
8846                version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
8847                description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
8848            },
8849            GraphDbSchemaContract {
8850                name: "worker_prompt_packet",
8851                version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
8852                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",
8853            },
8854            GraphDbSchemaContract {
8855                name: "conflict_matrix",
8856                version: CONFLICT_MATRIX_CONTRACT_VERSION,
8857                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",
8858            },
8859            GraphDbSchemaContract {
8860                name: "context_pack_graph_orchestration",
8861                version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
8862                description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
8863            },
8864            GraphDbSchemaContract {
8865                name: "session_review_follow_up",
8866                version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
8867                description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
8868            },
8869            GraphDbSchemaContract {
8870                name: "dispatch_trace",
8871                version: DISPATCH_TRACE_CONTRACT_VERSION,
8872                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",
8873            },
8874            GraphDbSchemaContract {
8875                name: "dependency_dag",
8876                version: DEPENDENCY_DAG_CONTRACT_VERSION,
8877                description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
8878            },
8879        ],
8880        node_fields: vec![
8881            GraphDbSchemaField {
8882                name: "id",
8883                value_type: "string",
8884                description: "Stable provider-neutral node id",
8885            },
8886            GraphDbSchemaField {
8887                name: "kind",
8888                value_type: "string",
8889                description: "Application-defined node family such as file, symbol, or backlog",
8890            },
8891            GraphDbSchemaField {
8892                name: "label",
8893                value_type: "string",
8894                description: "Human-readable label",
8895            },
8896            GraphDbSchemaField {
8897                name: "properties",
8898                value_type: "object<string,string>",
8899                description: "Adapter-specific string properties",
8900            },
8901            GraphDbSchemaField {
8902                name: "provenance",
8903                value_type: "array",
8904                description: "Source system and source reference metadata",
8905            },
8906            GraphDbSchemaField {
8907                name: "freshness",
8908                value_type: "object|null",
8909                description: "Optional content hash and observed timestamp",
8910            },
8911        ],
8912        edge_fields: vec![
8913            GraphDbSchemaField {
8914                name: "id",
8915                value_type: "string",
8916                description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
8917            },
8918            GraphDbSchemaField {
8919                name: "from_id",
8920                value_type: "string",
8921                description: "Source node id",
8922            },
8923            GraphDbSchemaField {
8924                name: "to_id",
8925                value_type: "string",
8926                description: "Target node id",
8927            },
8928            GraphDbSchemaField {
8929                name: "kind",
8930                value_type: "string",
8931                description: "Application-defined edge relation",
8932            },
8933            GraphDbSchemaField {
8934                name: "properties",
8935                value_type: "object<string,string>",
8936                description: "Adapter-specific string properties",
8937            },
8938            GraphDbSchemaField {
8939                name: "provenance",
8940                value_type: "array",
8941                description: "Source system and source reference metadata",
8942            },
8943            GraphDbSchemaField {
8944                name: "freshness",
8945                value_type: "object|null",
8946                description: "Optional content hash and observed timestamp",
8947            },
8948        ],
8949        operations: vec![
8950            GraphDbSchemaOperation {
8951                command: "refresh",
8952                description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
8953            },
8954            GraphDbSchemaOperation {
8955                command: "status",
8956                description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
8957            },
8958            GraphDbSchemaOperation {
8959                command: "doctor",
8960                description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
8961            },
8962            GraphDbSchemaOperation {
8963                command: "drift",
8964                description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
8965            },
8966            GraphDbSchemaOperation {
8967                command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
8968                description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
8969            },
8970            GraphDbSchemaOperation {
8971                command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
8972                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",
8973            },
8974            GraphDbSchemaOperation {
8975                command: "evidence <target> [--depth N] [--limit N]",
8976                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",
8977            },
8978            GraphDbSchemaOperation {
8979                command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
8980                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",
8981            },
8982            GraphDbSchemaOperation {
8983                command: "dispatch-trace [target...] --path <session> [--format json|html]",
8984                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",
8985            },
8986            GraphDbSchemaOperation {
8987                command: "dependency-dag [target...] --path <session>",
8988                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",
8989            },
8990            GraphDbSchemaOperation {
8991                command: "schema",
8992                description: "Return record and operation schemas",
8993            },
8994            GraphDbSchemaOperation {
8995                command: "node <id>",
8996                description: "Return one node by stable id",
8997            },
8998            GraphDbSchemaOperation {
8999                command: "edge <id>",
9000                description: "Return one edge by stable edge id",
9001            },
9002            GraphDbSchemaOperation {
9003                command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9004                description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
9005            },
9006            GraphDbSchemaOperation {
9007                command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9008                description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
9009            },
9010            GraphDbSchemaOperation {
9011                command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
9012                description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
9013            },
9014            GraphDbSchemaOperation {
9015                command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
9016                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",
9017            },
9018            GraphDbSchemaOperation {
9019                command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
9020                description: "Return the shortest directed path by node id, optionally bounded by hop count",
9021            },
9022        ],
9023    }
9024}
9025
9026pub(crate) fn sqlite_graph_freshness(
9027    store: &SqliteGraphStore,
9028    scope: &str,
9029) -> Result<GraphDbFreshnessReport> {
9030    let version = store.projection_version(scope)?;
9031    let Some(version) = version else {
9032        return Ok(GraphDbFreshnessReport {
9033            status: "missing".to_string(),
9034            fail_closed: true,
9035            projection_version: None,
9036            content_hash: None,
9037            source_watermark: None,
9038            diagnostics: vec![
9039                "graph projection metadata is missing; rebuild the graph before trusting reads"
9040                    .to_string(),
9041            ],
9042        });
9043    };
9044    let mut diagnostics = Vec::new();
9045    let fail_closed =
9046        version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
9047    if version.projection_version != GRAPH_PROJECTION_VERSION {
9048        diagnostics.push(format!(
9049            "projection version mismatch: expected {} got {}",
9050            GRAPH_PROJECTION_VERSION, version.projection_version
9051        ));
9052    }
9053    if version.content_hash.is_none() {
9054        diagnostics.push("projection content hash is missing".to_string());
9055    }
9056    Ok(GraphDbFreshnessReport {
9057        status: if fail_closed { "stale" } else { "current" }.to_string(),
9058        fail_closed,
9059        projection_version: Some(version.projection_version),
9060        content_hash: version.content_hash,
9061        source_watermark: version.source_watermark,
9062        diagnostics,
9063    })
9064}
9065
9066pub(crate) fn convex_graph_freshness(
9067    local: &ConvexProjectionRows,
9068    snapshot: &ConvexProjectionRows,
9069    scope: Option<&str>,
9070) -> GraphDbFreshnessReport {
9071    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
9072    GraphDbFreshnessReport {
9073        status: freshness.status,
9074        fail_closed: freshness.fail_closed,
9075        projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
9076        content_hash: freshness.snapshot_hash,
9077        source_watermark: None,
9078        diagnostics: freshness.diagnostics,
9079    }
9080}
9081
9082pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
9083    let (nodes, edges) = store.graph_counts()?;
9084    let files = store.file_count()?;
9085    Ok(GraphDbFreshnessReport {
9086        status: "current".to_string(),
9087        fail_closed: false,
9088        projection_version: Some("tokensave-readonly".to_string()),
9089        content_hash: None,
9090        source_watermark: Some(store.db_path().to_string_lossy().to_string()),
9091        diagnostics: vec![format!(
9092            "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
9093            nodes, edges, files
9094        )],
9095    })
9096}
9097
9098pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
9099    match TokensaveDb::discover(root) {
9100        Ok(Some(store)) => {
9101            report.push_check(GraphDbDoctorCheck {
9102                name: "tokensave_db_open".to_string(),
9103                status: "ok".to_string(),
9104                fail_closed: false,
9105                diagnostics: vec![format!(
9106                    "opened tokensave database at {}",
9107                    store.db_path().display()
9108                )],
9109                repair_commands: Vec::new(),
9110            });
9111            match (store.node_count(), store.edge_count(), store.file_count()) {
9112                (Ok(nodes), Ok(edges), Ok(files)) => {
9113                    report.push_check(GraphDbDoctorCheck {
9114                        name: "tokensave_counts".to_string(),
9115                        status: "ok".to_string(),
9116                        fail_closed: false,
9117                        diagnostics: vec![format!(
9118                            "tokensave contains {} node(s), {} edge(s), {} file(s)",
9119                            nodes, edges, files
9120                        )],
9121                        repair_commands: Vec::new(),
9122                    });
9123                }
9124                (nodes, edges, files) => {
9125                    report.push_check(graph_db_doctor_check(
9126                        "tokensave_counts",
9127                        vec![format!(
9128                            "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
9129                            nodes.err(),
9130                            edges.err(),
9131                            files.err()
9132                        )],
9133                        Vec::new(),
9134                    ));
9135                }
9136            }
9137        }
9138        Ok(None) => report.push_check(graph_db_doctor_check(
9139            "tokensave_db_exists",
9140            vec![format!(
9141                "tokensave database is missing at {}",
9142                root.join(".tokensave").join("tokensave.db").display()
9143            )],
9144            Vec::new(),
9145        )),
9146        Err(err) => report.push_check(graph_db_doctor_check(
9147            "tokensave_db_open",
9148            vec![err.to_string()],
9149            Vec::new(),
9150        )),
9151    }
9152}
9153
9154pub(crate) fn graph_db_resolve_evidence_target(
9155    store: &impl GraphStore,
9156    target: &str,
9157) -> Result<Option<SubstrateGraphNode>> {
9158    store.resolve_evidence_target(
9159        target,
9160        &[
9161            "backlog",
9162            "job_packet",
9163            "worker_result",
9164            "worker_context",
9165            "source_handle",
9166        ],
9167    )
9168}
9169
9170fn graph_db_reachable_nodes_by_kind(
9171    store: &impl GraphStore,
9172    from_id: &str,
9173    kind: &str,
9174    depth: usize,
9175    limit: usize,
9176) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
9177    store.reachable_nodes_by_kind(from_id, kind, depth, limit)
9178}
9179
9180fn graph_db_evidence_completed_queue_drift_warnings(
9181    store: &impl GraphStore,
9182    target: &SubstrateGraphNode,
9183    worker_results: &[SubstrateGraphNode],
9184) -> Result<Vec<String>> {
9185    let ref_id = target.properties.get("ref_id").map(String::as_str);
9186    let has_completed_result = worker_results.iter().any(|node| {
9187        node.properties.get("status").map(String::as_str) == Some("completed")
9188            && node.properties.get("ref_id").map(String::as_str) == ref_id
9189    });
9190    if !has_completed_result {
9191        return Ok(Vec::new());
9192    }
9193    let active_jobs = store
9194        .nodes_by_kind("job_packet")?
9195        .into_iter()
9196        .filter(|node| {
9197            node.properties.get("ref_id").map(String::as_str) == ref_id
9198                && node.label.starts_with("do #")
9199        })
9200        .collect::<Vec<_>>();
9201    if active_jobs.is_empty() {
9202        return Ok(Vec::new());
9203    }
9204    let repair = match (target.properties.get("path"), ref_id) {
9205        (Some(path), Some(id)) => format!(
9206            "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9207            shell_quote(path),
9208            shell_quote(id),
9209            shell_quote(id)
9210        ),
9211        _ => {
9212            "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9213        }
9214    };
9215    Ok(vec![format!(
9216        "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",
9217        target.label,
9218        active_jobs.len()
9219    )])
9220}
9221
9222fn graph_db_evidence_next_commands(
9223    root: &Path,
9224    scope: Option<&str>,
9225    target: &SubstrateGraphNode,
9226    worker_context: &[SubstrateGraphNode],
9227    source_handles: &[SubstrateGraphNode],
9228    worker_results: &[SubstrateGraphNode],
9229    semantic_related: &[SubstrateGraphNode],
9230) -> Vec<String> {
9231    let mut commands = BTreeSet::new();
9232    if let Some(expand) = target.properties.get("expand") {
9233        commands.insert(expand.clone());
9234    }
9235    for worker in worker_context {
9236        if let Some(expand) = worker.properties.get("expand") {
9237            commands.insert(expand.clone());
9238        }
9239    }
9240    for source in source_handles {
9241        if let Some(expand) = source.properties.get("expand") {
9242            commands.insert(expand.clone());
9243        }
9244    }
9245    for result in worker_results {
9246        if let Some(expand) = result.properties.get("expand") {
9247            commands.insert(expand.clone());
9248        }
9249    }
9250    for semantic in semantic_related {
9251        if let Some(expand) = semantic.properties.get("expand") {
9252            commands.insert(expand.clone());
9253        }
9254    }
9255    commands.insert(format!(
9256        "tsift graph-db --path {}{} status --json",
9257        shell_quote(root.to_string_lossy().as_ref()),
9258        graph_db_scope_arg(scope)
9259    ));
9260    commands.insert(format!(
9261        "tsift graph-db --path {}{} doctor --json",
9262        shell_quote(root.to_string_lossy().as_ref()),
9263        graph_db_scope_arg(scope)
9264    ));
9265    commands.into_iter().collect()
9266}
9267
9268fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9269    vec![
9270        format!(
9271            "tsift graph-db --path {}{} refresh --json",
9272            shell_quote(root.to_string_lossy().as_ref()),
9273            graph_db_scope_arg(scope)
9274        ),
9275        format!(
9276            "tsift graph-db --path {}{} doctor --json",
9277            shell_quote(root.to_string_lossy().as_ref()),
9278            graph_db_scope_arg(scope)
9279        ),
9280    ]
9281}
9282
9283fn graph_db_evidence_replay_commands(
9284    root: &Path,
9285    scope: Option<&str>,
9286    target: &str,
9287    depth: usize,
9288    limit: usize,
9289) -> Vec<String> {
9290    vec![
9291        format!(
9292            "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9293            shell_quote(root.to_string_lossy().as_ref()),
9294            graph_db_scope_arg(scope),
9295            shell_quote(target),
9296            depth,
9297            limit
9298        ),
9299        format!(
9300            "tsift conflict-matrix --path {} {} --json",
9301            shell_quote(root.to_string_lossy().as_ref()),
9302            shell_quote(target)
9303        ),
9304    ]
9305}
9306
9307fn graph_db_evidence_packet_id(
9308    target: &str,
9309    target_node: &SubstrateGraphNode,
9310    freshness: &GraphDbFreshnessReport,
9311) -> String {
9312    stable_handle(
9313        "gevd",
9314        &format!(
9315            "{}:{}:{}:{}",
9316            GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9317            target,
9318            target_node.id,
9319            freshness.content_hash.as_deref().unwrap_or("no-hash")
9320        ),
9321    )
9322}
9323
9324pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9325    input: GraphDbEvidenceInput<'_, S>,
9326) -> Result<GraphDbEvidenceReport> {
9327    let GraphDbEvidenceInput {
9328        root,
9329        scope,
9330        backend,
9331        target,
9332        depth,
9333        limit,
9334        cursor,
9335        store,
9336        freshness,
9337        mut warnings,
9338    } = input;
9339    let repair_commands = graph_db_repair_commands(root, scope);
9340    if freshness.fail_closed {
9341        bail!(
9342            "graph database evidence failed closed for {} backend: {}; repair: {}",
9343            backend,
9344            freshness.diagnostics.join("; "),
9345            repair_commands.join("; ")
9346        );
9347    }
9348    let semantic_readiness = graph_db_semantic_readiness(
9349        root,
9350        scope,
9351        graph_store_semantic_node_count(store).ok(),
9352    );
9353    if semantic_readiness.fail_closed {
9354        warnings.push(format!(
9355            "graph evidence semantic readiness blocked: {} — {}",
9356            semantic_readiness.reason,
9357            semantic_readiness.diagnostics.join("; ")
9358        ));
9359        warnings.push(format!(
9360            "repair: {}",
9361            semantic_readiness.next_commands.join("; then ")
9362        ));
9363    }
9364    let target_node = graph_db_resolve_evidence_target(store, target)?
9365        .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9366    let max_rows = if limit == 0 { usize::MAX } else { limit };
9367    let mut reachable = store.reachable_nodes_by_kinds(
9368        &target_node.id,
9369        &[
9370            "worker_context",
9371            "source_handle",
9372            "worker_result",
9373            "semantic_concept",
9374            "semantic_entity",
9375        ],
9376        depth,
9377        max_rows,
9378    )?;
9379    let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9380    let source_paths = reachable.remove("source_handle").unwrap_or_default();
9381    let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9382    let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9383    semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9384    semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9385        left_path
9386            .hops
9387            .cmp(&right_path.hops)
9388            .then(left_node.kind.cmp(&right_node.kind))
9389            .then(left_node.label.cmp(&right_node.label))
9390            .then(left_node.id.cmp(&right_node.id))
9391    });
9392    if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9393        semantic_paths.truncate(max_rows);
9394    }
9395
9396    let evidence_nodes = worker_paths
9397        .iter()
9398        .chain(source_paths.iter())
9399        .chain(worker_result_paths.iter())
9400        .chain(semantic_paths.iter())
9401        .map(|(node, _)| node.clone())
9402        .collect::<Vec<_>>();
9403    let evidence_depth_by_id = worker_paths
9404        .iter()
9405        .chain(source_paths.iter())
9406        .chain(worker_result_paths.iter())
9407        .chain(semantic_paths.iter())
9408        .map(|(node, path)| (node.id.clone(), path.hops))
9409        .collect::<BTreeMap<_, _>>();
9410    let target_query = graph_db_node_search_text(&target_node);
9411    let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9412    let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9413        std::slice::from_ref(&target_node.id),
9414        &semantic_scores,
9415        evidence_nodes,
9416        Vec::new(),
9417        Some(limit),
9418        Some(&evidence_depth_by_id),
9419        cursor,
9420    );
9421    let output_budget = budgeted.report;
9422    let truncated = budgeted.truncated;
9423    let next_cursor = budgeted.next_cursor;
9424    let retained_evidence_ids = budgeted
9425        .nodes
9426        .iter()
9427        .map(|node| node.id.as_str())
9428        .collect::<BTreeSet<_>>();
9429    let worker_context = worker_paths
9430        .iter()
9431        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9432        .map(|(node, _)| node.clone())
9433        .collect::<Vec<_>>();
9434    let source_handles = source_paths
9435        .iter()
9436        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9437        .map(|(node, _)| node.clone())
9438        .collect::<Vec<_>>();
9439    let worker_results = worker_result_paths
9440        .iter()
9441        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9442        .map(|(node, _)| node.clone())
9443        .collect::<Vec<_>>();
9444    let semantic_related = semantic_paths
9445        .iter()
9446        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9447        .map(|(node, _)| node.clone())
9448        .collect::<Vec<_>>();
9449    warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
9450        store,
9451        &target_node,
9452        &worker_results,
9453    )?);
9454    if worker_context.is_empty()
9455        && source_handles.is_empty()
9456        && worker_results.is_empty()
9457        && semantic_related.is_empty()
9458    {
9459        warnings.push(format!(
9460            "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",
9461            target, target_node.kind
9462        ));
9463    }
9464    let shortest_paths = worker_paths
9465        .iter()
9466        .chain(source_paths.iter())
9467        .chain(worker_result_paths.iter())
9468        .chain(semantic_paths.iter())
9469        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9470        .map(|(node, path)| GraphDbEvidencePath {
9471            to: node.id.clone(),
9472            kind: node.kind.clone(),
9473            label: node.label.clone(),
9474            path: Some(path.clone()),
9475            expand: node.properties.get("expand").cloned(),
9476        })
9477        .collect::<Vec<_>>();
9478    let next_commands = graph_db_evidence_next_commands(
9479        root,
9480        scope,
9481        &target_node,
9482        &worker_context,
9483        &source_handles,
9484        &worker_results,
9485        &semantic_related,
9486    );
9487    let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
9488    let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
9489    let projection_hash = freshness.content_hash.clone();
9490
9491    Ok(GraphDbEvidenceReport {
9492        root: root.to_string_lossy().to_string(),
9493        scope: scope.map(str::to_string),
9494        backend: backend.to_string(),
9495        contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
9496        target: target.to_string(),
9497        packet_id,
9498        projection_hash,
9499        freshness,
9500        target_node: target_node.into(),
9501        worker_context: worker_context.into_iter().map(Into::into).collect(),
9502        source_handles: source_handles.into_iter().map(Into::into).collect(),
9503        worker_results: worker_results.into_iter().map(Into::into).collect(),
9504        semantic_related: semantic_related.into_iter().map(Into::into).collect(),
9505        shortest_paths,
9506        output_budget: Some(output_budget),
9507        truncated,
9508        next_cursor,
9509        next_commands,
9510        replay_commands,
9511        repair_commands,
9512        fixture_coverage: GraphDbFixtureCoverage {
9513            test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
9514                .to_string(),
9515            fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
9516            assertions: vec![
9517                "backlog id and job packet handle resolve to graph nodes".to_string(),
9518                "worker_context rows are reachable from queued work".to_string(),
9519                "source_handle rows are reachable through bounded shortest paths".to_string(),
9520                "worker_result rows are reachable from completed or blocked work".to_string(),
9521            ],
9522        },
9523        warnings,
9524    })
9525}
9526
9527fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
9528    println!(
9529        "graph-db evidence backend: {} target: {} [{}] packet:{}",
9530        report.backend, report.target_node.id, report.target_node.kind, report.packet_id
9531    );
9532    let page_info = if report.truncated {
9533        let cursor = report.next_cursor.as_deref().unwrap_or("?");
9534        format!(" (truncated, next_cursor: {cursor})")
9535    } else {
9536        String::new()
9537    };
9538    println!(
9539        "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
9540        report.worker_context.len(),
9541        report.source_handles.len(),
9542        report.worker_results.len(),
9543        report.semantic_related.len(),
9544        report.shortest_paths.len()
9545    );
9546    for path in &report.shortest_paths {
9547        if let Some(graph_path) = &path.path {
9548            println!(
9549                "path: {} hop(s) {}",
9550                graph_path.hops,
9551                graph_path.nodes.join(" -> ")
9552            );
9553        }
9554    }
9555    for command in &report.next_commands {
9556        println!("next: {command}");
9557    }
9558    for warning in &report.warnings {
9559        println!("warning: {warning}");
9560    }
9561}
9562
9563pub(crate) fn print_graph_db_evidence_report(
9564    report: &GraphDbEvidenceReport,
9565    format: OutputFormat,
9566) -> Result<()> {
9567    if format.json_output {
9568        let page_info = if report.truncated {
9569            let cursor = report.next_cursor.as_deref().unwrap_or("?");
9570            format!(" (truncated, next_cursor: {cursor})")
9571        } else {
9572            String::new()
9573        };
9574        print_json_or_envelope(
9575            report,
9576            &format,
9577            "graph-db",
9578            "evidence",
9579            ToolEnvelopeSummary {
9580                text: format!(
9581                    "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}",
9582                    report.target,
9583                    report.worker_context.len(),
9584                    report.source_handles.len(),
9585                    report.worker_results.len(),
9586                    report.semantic_related.len(),
9587                    report.shortest_paths.len()
9588                ),
9589                metrics: vec![
9590                    envelope_metric("backend", &report.backend),
9591                    envelope_metric("worker_context", report.worker_context.len()),
9592                    envelope_metric("source_handles", report.source_handles.len()),
9593                    envelope_metric("worker_results", report.worker_results.len()),
9594                    envelope_metric("semantic_related", report.semantic_related.len()),
9595                    envelope_metric("paths", report.shortest_paths.len()),
9596                ],
9597            },
9598            report.truncated,
9599            report.next_commands.clone(),
9600        )
9601    } else {
9602        print_graph_db_evidence_human(report);
9603        Ok(())
9604    }
9605}
9606
9607pub(crate) fn graph_db_report_from_store(
9608    root: &Path,
9609    scope: Option<&str>,
9610    backend: &str,
9611    query: GraphDbQuery,
9612    store: &impl GraphStore,
9613    freshness: GraphDbFreshnessReport,
9614    warnings: Vec<String>,
9615) -> Result<GraphDbReport> {
9616    if freshness.fail_closed {
9617        bail!(
9618            "graph database read failed closed for {} backend: {}",
9619            backend,
9620            freshness.diagnostics.join("; ")
9621        );
9622    }
9623    let mut report = GraphDbReport {
9624        root: root.to_string_lossy().to_string(),
9625        scope: scope.map(str::to_string),
9626        backend: backend.to_string(),
9627        query: format!("{query:?}"),
9628        freshness,
9629        readiness: None,
9630        schema: None,
9631        node: None,
9632        edge: None,
9633        nodes: Vec::new(),
9634        edges: Vec::new(),
9635        ranked_neighbors: Vec::new(),
9636        semantic_related: Vec::new(),
9637        neighborhood_ranking_gate: None,
9638        ranked_neighborhood_comparison: None,
9639        knowledge_retrieval: None,
9640        output_budget: None,
9641        path: None,
9642        page: None,
9643        warnings,
9644    };
9645
9646    match query {
9647        GraphDbQuery::Refresh => {
9648            bail!("graph-db refresh must be handled by the refresh command path");
9649        }
9650        GraphDbQuery::Status => {
9651            bail!("graph-db status must be handled by the status command path");
9652        }
9653        GraphDbQuery::Doctor => {
9654            bail!("graph-db doctor must be handled by the doctor command path");
9655        }
9656        GraphDbQuery::Drift => {
9657            bail!("graph-db drift must be handled by the drift command path");
9658        }
9659        GraphDbQuery::Compact { .. } => {
9660            bail!("graph-db compact must be handled by the compact command path");
9661        }
9662        GraphDbQuery::BackendEval { .. } => {
9663            bail!("graph-db backend-eval must be handled by the benchmark command path");
9664        }
9665        GraphDbQuery::Evidence { .. } => {
9666            bail!("graph-db evidence must be handled by the evidence command path");
9667        }
9668        GraphDbQuery::Related {
9669            query,
9670            kind,
9671            depth,
9672            seed_limit,
9673            limit,
9674        } => {
9675            let semantic =
9676                semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
9677            let SemanticRelatedReport {
9678                items,
9679                warnings: semantic_warnings,
9680                ..
9681            } = semantic;
9682            let readiness = graph_db_semantic_readiness(
9683                root,
9684                scope,
9685                (!items.is_empty()).then_some(items.len()),
9686            );
9687            report.warnings.extend(semantic_warnings);
9688            let seed_ids = items
9689                .iter()
9690                .map(|item| item.handle.clone())
9691                .collect::<Vec<_>>();
9692            let semantic_scores = items
9693                .iter()
9694                .map(|item| (item.handle.clone(), item.score))
9695                .collect::<BTreeMap<_, _>>();
9696            let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
9697            let seed_count = seed_ids.len();
9698            let mut diagnostics = subgraph.diagnostics;
9699            let budgeted = graph_db_apply_output_budget(
9700                &seed_ids,
9701                &semantic_scores,
9702                subgraph.nodes,
9703                subgraph.edges,
9704                Some(limit),
9705            );
9706            let budget_report = budgeted.report;
9707            let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
9708            diagnostics.extend(budget_report.diagnostics.clone());
9709            diagnostics.extend(readiness.diagnostics.clone());
9710
9711            report.readiness = Some(readiness);
9712            report.semantic_related = items;
9713            if let Some(seed_id) = seed_ids.first() {
9714                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
9715                report.ranked_neighbors = graph_db_ranked_neighbors(
9716                    seed_id,
9717                    &budgeted.nodes,
9718                    &budgeted.edges,
9719                    ranked_neighbor_cap,
9720                );
9721                report.neighborhood_ranking_gate =
9722                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9723            }
9724            report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9725            report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9726            report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
9727                mode: "semantic_seeded_neighborhood".to_string(),
9728                query,
9729                seed_kind: semantic_related_kind_name(kind).to_string(),
9730                seed_limit,
9731                seed_count,
9732                depth,
9733                limit,
9734                node_count: report.nodes.len(),
9735                edge_count: report.edges.len(),
9736                truncated: subgraph.truncated || dropped_by_budget,
9737                traversal: "incident_plus_outgoing_edges".to_string(),
9738                freshness_boundary:
9739                    "semantic rows must come from refreshed summary or tsift-memory graph records"
9740                        .to_string(),
9741                privacy_boundary:
9742                    "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
9743                        .to_string(),
9744                diagnostics,
9745            });
9746            report.output_budget = Some(budget_report);
9747        }
9748        GraphDbQuery::Schema => {
9749            report.schema = Some(graph_db_schema());
9750        }
9751        GraphDbQuery::Node { id } => {
9752            report.node = store.node(&id)?.map(Into::into);
9753        }
9754        GraphDbQuery::Edge { id } => {
9755            report.edge = store.edge(&id)?.map(Into::into);
9756        }
9757        GraphDbQuery::Edges {
9758            edge_kind,
9759            cursor,
9760            limit,
9761            property_filters,
9762        } => {
9763            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9764            let paged = store.paged_edges(
9765                edge_kind.as_deref(),
9766                graph_db_query_options_for_store(&options),
9767            )?;
9768            report.edges = paged.edges.into_iter().map(Into::into).collect();
9769            report.page = Some(graph_db_page_report_from_store(
9770                paged.page,
9771                options.property_filters,
9772            ));
9773        }
9774        GraphDbQuery::Incident {
9775            id,
9776            edge_kind,
9777            cursor,
9778            limit,
9779            property_filters,
9780        } => {
9781            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9782            let paged = store.paged_incident_edges(
9783                &id,
9784                edge_kind.as_deref(),
9785                graph_db_query_options_for_store(&options),
9786            )?;
9787            report.edges = paged.edges.into_iter().map(Into::into).collect();
9788            report.page = Some(graph_db_page_report_from_store(
9789                paged.page,
9790                options.property_filters,
9791            ));
9792        }
9793        GraphDbQuery::Kind {
9794            kind,
9795            cursor,
9796            limit,
9797            property_filters,
9798        } => {
9799            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9800            let paged =
9801                store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
9802            report.nodes = paged.nodes.into_iter().map(Into::into).collect();
9803            report.edges = paged.edges.into_iter().map(Into::into).collect();
9804            report.page = Some(graph_db_page_report_from_store(
9805                paged.page,
9806                options.property_filters,
9807            ));
9808        }
9809        GraphDbQuery::Neighborhood {
9810            id,
9811            depth,
9812            edge_kind,
9813            cursor,
9814            limit,
9815            property_filters,
9816        } => {
9817            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9818            if let Some(paged) = store.paged_neighborhood(
9819                &id,
9820                depth,
9821                edge_kind.as_deref(),
9822                graph_db_query_options_for_store(&options),
9823            )? {
9824                let budgeted = graph_db_apply_output_budget(
9825                    std::slice::from_ref(&id),
9826                    &BTreeMap::new(),
9827                    paged.nodes,
9828                    paged.edges,
9829                    options.limit,
9830                );
9831                let budget_report = budgeted.report;
9832                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
9833                let ranked_neighbors = graph_db_ranked_neighbors(
9834                    &id,
9835                    &budgeted.nodes,
9836                    &budgeted.edges,
9837                    ranked_neighbor_cap,
9838                );
9839                let comparison = graph_db_ranked_neighborhood_comparison(
9840                    &id,
9841                    depth,
9842                    edge_kind.as_deref(),
9843                    options.limit,
9844                    &budgeted.nodes,
9845                    &budgeted.edges,
9846                    store,
9847                )?;
9848                report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9849                report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9850                report.ranked_neighbors = ranked_neighbors;
9851                report.neighborhood_ranking_gate =
9852                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9853                let mut page =
9854                    graph_db_page_report_from_store(paged.page, options.property_filters);
9855                page.returned_nodes = report.nodes.len();
9856                page.returned_edges = report.edges.len();
9857                page.truncated |= !budget_report.dropped_by_budget.is_empty();
9858                page.diagnostics.extend(budget_report.diagnostics.clone());
9859                report.page = Some(page);
9860                report.output_budget = Some(budget_report);
9861                if let Some(comparison) = comparison {
9862                    report.ranked_neighborhood_comparison = Some(comparison);
9863                }
9864            }
9865        }
9866        GraphDbQuery::Path {
9867            from,
9868            to,
9869            edge_kind,
9870            max_hops,
9871        } => {
9872            report.path =
9873                store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
9874            if let Some(max_hops) = max_hops
9875                && report.path.is_none()
9876            {
9877                report.warnings.push(format!(
9878                    "no directed path found within --max-hops {}",
9879                    max_hops
9880                ));
9881            }
9882        }
9883        GraphDbQuery::Map { .. } => {
9884            bail!("graph-db map must be handled by the map command path");
9885        }
9886    }
9887    Ok(report)
9888}
9889
9890pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
9891    if compact {
9892        println!(
9893            "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
9894            report.backend,
9895            report.query,
9896            report.nodes.len() + usize::from(report.node.is_some()),
9897            report.edges.len() + usize::from(report.edge.is_some()),
9898            report.freshness.status
9899        );
9900        return;
9901    }
9902    println!("graph-db backend: {}", report.backend);
9903    println!("freshness: {}", report.freshness.status);
9904    if let Some(readiness) = &report.readiness {
9905        println!(
9906            "readiness: {} reason: {} fail_closed: {}",
9907            readiness.status, readiness.reason, readiness.fail_closed
9908        );
9909        for diagnostic in &readiness.diagnostics {
9910            println!("readiness diagnostic: {diagnostic}");
9911        }
9912        for command in &readiness.next_commands {
9913            println!("readiness next: {command}");
9914        }
9915    }
9916    if let Some(schema) = &report.schema {
9917        println!(
9918            "schema: {} node fields, {} edge fields, {} operations",
9919            schema.node_fields.len(),
9920            schema.edge_fields.len(),
9921            schema.operations.len()
9922        );
9923    }
9924    if let Some(node) = &report.node {
9925        println!("node: {} [{}] {}", node.id, node.kind, node.label);
9926    }
9927    if let Some(edge) = &report.edge {
9928        let edge_full: SubstrateGraphEdge = edge.into();
9929        println!(
9930            "edge: {} {} -{}-> {}",
9931            graph_db_edge_key(&edge_full),
9932            edge.from_id,
9933            edge.kind,
9934            edge.to_id
9935        );
9936    }
9937    if let Some(knowledge) = &report.knowledge_retrieval {
9938        println!(
9939            "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
9940            knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
9941        );
9942    }
9943    for item in &report.semantic_related {
9944        println!(
9945            "semantic_seed: {:.3} [{}] {} ({})",
9946            item.score, item.kind, item.label, item.handle
9947        );
9948    }
9949    for node in &report.nodes {
9950        println!("node: {} [{}] {}", node.id, node.kind, node.label);
9951    }
9952    for edge in &report.edges {
9953        let edge_full: SubstrateGraphEdge = edge.into();
9954        println!(
9955            "edge: {} {} -{}-> {}",
9956            graph_db_edge_key(&edge_full),
9957            edge.from_id,
9958            edge.kind,
9959            edge.to_id
9960        );
9961    }
9962    for neighbor in &report.ranked_neighbors {
9963        println!(
9964            "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
9965            neighbor.rank,
9966            neighbor.score,
9967            neighbor
9968                .depth
9969                .map(|depth| depth.to_string())
9970                .unwrap_or_else(|| "unknown".to_string()),
9971            neighbor.node_id,
9972            neighbor.kind,
9973            neighbor.label
9974        );
9975    }
9976    if let Some(gate) = &report.neighborhood_ranking_gate {
9977        println!(
9978            "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
9979            gate.status, gate.default_order, gate.ranked_output_default
9980        );
9981    }
9982    if let Some(path) = &report.path {
9983        println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
9984    }
9985    if let Some(page) = &report.page {
9986        if let Some(next_cursor) = &page.next_cursor {
9987            println!("next_cursor: {next_cursor}");
9988        }
9989        for diagnostic in &page.diagnostics {
9990            println!("page: {diagnostic}");
9991        }
9992    }
9993    for warning in &report.warnings {
9994        println!("warning: {warning}");
9995    }
9996}
9997
9998pub(crate) fn graph_db_backend_eval_phase_timing(
9999    name: &str,
10000    duration_micros: u128,
10001    detail: &str,
10002) -> GraphDbBackendEvalPhaseTiming {
10003    GraphDbBackendEvalPhaseTiming {
10004        name: name.to_string(),
10005        duration_micros,
10006        detail: detail.to_string(),
10007    }
10008}
10009
10010pub(crate) fn graph_db_backend_eval_timed_phase<T>(
10011    phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
10012    name: &str,
10013    detail: &str,
10014    run: impl FnOnce() -> Result<T>,
10015) -> Result<T> {
10016    let started = Instant::now();
10017    let result = run();
10018    phases.push(graph_db_backend_eval_phase_timing(
10019        name,
10020        started.elapsed().as_micros(),
10021        detail,
10022    ));
10023    result
10024}
10025
10026pub(crate) fn graph_db_backend_eval_refresh_total_micros(
10027    phases: &[GraphDbBackendEvalPhaseTiming],
10028) -> u128 {
10029    phases
10030        .iter()
10031        .filter(|phase| phase.name != "conflict_matrix_preparation")
10032        .map(|phase| phase.duration_micros)
10033        .sum()
10034}
10035
10036pub(crate) fn graph_db_backend_eval_cached_refresh(
10037    root: &Path,
10038    scope: Option<&str>,
10039    source_watermark: Option<&str>,
10040) -> Result<
10041    Option<(
10042        TraversalGraphBuild,
10043        SqliteProjectionRefresh,
10044        Vec<GraphDbBackendEvalPhaseTiming>,
10045    )>,
10046> {
10047    let Some(source_watermark) = source_watermark else {
10048        return Ok(None);
10049    };
10050    let graph_db = graph_substrate_db_path(root, scope);
10051    if !graph_db.exists() {
10052        return Ok(None);
10053    }
10054
10055    let started = Instant::now();
10056    let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
10057        Ok(store) => store,
10058        Err(_) => return Ok(None),
10059    };
10060    if store.has_user_triggers().unwrap_or(true) {
10061        return Ok(None);
10062    }
10063    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
10064    if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
10065        return Ok(None);
10066    }
10067
10068    let phases = vec![
10069        graph_db_backend_eval_phase_timing(
10070            "source_graph_build",
10071            started.elapsed().as_micros(),
10072            "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10073        ),
10074        graph_db_backend_eval_phase_timing(
10075            "projection_rows",
10076            0,
10077            "reused cached provider-neutral projection rows from graph.db",
10078        ),
10079        graph_db_backend_eval_phase_timing(
10080            "sqlite_open",
10081            0,
10082            "reused existing graph.db projection without opening a write transaction",
10083        ),
10084    ];
10085    let refresh = SqliteProjectionRefresh {
10086        scope: scope.unwrap_or("root").to_string(),
10087        projection_version: freshness
10088            .projection_version
10089            .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
10090        source_watermark: Some(source_watermark.to_string()),
10091        tombstoned_nodes: Vec::new(),
10092        tombstoned_edges: Vec::new(),
10093        upserted_nodes: 0,
10094        upserted_edges: 0,
10095        unchanged_nodes: 0,
10096        unchanged_edges: 0,
10097        upserted_properties: 0,
10098        unchanged_properties: 0,
10099        deleted_properties: 0,
10100        deleted_nodes: 0,
10101        deleted_edges: 0,
10102        pruned_tombstones: 0,
10103        file_size_bytes_before: None,
10104        file_size_bytes_after: None,
10105        phase_timings: Vec::new(),
10106    };
10107    Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
10108}
10109
10110pub(crate) fn graph_db_backend_eval_reused_cached_projection(
10111    phases: &[GraphDbBackendEvalPhaseTiming],
10112) -> bool {
10113    phases.iter().any(|phase| {
10114        phase.name == "source_graph_build"
10115            && phase.detail.contains("reused current graph.db projection")
10116    })
10117}
10118
10119pub(crate) fn graph_db_backend_eval_update_source_watermark(
10120    root: &Path,
10121    path_hint: &Path,
10122    scope: Option<&str>,
10123) -> Result<()> {
10124    let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
10125        return Ok(());
10126    };
10127    let graph_db = graph_substrate_db_path(root, scope);
10128    let mut store = SqliteGraphStore::open(&graph_db)?;
10129    store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
10130    Ok(())
10131}
10132
10133pub(crate) fn graph_db_backend_eval_refresh_with_profile(
10134    root: &Path,
10135    path_hint: &Path,
10136    scope: Option<&str>,
10137) -> Result<(
10138    TraversalGraphBuild,
10139    SqliteProjectionRefresh,
10140    Vec<GraphDbBackendEvalPhaseTiming>,
10141)> {
10142    let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
10143    if let Some(cached) =
10144        graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
10145    {
10146        return Ok(cached);
10147    }
10148
10149    let mut phases = Vec::new();
10150    let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
10151        "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"
10152    } else {
10153        "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
10154    };
10155    let source_graph = graph_db_backend_eval_timed_phase(
10156        &mut phases,
10157        "source_graph_build",
10158        source_graph_detail,
10159        || build_traversal_graph_source_with_options(root, path_hint, scope, false),
10160    )?;
10161    let projection = graph_db_backend_eval_timed_phase(
10162        &mut phases,
10163        "projection_rows",
10164        "provider-neutral GraphStore node/edge row construction before SQLite persistence",
10165        || traversal_projection_from_graph(root, scope, &source_graph),
10166    )?;
10167    let graph_db = graph_substrate_db_path(root, scope);
10168    let mut store = graph_db_backend_eval_timed_phase(
10169        &mut phases,
10170        "sqlite_open",
10171        "open the local SQLite graph.db with WAL and busy-timeout settings",
10172        || SqliteGraphStore::open(&graph_db),
10173    )?;
10174    let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
10175        .ok()
10176        .flatten();
10177    let refresh = store.replace_projection_with_version(
10178        scope.unwrap_or("root"),
10179        &projection,
10180        Some(GRAPH_PROJECTION_VERSION),
10181        refreshed_source_watermark
10182            .or(source_watermark)
10183            .or_else(|| graph_projection_content_hash(&projection)),
10184    )?;
10185    phases.extend(
10186        refresh
10187            .phase_timings
10188            .iter()
10189            .map(|phase| GraphDbBackendEvalPhaseTiming {
10190                name: phase.name.clone(),
10191                duration_micros: phase.duration_micros,
10192                detail: phase.detail.clone(),
10193            }),
10194    );
10195    Ok((source_graph, refresh, phases))
10196}
10197
10198fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10199    root.join(".tsift/backend-eval-cache")
10200}
10201
10202fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10203    graph_db_backend_eval_disk_cache_dir(root)
10204        .join(kind)
10205        .join(format!("{key}.json.gz"))
10206}
10207
10208fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10209    graph_db_backend_eval_disk_cache_dir(root)
10210        .join(kind)
10211        .join(format!("{key}.json"))
10212}
10213
10214#[derive(Default, Clone)]
10215struct GraphDbBackendEvalDiskCacheReadProfile {
10216    file_read_micros: u128,
10217    gzip_decode_micros: u128,
10218    serde_decode_micros: u128,
10219    legacy: bool,
10220}
10221
10222fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10223    root: &Path,
10224    kind: &str,
10225    key: &str,
10226) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10227    let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10228    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10229    let read_started = Instant::now();
10230    let read_result = fs::read(&path);
10231    profile.file_read_micros = read_started.elapsed().as_micros();
10232    if let Ok(bytes) = read_result {
10233        let decode_started = Instant::now();
10234        let mut decoder = GzDecoder::new(bytes.as_slice());
10235        let mut decoded = Vec::new();
10236        let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10237        profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10238        if decode_ok {
10239            let serde_started = Instant::now();
10240            let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10241            profile.serde_decode_micros = serde_started.elapsed().as_micros();
10242            if let Some(value) = parsed {
10243                return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10244            }
10245        }
10246    }
10247
10248    let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10249    let legacy_started = Instant::now();
10250    let bytes = fs::read(legacy_path).ok()?;
10251    profile.file_read_micros = profile
10252        .file_read_micros
10253        .saturating_add(legacy_started.elapsed().as_micros());
10254    let serde_started = Instant::now();
10255    let value = serde_json::from_slice(&bytes).ok()?;
10256    profile.serde_decode_micros = profile
10257        .serde_decode_micros
10258        .saturating_add(serde_started.elapsed().as_micros());
10259    profile.legacy = true;
10260    Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10261}
10262
10263#[derive(Default, Clone)]
10264struct GraphDbBackendEvalDiskCacheWriteProfile {
10265    serde_encode_micros: u128,
10266    gzip_encode_micros: u128,
10267    file_write_micros: u128,
10268}
10269
10270fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10271    root: &Path,
10272    kind: &str,
10273    key: &str,
10274    value: &T,
10275) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10276    let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10277    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10278    let parent = path.parent()?;
10279    if fs::create_dir_all(parent).is_err() {
10280        return None;
10281    }
10282    let serde_started = Instant::now();
10283    let bytes = serde_json::to_vec(value).ok()?;
10284    profile.serde_encode_micros = serde_started.elapsed().as_micros();
10285    let gzip_started = Instant::now();
10286    let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10287    if encoder.write_all(&bytes).is_err() {
10288        return None;
10289    }
10290    let encoded = encoder.finish().ok()?;
10291    profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10292    let write_started = Instant::now();
10293    if fs::write(&path, &encoded).is_err() {
10294        return None;
10295    }
10296    profile.file_write_micros = write_started.elapsed().as_micros();
10297    Some((encoded.len() as u64, bytes.len() as u64, profile))
10298}
10299
10300fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10301    let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10302    let Ok(entries) = fs::read_dir(dir) else {
10303        return (0, 0);
10304    };
10305    let keep_name = format!("{keep_key}.json.gz");
10306    let mut pruned_files = 0usize;
10307    let mut pruned_bytes = 0u64;
10308    for entry in entries.flatten() {
10309        let path = entry.path();
10310        if !path.is_file() {
10311            continue;
10312        }
10313        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10314            continue;
10315        };
10316        if name == keep_name {
10317            continue;
10318        }
10319        let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10320        if !is_backend_eval_cache {
10321            continue;
10322        }
10323        let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10324        if fs::remove_file(&path).is_ok() {
10325            pruned_files += 1;
10326            pruned_bytes += bytes;
10327        }
10328    }
10329    (pruned_files, pruned_bytes)
10330}
10331
10332fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10333    root: &Path,
10334    source_root: &Path,
10335) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10336    let mut rows = Vec::new();
10337    let mut entries = walk::walk_files(source_root)?;
10338    entries.sort_by(|left, right| left.path.cmp(&right.path));
10339    for entry in entries {
10340        if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10341            continue;
10342        }
10343        if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10344            continue;
10345        }
10346        let bytes = fs::read(&entry.path)
10347            .with_context(|| format!("reading source input {}", entry.path.display()))?;
10348        rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10349            path: traversal_watermark_path(root, &entry.path),
10350            bytes: bytes.len() as u64,
10351            content_hash: content_hash(&bytes)?,
10352        });
10353    }
10354    Ok(rows)
10355}
10356
10357fn graph_db_backend_eval_full_projection_source_watermark(
10358    root: &Path,
10359    scope: Option<&str>,
10360) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10361    let path_hint = root;
10362    let mut detail_parts = Vec::new();
10363    let mut parts = vec![
10364        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10365        format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10366        "watermark_kind:stable_full_projection_inputs".to_string(),
10367        format!("scope:{}", scope.unwrap_or("root")),
10368        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10369    ];
10370
10371    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10372    match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10373        Some(db_path) => {
10374            let db = index::IndexDb::open_read_only_resilient(db_path)?;
10375            parts.push("index_mode:indexed".to_string());
10376            detail_parts.push("mode=indexed".to_string());
10377            parts.push(format!(
10378                "index_source_root:{}",
10379                traversal_watermark_path(root, &gate.source_root)
10380            ));
10381
10382            let symbols = db
10383                .all_symbols()?
10384                .into_iter()
10385                .filter(|symbol| {
10386                    !traversal_path_is_generated_artifact(
10387                        root,
10388                        &gate.source_root,
10389                        Path::new(&symbol.file),
10390                    ) && !traversal_path_is_session_markdown(
10391                        root,
10392                        &gate.source_root,
10393                        Path::new(&symbol.file),
10394                    )
10395                })
10396                .collect::<Vec<_>>();
10397            let symbols_hash = content_hash(&symbols)?;
10398            detail_parts.push(format!("symbols={symbols_hash}"));
10399            parts.push(format!("index_symbols:{symbols_hash}"));
10400
10401            let edges = db
10402                .all_stored_edges()?
10403                .into_iter()
10404                .filter(|edge| {
10405                    !traversal_path_is_generated_artifact(
10406                        root,
10407                        &gate.source_root,
10408                        Path::new(&edge.caller_file),
10409                    ) && !traversal_path_is_session_markdown(
10410                        root,
10411                        &gate.source_root,
10412                        Path::new(&edge.caller_file),
10413                    )
10414                })
10415                .collect::<Vec<_>>();
10416            let edges_hash = content_hash(&edges)?;
10417            detail_parts.push(format!("call_edges={edges_hash}"));
10418            parts.push(format!("index_call_edges:{edges_hash}"));
10419
10420            let routes = db
10421                .all_routes()?
10422                .into_iter()
10423                .filter(|route| {
10424                    !traversal_path_is_generated_artifact(
10425                        root,
10426                        &gate.source_root,
10427                        Path::new(&route.file),
10428                    ) && !traversal_path_is_session_markdown(
10429                        root,
10430                        &gate.source_root,
10431                        Path::new(&route.file),
10432                    )
10433                })
10434                .collect::<Vec<_>>();
10435            let routes_hash = content_hash(&routes)?;
10436            detail_parts.push(format!("routes={routes_hash}"));
10437            parts.push(format!("index_routes:{routes_hash}"));
10438        }
10439        None => {
10440            parts.push("index_mode:raw_fallback".to_string());
10441            detail_parts.push("mode=raw_fallback".to_string());
10442            parts.push(format!(
10443                "raw_source_root:{}",
10444                traversal_watermark_path(root, &gate.source_root)
10445            ));
10446            let raw_rows =
10447                graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
10448            let raw_hash = content_hash(&raw_rows)?;
10449            detail_parts.push(format!("raw_source_files={raw_hash}"));
10450            parts.push(format!("raw_source_files:{raw_hash}"));
10451        }
10452    }
10453
10454    parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
10455    detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
10456    let summaries_start = parts.len();
10457    push_traversal_summaries_watermark_part(root, &mut parts)?;
10458    let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
10459    detail_parts.push(format!("summaries={summaries_hash}"));
10460    let value = content_hash(&parts)?;
10461    detail_parts.push(format!("watermark={value}"));
10462    Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
10463        value,
10464        detail: detail_parts.join(" "),
10465    })
10466}
10467
10468fn graph_db_backend_eval_full_projection_cache_key(
10469    root: &Path,
10470    scope: Option<&str>,
10471) -> Result<(String, String, String)> {
10472    let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
10473    let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10474        root,
10475        scope,
10476        &source_watermark.value,
10477    )?;
10478    Ok((source_watermark.value, key, source_watermark.detail))
10479}
10480
10481fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
10482    root: &Path,
10483    scope: Option<&str>,
10484    source_watermark: &str,
10485) -> Result<String> {
10486    content_hash(&serde_json::json!({
10487    "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
10488    "root": root.display().to_string(),
10489    "scope": scope.unwrap_or("root"),
10490    "source_watermark": source_watermark,
10491    }))
10492}
10493
10494pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
10495    root: &Path,
10496    scope: Option<&str>,
10497) -> Result<(
10498    GraphProjection,
10499    Vec<String>,
10500    Vec<GraphDbBackendEvalPhaseTiming>,
10501    GraphDbBackendEvalFullProjectionCacheStats,
10502)> {
10503    let (source_watermark, key, source_watermark_detail) =
10504        graph_db_backend_eval_full_projection_cache_key(root, scope)?;
10505    let lookup_started = Instant::now();
10506    if let Some((cached, disk_bytes, json_bytes, read_profile)) =
10507        graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
10508            root,
10509            "full_projection",
10510            &key,
10511        )
10512        && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
10513        && cached.key == key
10514        && cached.source_watermark == source_watermark
10515    {
10516        let lookup_overhead_micros = lookup_started
10517            .elapsed()
10518            .as_micros()
10519            .saturating_sub(read_profile.file_read_micros)
10520            .saturating_sub(read_profile.gzip_decode_micros)
10521            .saturating_sub(read_profile.serde_decode_micros);
10522        let prune_started = Instant::now();
10523        let (pruned_files, pruned_bytes) =
10524            graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
10525        let prune_micros = prune_started.elapsed().as_micros();
10526        let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
10527            hit: true,
10528            disk_bytes,
10529            json_bytes,
10530            pruned_files,
10531            pruned_bytes,
10532        };
10533        let read_detail_suffix = if read_profile.legacy {
10534            " (legacy uncompressed cache path)"
10535        } else {
10536            ""
10537        };
10538        return Ok((
10539            cached.projection,
10540            cached.warnings,
10541            vec![
10542                graph_db_backend_eval_phase_timing(
10543                    "full_projection.cache_lookup",
10544                    lookup_overhead_micros,
10545                    &format!(
10546                        "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
10547                    ),
10548                ),
10549                graph_db_backend_eval_phase_timing(
10550                    "full_projection.cache.file_read",
10551                    read_profile.file_read_micros,
10552                    &format!(
10553                        "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
10554                    ),
10555                ),
10556                graph_db_backend_eval_phase_timing(
10557                    "full_projection.cache.gzip_decode",
10558                    read_profile.gzip_decode_micros,
10559                    "gunzip the compressed projection cache bytes",
10560                ),
10561                graph_db_backend_eval_phase_timing(
10562                    "full_projection.cache.serde_decode",
10563                    read_profile.serde_decode_micros,
10564                    "serde_json deserialize the decoded projection cache payload",
10565                ),
10566                graph_db_backend_eval_phase_timing(
10567                    "full_projection.cache.prune",
10568                    prune_micros,
10569                    "prune sibling cache files older than the current key",
10570                ),
10571                graph_db_backend_eval_phase_timing(
10572                    "full_projection.source_graph_build",
10573                    0,
10574                    "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10575                ),
10576                graph_db_backend_eval_phase_timing(
10577                    "full_projection.projection_rows",
10578                    0,
10579                    "reused cached provider-neutral full-project projection rows",
10580                ),
10581            ],
10582            cache_stats,
10583        ));
10584    }
10585
10586    let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
10587    let mut phases = vec![graph_db_backend_eval_phase_timing(
10588        "full_projection.cache_lookup",
10589        lookup_started.elapsed().as_micros(),
10590        &format!(
10591            "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
10592        ),
10593    )];
10594    let full_source = graph_db_backend_eval_timed_phase(
10595        &mut phases,
10596        "full_projection.source_graph_build",
10597        "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
10598        || build_traversal_graph_source_with_options(root, root, scope, false),
10599    )?;
10600    let projection = graph_db_backend_eval_timed_phase(
10601        &mut phases,
10602        "full_projection.projection_rows",
10603        "provider-neutral row construction for the opt-in full-project projection dataset",
10604        || traversal_projection_from_graph(root, scope, &full_source),
10605    )?;
10606    let warnings = full_source.warnings;
10607    let refreshed_source_watermark =
10608        graph_db_backend_eval_full_projection_source_watermark(root, scope)
10609            .map(|watermark| watermark.value)
10610            .unwrap_or_else(|_| source_watermark.clone());
10611    let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10612        root,
10613        scope,
10614        &refreshed_source_watermark,
10615    )?;
10616    let cache = GraphDbBackendEvalFullProjectionCache {
10617        version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
10618        key: write_key.clone(),
10619        source_watermark: refreshed_source_watermark,
10620        projection: projection.clone(),
10621        warnings: warnings.clone(),
10622    };
10623    if let Some((disk_bytes, json_bytes, write_profile)) =
10624        graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
10625    {
10626        cache_stats.disk_bytes = disk_bytes;
10627        cache_stats.json_bytes = json_bytes;
10628        phases.push(graph_db_backend_eval_phase_timing(
10629            "full_projection.cache.serde_encode",
10630            write_profile.serde_encode_micros,
10631            "serde_json serialize the projection cache payload before compression",
10632        ));
10633        phases.push(graph_db_backend_eval_phase_timing(
10634            "full_projection.cache.gzip_encode",
10635            write_profile.gzip_encode_micros,
10636            "gzip-compress the serialized projection cache payload",
10637        ));
10638        phases.push(graph_db_backend_eval_phase_timing(
10639            "full_projection.cache.file_write",
10640            write_profile.file_write_micros,
10641            "write the compressed projection cache bytes to .tsift/backend-eval-cache",
10642        ));
10643    }
10644    let prune_started = Instant::now();
10645    let (pruned_files, pruned_bytes) =
10646        graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
10647    phases.push(graph_db_backend_eval_phase_timing(
10648        "full_projection.cache.prune",
10649        prune_started.elapsed().as_micros(),
10650        "prune sibling cache files older than the current key",
10651    ));
10652    cache_stats.pruned_files = pruned_files;
10653    cache_stats.pruned_bytes = pruned_bytes;
10654    Ok((projection, warnings, phases, cache_stats))
10655}
10656
10657fn graph_db_backend_eval_timed(
10658    name: &str,
10659    run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
10660) -> (
10661    GraphDbBackendEvalOperation,
10662    Option<GraphDbBackendEvalSignature>,
10663) {
10664    let started = Instant::now();
10665    match run() {
10666        Ok((rows, value)) => (
10667            GraphDbBackendEvalOperation {
10668                name: name.to_string(),
10669                supported: true,
10670                status: "ok".to_string(),
10671                duration_micros: started.elapsed().as_micros(),
10672                rows,
10673                error: None,
10674            },
10675            Some(GraphDbBackendEvalSignature {
10676                operation: name.to_string(),
10677                value,
10678            }),
10679        ),
10680        Err(err) => (
10681            GraphDbBackendEvalOperation {
10682                name: name.to_string(),
10683                supported: false,
10684                status: "error".to_string(),
10685                duration_micros: started.elapsed().as_micros(),
10686                rows: None,
10687                error: Some(format!("{err:#}")),
10688            },
10689            None,
10690        ),
10691    }
10692}
10693
10694fn graph_db_backend_eval_parity(
10695    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10696    candidate_signatures: &[GraphDbBackendEvalSignature],
10697) -> GraphDbBackendEvalParity {
10698    let Some(sqlite_signatures) = sqlite_signatures else {
10699        return GraphDbBackendEvalParity {
10700            matches_sqlite: true,
10701            diagnostics: Vec::new(),
10702        };
10703    };
10704    let sqlite = sqlite_signatures
10705        .iter()
10706        .map(|signature| (signature.operation.as_str(), &signature.value))
10707        .collect::<BTreeMap<_, _>>();
10708    let candidate = candidate_signatures
10709        .iter()
10710        .map(|signature| (signature.operation.as_str(), &signature.value))
10711        .collect::<BTreeMap<_, _>>();
10712    let mut diagnostics = Vec::new();
10713    for (operation, sqlite_value) in sqlite {
10714        match candidate.get(operation) {
10715            Some(candidate_value) if *candidate_value == sqlite_value => {}
10716            Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
10717            None => diagnostics.push(format!(
10718                "{operation} did not complete for candidate backend"
10719            )),
10720        }
10721    }
10722    GraphDbBackendEvalParity {
10723        matches_sqlite: diagnostics.is_empty(),
10724        diagnostics,
10725    }
10726}
10727
10728pub(crate) fn graph_db_backend_eval_targets(
10729    store: &impl GraphStore,
10730    requested: &[String],
10731) -> Result<Vec<String>> {
10732    let requested = requested
10733        .iter()
10734        .filter_map(|target| normalize_conflict_target(target))
10735        .collect::<Vec<_>>();
10736    if !requested.is_empty() {
10737        return Ok(requested);
10738    }
10739
10740    for kind in ["backlog", "job_packet"] {
10741        let nodes = store.nodes_by_kind(kind)?;
10742        if let Some(node) = nodes.first() {
10743            if let Some(ref_id) = node.properties.get("ref_id") {
10744                return Ok(vec![ref_id.clone()]);
10745            }
10746            return Ok(vec![node.id.clone()]);
10747        }
10748    }
10749    Ok(Vec::new())
10750}
10751
10752fn graph_db_backend_eval_path_targets(
10753    store: &impl GraphStore,
10754    max_hops: usize,
10755) -> Result<Option<(String, String, usize)>> {
10756    let synthetic_from = "gsym-synthetic-0000";
10757    let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
10758    if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
10759        let outgoing = store.outgoing_edges(synthetic_from, None)?;
10760        if outgoing.len() > 1
10761            && let Some(edge) = outgoing.first()
10762        {
10763            return Ok(Some((
10764                edge.from_id.clone(),
10765                edge.to_id.clone(),
10766                GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10767            )));
10768        }
10769        return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
10770    }
10771
10772    Ok(store.sample_edge(None)?.map(|edge| {
10773        (
10774            edge.from_id,
10775            edge.to_id,
10776            GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10777        )
10778    }))
10779}
10780
10781fn graph_db_backend_eval_path_operation<S: GraphStore>(
10782    store: &S,
10783    configured_max_hops: usize,
10784) -> (
10785    GraphDbBackendEvalOperation,
10786    Option<GraphDbBackendEvalSignature>,
10787) {
10788    let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10789        "path_max_hops".to_string()
10790    } else {
10791        format!("path_max_hops_{configured_max_hops}")
10792    };
10793    graph_db_backend_eval_timed(&operation_name, || {
10794        let (from, to, effective_max_hops) =
10795            graph_db_backend_eval_path_targets(store, configured_max_hops)?
10796                .context("backend-eval path probe requires at least one traversable edge")?;
10797        let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
10798        let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10799            Some(format!(
10800                "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
10801                GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
10802            ))
10803        } else if path.is_none() && effective_max_hops == configured_max_hops {
10804            Some(format!(
10805                "path probe truncated at {configured_max_hops} hops before a route was found"
10806            ))
10807        } else {
10808            None
10809        };
10810        Ok((
10811            path.as_ref().map(|path| path.nodes.len()),
10812            serde_json::json!({
10813                "from": from,
10814                "to": to,
10815                "configured_max_hops": configured_max_hops,
10816                "effective_max_hops": effective_max_hops,
10817                "hops": path.as_ref().map(|path| path.hops),
10818                "nodes": path.as_ref().map(|path| &path.nodes),
10819                "found": path.is_some(),
10820                "warning": warning,
10821            }),
10822        ))
10823    })
10824}
10825
10826fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
10827    store: &S,
10828    depth: usize,
10829    limit: usize,
10830) -> (
10831    GraphDbBackendEvalOperation,
10832    Option<GraphDbBackendEvalSignature>,
10833) {
10834    graph_db_backend_eval_timed("neighborhood", || {
10835        let edge = match store.sample_edge(Some("calls"))? {
10836            Some(edge) => edge,
10837            None => store.sample_edge(None)?.context(
10838                "backend-eval neighborhood probe requires at least one traversable edge",
10839            )?,
10840        };
10841        let page = store
10842            .paged_neighborhood(
10843                &edge.from_id,
10844                depth,
10845                Some(&edge.kind),
10846                GraphQueryOptions {
10847                    limit: Some(limit.max(1)),
10848                    ..GraphQueryOptions::default()
10849                },
10850            )?
10851            .with_context(|| {
10852                format!(
10853                    "backend-eval neighborhood target not found: {}",
10854                    edge.from_id
10855                )
10856            })?;
10857        Ok((
10858            Some(page.nodes.len() + page.edges.len()),
10859            serde_json::json!({
10860                "center": edge.from_id,
10861                "kind": edge.kind,
10862                "depth": depth,
10863                "limit": limit.max(1),
10864                "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10865                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10866                "truncated": page.page.truncated,
10867            }),
10868        ))
10869    })
10870}
10871
10872fn graph_db_backend_eval_related_operation<S: GraphStore>(
10873    root: &Path,
10874    scope: Option<&str>,
10875    store: &S,
10876    depth: usize,
10877    limit: usize,
10878) -> (
10879    GraphDbBackendEvalOperation,
10880    Option<GraphDbBackendEvalSignature>,
10881) {
10882    graph_db_backend_eval_timed("related", || {
10883        let query = "backend evaluation";
10884        let semantic = semantic_related_report_from_store(
10885            root,
10886            scope,
10887            query,
10888            3,
10889            SemanticRelatedKind::All,
10890            store,
10891        )?;
10892        let seed_ids = semantic
10893            .items
10894            .iter()
10895            .map(|item| item.handle.clone())
10896            .collect::<Vec<_>>();
10897        let subgraph =
10898            graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
10899        Ok((
10900            Some(subgraph.nodes.len() + subgraph.edges.len()),
10901            serde_json::json!({
10902                "query": query,
10903                "seed_ids": seed_ids,
10904                "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10905                "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10906                "truncated": subgraph.truncated,
10907                "warnings": semantic.warnings,
10908                "diagnostics": subgraph.diagnostics,
10909            }),
10910        ))
10911    })
10912}
10913
10914fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
10915    serde_json::json!({
10916        "target": report.target,
10917        "target_node_id": report.target_node.id,
10918        "target_kind": report.target_node.kind,
10919        "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
10920        "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
10921        "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
10922        "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
10923        "path_count": report.shortest_paths.len(),
10924    })
10925}
10926
10927fn graph_db_backend_eval_target_resolution_signature(
10928    resolved: &[(String, SubstrateGraphNode)],
10929) -> serde_json::Value {
10930    serde_json::json!({
10931        "targets": resolved.iter().map(|(target, node)| {
10932            serde_json::json!({
10933                "target": target,
10934                "target_node_id": node.id,
10935                "target_kind": node.kind,
10936                "target_label": node.label,
10937            })
10938        }).collect::<Vec<_>>(),
10939    })
10940}
10941
10942fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
10943    serde_json::json!({
10944        "targets": report.targets,
10945        "can_parallel": report.can_parallel,
10946        "fail_closed": report.fail_closed,
10947        "cross_target_parallel_safe": report.cross_target_parallel_safe,
10948        "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
10949        "candidates": report.candidates.iter().map(|candidate| {
10950            serde_json::json!({
10951                "target": candidate.target,
10952                "risk": conflict_risk_label(candidate.risk),
10953                "owned_files": candidate.owned_files,
10954                "owned_symbols": candidate.owned_symbols,
10955                "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
10956                "previously_completed": candidate.previously_completed,
10957                "parallel_safe": candidate.parallel_safe,
10958            })
10959        }).collect::<Vec<_>>(),
10960        "conflicts": report.conflicts.iter().map(|pair| {
10961            serde_json::json!({
10962                "left": pair.left,
10963                "right": pair.right,
10964                "risk": conflict_risk_label(pair.risk),
10965            })
10966        }).collect::<Vec<_>>(),
10967    })
10968}
10969
10970fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
10971    serde_json::json!({
10972        "targets": report.targets,
10973        "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10974        "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
10975        "evidence_packet_ids": report.evidence_packet_ids,
10976        "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
10977        "truncated": report.truncated,
10978    })
10979}
10980
10981fn graph_db_backend_eval_edge_scan_probe(
10982    store: &impl GraphStore,
10983) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
10984    if let Some((edge, filter)) = store.sample_edge_with_property()? {
10985        return Ok((edge, vec![filter]));
10986    }
10987    let edge = store
10988        .sample_edge(None)?
10989        .context("backend-eval edge scan requires at least one edge")?;
10990    Ok((edge, Vec::new()))
10991}
10992
10993#[allow(clippy::too_many_arguments)]
10994fn graph_db_backend_eval_report_for_store<S: GraphStore>(
10995    backend: &str,
10996    adapter: &str,
10997    read_only: bool,
10998    root: &Path,
10999    path: &Path,
11000    scope: Option<&str>,
11001    targets: &[String],
11002    depth: usize,
11003    limit: usize,
11004    impact_limit: usize,
11005    store: &S,
11006    freshness: GraphDbFreshnessReport,
11007    refresh_operation: GraphDbBackendEvalOperation,
11008    refresh_signature: Option<GraphDbBackendEvalSignature>,
11009    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11010    extra_warnings: Vec<String>,
11011    prepared: &ConflictMatrixPreparedInputs,
11012    projection_load: &str,
11013    lock_behavior: &str,
11014    install_portability: &str,
11015) -> (
11016    GraphDbBackendEvalBackendReport,
11017    Vec<GraphDbBackendEvalSignature>,
11018) {
11019    let mut operations = vec![refresh_operation];
11020    let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
11021
11022    let (operation, signature) = graph_db_backend_eval_timed("status", || {
11023        let (nodes, edges) = store.graph_counts()?;
11024        Ok((
11025            Some(nodes + edges),
11026            serde_json::json!({
11027                "freshness": freshness.status,
11028                "nodes": nodes,
11029                "edges": edges,
11030            }),
11031        ))
11032    });
11033    operations.push(operation);
11034    signatures.extend(signature);
11035
11036    let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
11037        let edge = store
11038            .sample_edge(None)?
11039            .context("backend-eval edge lookup requires at least one edge")?;
11040        let edge_id = graph_db_edge_key(&edge);
11041        let found = store
11042            .edge(&edge_id)?
11043            .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
11044        Ok((
11045            Some(1),
11046            serde_json::json!({
11047                "edge_id": edge_id,
11048                "from_id": found.from_id,
11049                "to_id": found.to_id,
11050                "kind": found.kind,
11051            }),
11052        ))
11053    });
11054    operations.push(operation);
11055    signatures.extend(signature);
11056
11057    let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
11058        let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
11059        let page = store.paged_edges(
11060            Some(&edge.kind),
11061            GraphQueryOptions {
11062                limit: Some(limit.max(1)),
11063                property_filters: filters.clone(),
11064                ..GraphQueryOptions::default()
11065            },
11066        )?;
11067        Ok((
11068            Some(page.edges.len()),
11069            serde_json::json!({
11070                "kind": edge.kind,
11071                "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
11072                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11073                "truncated": page.page.truncated,
11074            }),
11075        ))
11076    });
11077    operations.push(operation);
11078    signatures.extend(signature);
11079
11080    let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
11081        let edge = store
11082            .sample_edge(None)?
11083            .context("backend-eval incident edge scan requires at least one edge")?;
11084        let page = store.paged_incident_edges(
11085            &edge.from_id,
11086            Some(&edge.kind),
11087            GraphQueryOptions {
11088                limit: Some(limit.max(1)),
11089                ..GraphQueryOptions::default()
11090            },
11091        )?;
11092        Ok((
11093            Some(page.edges.len()),
11094            serde_json::json!({
11095                "node_id": edge.from_id,
11096                "kind": edge.kind,
11097                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11098                "truncated": page.page.truncated,
11099            }),
11100        ))
11101    });
11102    operations.push(operation);
11103    signatures.extend(signature);
11104
11105    let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
11106    operations.push(operation);
11107    signatures.extend(signature);
11108
11109    let (operation, signature) =
11110        graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
11111    operations.push(operation);
11112    signatures.extend(signature);
11113
11114    for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
11115        .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
11116    {
11117        let (operation, signature) =
11118            graph_db_backend_eval_path_operation(store, configured_max_hops);
11119        operations.push(operation);
11120        signatures.extend(signature);
11121    }
11122
11123    let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
11124        let resolved = targets
11125            .iter()
11126            .map(|target| {
11127                let node = graph_db_resolve_evidence_target(store, target)?
11128                    .with_context(|| format!("backend-eval target not found: {target}"))?;
11129                Ok((target.clone(), node))
11130            })
11131            .collect::<Result<Vec<_>>>()?;
11132        let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
11133        Ok((Some(resolved.len()), signature))
11134    });
11135    operations.push(operation);
11136    signatures.extend(signature);
11137
11138    let mut evidence_for_report = None;
11139    let mut graph_snapshot_for_trace = None;
11140    let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
11141        let resolved_targets =
11142            resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
11143        let evidence = collect_conflict_matrix_evidence_packets(
11144            root,
11145            scope,
11146            backend,
11147            &resolved_targets,
11148            depth,
11149            limit,
11150            store,
11151            freshness.clone(),
11152        )?;
11153        let report = &evidence
11154            .first()
11155            .context("backend-eval evidence requires at least one target")?
11156            .report;
11157        let rows = evidence
11158            .iter()
11159            .map(|entry| {
11160                entry.report.worker_context.len()
11161                    + entry.report.source_handles.len()
11162                    + entry.report.worker_results.len()
11163                    + entry.report.semantic_related.len()
11164            })
11165            .sum();
11166        let signature = graph_db_backend_eval_evidence_signature(report);
11167        evidence_for_report = Some((resolved_targets, evidence));
11168        Ok((Some(rows), signature))
11169    });
11170    operations.push(operation);
11171    signatures.extend(signature);
11172
11173    let mut conflict_for_trace = None;
11174    let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
11175        let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
11176            let graph =
11177                conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
11178            let shared_preparation =
11179                conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
11180            ConflictMatrixGraphPreparedInputs {
11181                targets,
11182                graph,
11183                evidence,
11184                shared_preparation,
11185            }
11186        } else {
11187            prepare_conflict_matrix_graph_orchestration(
11188                root,
11189                scope,
11190                backend,
11191                targets,
11192                prepared,
11193                depth,
11194                limit,
11195                store,
11196                freshness.clone(),
11197            )?
11198        };
11199        let report = build_conflict_matrix_report_from_prepared_graph(
11200            root,
11201            path,
11202            scope,
11203            depth,
11204            limit,
11205            impact_limit,
11206            freshness.clone(),
11207            extra_warnings.clone(),
11208            prepared,
11209            &graph_prepared,
11210        )?;
11211        let signature = graph_db_backend_eval_conflict_signature(&report);
11212        let rows = report.candidates.len() + report.conflicts.len();
11213        conflict_for_trace = Some(report);
11214        graph_snapshot_for_trace = Some(graph_prepared.graph);
11215        Ok((Some(rows), signature))
11216    });
11217    operations.push(operation);
11218    signatures.extend(signature);
11219
11220    let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11221        let conflict = conflict_for_trace
11222            .take()
11223            .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11224        let graph = graph_snapshot_for_trace
11225            .take()
11226            .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11227        let report = build_dispatch_trace_report_from_conflict_snapshot(
11228            root,
11229            scope,
11230            conflict,
11231            graph.nodes,
11232            graph.edges,
11233            depth,
11234            limit,
11235            Vec::new(),
11236        )?;
11237        Ok((
11238            Some(report.nodes.len() + report.edges.len()),
11239            graph_db_backend_eval_dispatch_signature(&report),
11240        ))
11241    });
11242    operations.push(operation);
11243    signatures.extend(signature);
11244
11245    let total_micros = operations
11246        .iter()
11247        .map(|operation| operation.duration_micros)
11248        .sum();
11249    let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11250    (
11251        GraphDbBackendEvalBackendReport {
11252            backend: backend.to_string(),
11253            adapter: adapter.to_string(),
11254            read_only,
11255            projection_load: projection_load.to_string(),
11256            operations,
11257            total_micros,
11258            parity,
11259            lock_behavior: lock_behavior.to_string(),
11260            install_portability: install_portability.to_string(),
11261        },
11262        signatures,
11263    )
11264}
11265
11266pub(crate) fn graph_db_backend_eval_refresh_operation(
11267    duration_micros: u128,
11268    rows: usize,
11269    value: serde_json::Value,
11270) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11271    (
11272        GraphDbBackendEvalOperation {
11273            name: "refresh".to_string(),
11274            supported: true,
11275            status: "ok".to_string(),
11276            duration_micros,
11277            rows: Some(rows),
11278            error: None,
11279        },
11280        GraphDbBackendEvalSignature {
11281            operation: "refresh".to_string(),
11282            value,
11283        },
11284    )
11285}
11286
11287pub(crate) fn graph_db_backend_eval_synthetic_projection(
11288    nodes: usize,
11289    fanout: usize,
11290) -> GraphProjection {
11291    let nodes = nodes.max(12);
11292    let symbol_count = nodes.saturating_sub(9).max(1);
11293    let source = GraphProvenance::new("backend-eval", "synthetic");
11294    let mut projection_nodes = vec![
11295        SubstrateGraphNode::new(
11296            "projection:tsift-traversal:synthetic",
11297            GRAPH_PROJECTION_META_KIND,
11298            "synthetic projection",
11299        )
11300        .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11301        .with_property(
11302            "content_hash",
11303            format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11304        )
11305        .with_provenance(source.clone()),
11306        SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11307            .with_property("ref_id", "synthetic-session"),
11308        SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11309            .with_property("ref_id", "synthetic")
11310            .with_property("path", "tasks/software/synthetic.md")
11311            .with_property("line", "1")
11312            .with_property(
11313                "expand",
11314                "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11315            ),
11316        SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11317            .with_property("ref_id", "synthetic"),
11318        SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11319            .with_property("target", "synthetic")
11320            .with_property("summary", "Synthetic worker owns synthetic.rs")
11321            .with_property(
11322                "expand",
11323                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11324            ),
11325        SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11326            .with_property("file", "synthetic.rs")
11327            .with_property("start", "1")
11328            .with_property("end", "80")
11329            .with_property(
11330                "expand",
11331                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11332            ),
11333        SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11334            .with_property("path", "synthetic.rs"),
11335        SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11336            .with_property("handle", "gsem-synthetic")
11337            .with_property("label", "backend evaluation")
11338            .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11339            .with_property(
11340                "embedding",
11341                semantic_embedding_property("backend evaluation"),
11342            ),
11343        SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11344            .with_property("ref_id", "synthetic")
11345            .with_property("status", "completed")
11346            .with_property("touched_files", "synthetic.rs")
11347            .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11348    ];
11349    for idx in 0..symbol_count {
11350        projection_nodes.push(
11351            SubstrateGraphNode::new(
11352                format!("gsym-synthetic-{idx:04}"),
11353                "symbol",
11354                format!("synthetic_symbol_{idx:04}"),
11355            )
11356            .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11357            .with_property("path", "synthetic.rs")
11358            .with_property("line", (idx + 1).to_string()),
11359        );
11360    }
11361
11362    let mut projection_edges = vec![
11363        SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11364        SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11365        SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11366        SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11367        SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11368        SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11369        SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11370        SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11371        SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11372    ];
11373    for idx in 0..symbol_count {
11374        let from = format!("gsym-synthetic-{idx:04}");
11375        for offset in 1..=fanout.max(1).min(symbol_count) {
11376            let to_idx = (idx + offset) % symbol_count;
11377            if to_idx != idx {
11378                projection_edges.push(SubstrateGraphEdge::new(
11379                    from.clone(),
11380                    format!("gsym-synthetic-{to_idx:04}"),
11381                    "calls",
11382                ));
11383            }
11384        }
11385    }
11386
11387    GraphProjection {
11388        nodes: projection_nodes,
11389        edges: projection_edges
11390            .into_iter()
11391            .map(|edge| {
11392                edge.with_property("dataset", "synthetic")
11393                    .with_provenance(source.clone())
11394            })
11395            .collect(),
11396    }
11397}
11398
11399pub(crate) fn graph_db_backend_eval_promotion(
11400    datasets: &[GraphDbBackendEvalDataset],
11401    candidates: &[GraphDbExperimentalBackend],
11402) -> Vec<GraphDbBackendPromotionDecision> {
11403    let mut decisions = Vec::new();
11404    for candidate in candidates {
11405        let mut reasons = Vec::new();
11406        let mut faster_everywhere = true;
11407        let mut parity_everywhere = true;
11408        for dataset in datasets {
11409            let Some(sqlite_report) = dataset
11410                .backends
11411                .iter()
11412                .find(|backend| backend.backend == "sqlite")
11413            else {
11414                parity_everywhere = false;
11415                faster_everywhere = false;
11416                reasons.push(format!(
11417                    "{} dataset is missing SQLite baseline",
11418                    dataset.name
11419                ));
11420                continue;
11421            };
11422            let sqlite_total = sqlite_report.total_micros;
11423            let Some(candidate_report) = dataset
11424                .backends
11425                .iter()
11426                .find(|backend| backend.backend == candidate.name())
11427            else {
11428                parity_everywhere = false;
11429                reasons.push(format!("{} dataset did not run", dataset.name));
11430                continue;
11431            };
11432            if !candidate_report.parity.matches_sqlite {
11433                parity_everywhere = false;
11434                reasons.push(format!("{} parity differed from SQLite", dataset.name));
11435            }
11436            if candidate_report.total_micros >= sqlite_total {
11437                faster_everywhere = false;
11438                reasons.push(format!(
11439                    "{} total {}us did not beat SQLite {}us",
11440                    dataset.name, candidate_report.total_micros, sqlite_total
11441                ));
11442            }
11443            let sqlite_operations = sqlite_report
11444                .operations
11445                .iter()
11446                .map(|operation| (operation.name.as_str(), operation.duration_micros))
11447                .collect::<BTreeMap<_, _>>();
11448            for operation in &candidate_report.operations {
11449                if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
11450                    && operation.duration_micros >= *sqlite_duration
11451                {
11452                    faster_everywhere = false;
11453                    reasons.push(format!(
11454                        "{} {} operation {}us did not beat SQLite {}us",
11455                        dataset.name, operation.name, operation.duration_micros, sqlite_duration
11456                    ));
11457                }
11458            }
11459            if candidate_report
11460                .operations
11461                .iter()
11462                .any(|operation| operation.status != "ok")
11463            {
11464                parity_everywhere = false;
11465                reasons.push(format!("{} has failed benchmark operations", dataset.name));
11466            }
11467        }
11468        let decision = if let Some(reason) = candidate.prototype_hold_reason() {
11469            reasons.push(reason.to_string());
11470            reasons.push(
11471                "current bounded prototype timings are benchmark evidence, not a backend switch approval"
11472                    .to_string(),
11473            );
11474            "hold"
11475        } else if parity_everywhere && faster_everywhere {
11476            reasons.push(
11477                "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
11478                    .to_string(),
11479            );
11480            "eligible"
11481        } else {
11482            reasons.push(
11483                "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
11484                    .to_string(),
11485            );
11486            "hold"
11487        };
11488        decisions.push(GraphDbBackendPromotionDecision {
11489            backend: candidate.name().to_string(),
11490            decision: decision.to_string(),
11491            reasons: dedupe_preserve_order(reasons),
11492            gate: candidate.promotion_gate(),
11493        });
11494    }
11495    decisions
11496}
11497
11498pub(crate) fn graph_db_backend_eval_metrics(
11499    datasets: &[GraphDbBackendEvalDataset],
11500) -> BTreeMap<String, f64> {
11501    let mut metrics = BTreeMap::new();
11502    for dataset in datasets {
11503        let graph_rows = graph_db_backend_eval_graph_rows(dataset);
11504        metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
11505        metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
11506        metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
11507        for backend in &dataset.backends {
11508            let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
11509            metrics.insert(
11510                format!("{prefix}.total_duration_micros"),
11511                backend.total_micros as f64,
11512            );
11513            append_graph_db_backend_eval_normalized_duration_metric(
11514                &mut metrics,
11515                &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
11516                backend.total_micros,
11517                graph_rows,
11518            );
11519            for operation in &backend.operations {
11520                metrics.insert(
11521                    format!("{prefix}.{}.duration_micros", operation.name),
11522                    operation.duration_micros as f64,
11523                );
11524                append_graph_db_backend_eval_normalized_duration_metric(
11525                    &mut metrics,
11526                    &format!(
11527                        "{prefix}.{}.duration_micros_per_1k_graph_rows",
11528                        operation.name
11529                    ),
11530                    operation.duration_micros,
11531                    graph_rows,
11532                );
11533                if let Some(rows) = operation.rows {
11534                    metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
11535                }
11536            }
11537        }
11538    }
11539    metrics
11540}
11541
11542pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
11543    dataset.nodes + dataset.edges
11544}
11545
11546pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
11547    metrics: &mut BTreeMap<String, f64>,
11548    key: &str,
11549    duration_micros: u128,
11550    graph_rows: usize,
11551) {
11552    if graph_rows == 0 {
11553        return;
11554    }
11555    metrics.insert(
11556        key.to_string(),
11557        duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
11558    );
11559}
11560
11561pub(crate) fn append_graph_db_backend_eval_phase_metrics(
11562    metrics: &mut BTreeMap<String, f64>,
11563    dataset: &str,
11564    graph_rows: usize,
11565    phases: &[GraphDbBackendEvalPhaseTiming],
11566) {
11567    for phase in phases {
11568        metrics.insert(
11569            format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
11570            phase.duration_micros as f64,
11571        );
11572        append_graph_db_backend_eval_normalized_duration_metric(
11573            metrics,
11574            &format!(
11575                "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
11576                phase.name
11577            ),
11578            phase.duration_micros,
11579            graph_rows,
11580        );
11581    }
11582}
11583
11584fn graph_db_backend_eval_base_command(
11585    root: &Path,
11586    scope: Option<&str>,
11587    full_projection: bool,
11588) -> String {
11589    let full_projection_arg = if full_projection {
11590        " --full-projection"
11591    } else {
11592        ""
11593    };
11594    format!(
11595        "tsift graph-db --path {}{} --json backend-eval{}",
11596        shell_quote(root.to_string_lossy().as_ref()),
11597        graph_db_scope_arg(scope),
11598        full_projection_arg
11599    )
11600}
11601
11602pub(crate) fn graph_db_backend_eval_metric_digest_command(
11603    root: &Path,
11604    scope: Option<&str>,
11605    full_projection: bool,
11606) -> String {
11607    format!(
11608        "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11609        graph_db_backend_eval_base_command(root, scope, full_projection)
11610    )
11611}
11612
11613fn graph_db_backend_eval_repeated_sample_command(
11614    root: &Path,
11615    scope: Option<&str>,
11616    full_projection: bool,
11617) -> String {
11618    format!(
11619        "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11620        graph_db_backend_eval_base_command(root, scope, full_projection)
11621    )
11622}
11623
11624fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
11625    let mut required_metrics = Vec::new();
11626    for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
11627        required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
11628        required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
11629        for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
11630            required_metrics.push(format!(
11631                "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
11632            ));
11633            required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
11634        }
11635    }
11636    GraphDbHopCapPromotionGate {
11637        status: "hold_64_default_until_gate_passes".to_string(),
11638        current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
11639        candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
11640        required_backend: perf_gate::BASELINE_BACKEND.to_string(),
11641        required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
11642            .iter()
11643            .map(|workload| (*workload).to_string())
11644            .collect(),
11645        required_metrics,
11646        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11647        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11648        decision_rule:
11649            "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"
11650                .to_string(),
11651    }
11652}
11653
11654fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
11655    let candidate_backends = [
11656        GraphDbExperimentalBackend::Falkordb,
11657        GraphDbExperimentalBackend::Kuzu,
11658        GraphDbExperimentalBackend::Surrealdb,
11659    ]
11660    .into_iter()
11661    .map(|backend| GraphDbBackendAdapterSpikeCandidate {
11662        backend: backend.name().to_string(),
11663        adapter_label: backend.adapter_label().to_string(),
11664        projection_load: backend.projection_load().to_string(),
11665        lock_behavior: backend.lock_behavior().to_string(),
11666        install_portability: backend.install_portability().to_string(),
11667    })
11668    .collect();
11669
11670    GraphDbBackendAdapterSpikeGate {
11671        status: "hold_real_optional_adapter_required".to_string(),
11672        candidate_backends,
11673        required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
11674            .iter()
11675            .map(|workload| (*workload).to_string())
11676            .collect(),
11677        required_checks: vec![
11678            "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
11679            "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
11680            "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
11681            "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
11682            "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
11683                .to_string(),
11684            "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
11685            "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
11686        ],
11687        decision_rule:
11688            "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"
11689                .to_string(),
11690        evidence_plan: "plans/gback-evidence.md".to_string(),
11691    }
11692}
11693
11694pub(crate) fn graph_db_backend_eval_performance_gate(
11695    root: &Path,
11696    scope: Option<&str>,
11697    full_projection: bool,
11698) -> GraphDbBackendEvalPerformanceGate {
11699    let mut required_metrics = vec![
11700        "real.sqlite.refresh.duration_micros".to_string(),
11701        "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
11702        "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
11703        "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
11704        "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11705        "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11706        "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
11707        "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
11708        "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11709        "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
11710        "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
11711        "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
11712        "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
11713        "real.sqlite.conflict_matrix.duration_micros".to_string(),
11714        "real.sqlite.dispatch_trace.duration_micros".to_string(),
11715        "real.sqlite.path_max_hops.duration_micros".to_string(),
11716        "real.sqlite.path_max_hops_128.duration_micros".to_string(),
11717        "real.sqlite.path_max_hops_256.duration_micros".to_string(),
11718        "real.sqlite.path_max_hops_512.duration_micros".to_string(),
11719        "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
11720        "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
11721        "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
11722        "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
11723        "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11724        "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11725        "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
11726            .to_string(),
11727        "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11728            .to_string(),
11729        "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11730        "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11731        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
11732        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
11733        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
11734        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
11735        "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11736            .to_string(),
11737        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
11738        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
11739            .to_string(),
11740        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
11741            .to_string(),
11742        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
11743            .to_string(),
11744    ];
11745    if full_projection {
11746        required_metrics.extend([
11747            "full_projection.cache.hit".to_string(),
11748            "full_projection.cache.disk_bytes".to_string(),
11749            "full_projection.cache.compression_ratio".to_string(),
11750            "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
11751            "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11752            "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
11753                .to_string(),
11754            "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
11755                .to_string(),
11756            "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
11757            "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
11758            "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
11759            "full_projection.sqlite.neighborhood.duration_micros".to_string(),
11760            "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
11761            "full_projection.sqlite.evidence.duration_micros".to_string(),
11762            "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
11763            "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
11764            "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
11765            "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
11766            "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
11767            "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
11768        ]);
11769    }
11770    GraphDbBackendEvalPerformanceGate {
11771        baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
11772        ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
11773            .to_string(),
11774        opt_in_real_profile:
11775            "pass --full-projection to add the full-project dataset when checking for large projection regressions"
11776                .to_string(),
11777        full_projection_cache_hit_gate: if full_projection {
11778            "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"
11779                .to_string()
11780        } else {
11781            "not evaluated until --full-projection is enabled".to_string()
11782        },
11783        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11784        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11785        normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
11786        required_metrics,
11787        digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
11788        repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
11789            root,
11790            scope,
11791            full_projection,
11792        ),
11793        hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
11794        backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
11795    }
11796}
11797
11798#[cfg(feature = "backend-surrealdb")]
11799fn graph_db_backend_eval_path_segment(value: &str) -> String {
11800    value
11801        .chars()
11802        .map(|ch| {
11803            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
11804                ch
11805            } else {
11806                '_'
11807            }
11808        })
11809        .collect()
11810}
11811
11812#[cfg(feature = "backend-surrealdb")]
11813fn graph_db_backend_eval_surrealdb_store_path(
11814    root: &Path,
11815    scope: Option<&str>,
11816    dataset: &str,
11817) -> PathBuf {
11818    root.join(".tsift/backend-eval-cache/surrealdb")
11819        .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
11820        .join(graph_db_backend_eval_path_segment(dataset))
11821        .join("surrealkv")
11822}
11823
11824pub(crate) struct GraphDbBackendEvalOptions<'a> {
11825    path: &'a Path,
11826    scope: Option<&'a str>,
11827    candidates: &'a [String],
11828    targets: &'a [String],
11829    full_projection: bool,
11830}
11831
11832#[allow(clippy::too_many_arguments)]
11833pub(crate) fn graph_db_backend_eval_dataset(
11834    name: &str,
11835    root: &Path,
11836    path: &Path,
11837    scope: Option<&str>,
11838    targets: &[String],
11839    depth: usize,
11840    limit: usize,
11841    impact_limit: usize,
11842    candidates: &[GraphDbExperimentalBackend],
11843    sqlite_store: &SqliteGraphStore,
11844    sqlite_freshness: GraphDbFreshnessReport,
11845    sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
11846    sqlite_rows: ConvexProjectionRows,
11847    extra_warnings: Vec<String>,
11848    prepared: &ConflictMatrixPreparedInputs,
11849) -> Result<GraphDbBackendEvalDataset> {
11850    let (nodes, edges) = sqlite_store.graph_counts()?;
11851    let (sqlite_operation, sqlite_signature) = sqlite_refresh;
11852    let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
11853        "sqlite",
11854        "SQLite GraphStore correctness baseline",
11855        false,
11856        root,
11857        path,
11858        scope,
11859        targets,
11860        depth,
11861        limit,
11862        impact_limit,
11863        sqlite_store,
11864        sqlite_freshness,
11865        sqlite_operation,
11866        Some(sqlite_signature),
11867        None,
11868        extra_warnings.clone(),
11869        prepared,
11870        "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
11871        "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
11872        "bundled rusqlite baseline; no external service or runtime required",
11873    );
11874
11875    let mut backends = vec![sqlite_report];
11876    for candidate in candidates {
11877        #[cfg(feature = "backend-surrealdb")]
11878        if *candidate == GraphDbExperimentalBackend::Surrealdb {
11879            let started = Instant::now();
11880            let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
11881            let (store, warm_start) =
11882                SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
11883            let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11884            let rows = candidate_nodes + candidate_edges;
11885            let mut refresh_meta = serde_json::json!({
11886                "nodes": candidate_nodes,
11887                "edges": candidate_edges,
11888            });
11889            if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
11890                refresh_meta["warm_start"] = serde_json::json!("cache_hit");
11891            }
11892            let refresh = graph_db_backend_eval_refresh_operation(
11893                started.elapsed().as_micros(),
11894                rows,
11895                refresh_meta,
11896            );
11897            let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11898            let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11899                candidate.name(),
11900                "SurrealDB SurrealKV optional adapter spike",
11901                false,
11902                root,
11903                path,
11904                scope,
11905                targets,
11906                depth,
11907                limit,
11908                impact_limit,
11909                &store,
11910                freshness,
11911                refresh.0,
11912                Some(refresh.1),
11913                Some(&sqlite_signatures),
11914                extra_warnings.clone(),
11915                prepared,
11916                "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",
11917                "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
11918                "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
11919            );
11920            backends.push(candidate_report);
11921            continue;
11922        }
11923        let started = Instant::now();
11924        let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
11925        let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11926        let rows = candidate_nodes + candidate_edges;
11927        let refresh = graph_db_backend_eval_refresh_operation(
11928            started.elapsed().as_micros(),
11929            rows,
11930            serde_json::json!({
11931                "nodes": candidate_nodes,
11932                "edges": candidate_edges,
11933            }),
11934        );
11935        let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11936        let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11937            candidate.name(),
11938            candidate.adapter_label(),
11939            true,
11940            root,
11941            path,
11942            scope,
11943            targets,
11944            depth,
11945            limit,
11946            impact_limit,
11947            &store,
11948            freshness,
11949            refresh.0,
11950            Some(refresh.1),
11951            Some(&sqlite_signatures),
11952            extra_warnings.clone(),
11953            prepared,
11954            candidate.projection_load(),
11955            candidate.lock_behavior(),
11956            candidate.install_portability(),
11957        );
11958        backends.push(candidate_report);
11959    }
11960
11961    Ok(GraphDbBackendEvalDataset {
11962        name: name.to_string(),
11963        target_count: targets.len(),
11964        nodes,
11965        edges,
11966        backends,
11967    })
11968}
11969
11970pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
11971    println!(
11972        "graph-db backend-eval baseline:{} candidates:{}",
11973        report.baseline_backend,
11974        report.candidates.join(", ")
11975    );
11976    for phase in &report.phase_timings {
11977        println!(
11978            "phase:{} {}us {}",
11979            phase.name, phase.duration_micros, phase.detail
11980        );
11981    }
11982    for dataset in &report.datasets {
11983        println!(
11984            "dataset:{} targets:{} rows:{}",
11985            dataset.name,
11986            dataset.target_count,
11987            dataset.nodes + dataset.edges
11988        );
11989        for backend in &dataset.backends {
11990            println!(
11991                "  backend:{} total:{}us parity:{}",
11992                backend.backend, backend.total_micros, backend.parity.matches_sqlite
11993            );
11994            println!("    projection-load: {}", backend.projection_load);
11995            println!("    lock-behavior: {}", backend.lock_behavior);
11996            println!("    install-portability: {}", backend.install_portability);
11997            for operation in &backend.operations {
11998                println!(
11999                    "    {} {} {}us",
12000                    operation.name, operation.status, operation.duration_micros
12001                );
12002            }
12003            for diagnostic in &backend.parity.diagnostics {
12004                println!("    parity: {diagnostic}");
12005            }
12006        }
12007    }
12008    for decision in &report.promotion {
12009        println!("promotion {}: {}", decision.backend, decision.decision);
12010        println!("  gate: {}", decision.gate.status);
12011        for reason in &decision.reasons {
12012            println!("  reason: {reason}");
12013        }
12014        for check in &decision.gate.required_checks {
12015            println!("  check: {check}");
12016        }
12017    }
12018    println!("metric-digest: {}", report.metric_digest_command);
12019    println!(
12020        "repeat-samples: {}",
12021        report.performance_gate.repeated_sample_command
12022    );
12023}
12024
12025fn traversal_expand_command(root: &Path, handle: &str) -> String {
12026    format!(
12027        "tsift traverse {} --path {} --depth 1 --limit 50",
12028        shell_quote(handle),
12029        shell_quote(root.to_string_lossy().as_ref())
12030    )
12031}
12032
12033fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
12034    let display = relativize(file, root);
12035    let handle = stable_handle("gfil", &format!("file:{display}"));
12036    TraversalNode {
12037        handle: handle.clone(),
12038        kind: "file".to_string(),
12039        label: display.clone(),
12040        ref_id: Some(display.clone()),
12041        path: Some(display),
12042        line: None,
12043        detail: None,
12044        properties: BTreeMap::new(),
12045        expand: traversal_expand_command(root, &handle),
12046    }
12047}
12048
12049fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
12050    let mut node = traversal_file_node(root, file);
12051    if let Some(path) = node.path.clone() {
12052        node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
12053        node.expand = source_read_command(root, &path, 1, 80);
12054    }
12055    node
12056}
12057
12058fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
12059    let file = relativize(&symbol.file, root);
12060    let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
12061    let handle = stable_handle("gsym", &key);
12062    TraversalNode {
12063        handle: handle.clone(),
12064        kind: "symbol".to_string(),
12065        label: symbol.name.clone(),
12066        ref_id: Some(symbol.name.clone()),
12067        path: Some(file),
12068        line: Some(symbol.line),
12069        detail: Some(format!("{} {}", symbol.language, symbol.kind)),
12070        properties: BTreeMap::new(),
12071        expand: traversal_expand_command(root, &handle),
12072    }
12073}
12074
12075fn traversal_ast_span_expand_command(
12076    root: &Path,
12077    file: &str,
12078    symbol: &index::StoredSymbol,
12079    span: &AstSpanPreview,
12080) -> String {
12081    if symbol.language == "markdown" {
12082        markdown_ast_command(root, file, Some(&span.handle))
12083    } else {
12084        let line_count = span
12085            .end_line
12086            .saturating_sub(span.start_line)
12087            .saturating_add(1)
12088            .max(1);
12089        source_read_command(root, file, span.start_line, line_count)
12090    }
12091}
12092
12093fn traversal_ast_span_node(
12094    root: &Path,
12095    symbol: &index::StoredSymbol,
12096    source: &[u8],
12097    symbols: &[index::StoredSymbol],
12098) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
12099    let span = stored_symbol_ast_span(symbol, source, symbols, usize::MAX)?;
12100    let file = relativize(&symbol.file, root);
12101    let mut properties = BTreeMap::new();
12102    properties.insert("layer".to_string(), "ast_navigation".to_string());
12103    properties.insert("language".to_string(), symbol.language.clone());
12104    properties.insert("symbol_kind".to_string(), symbol.kind.clone());
12105    properties.insert("node_kind".to_string(), span.node_kind.clone());
12106    properties.insert("start_byte".to_string(), span.start_byte.to_string());
12107    properties.insert("end_byte".to_string(), span.end_byte.to_string());
12108    properties.insert("end_line".to_string(), span.end_line.to_string());
12109    if let Some(body_start_byte) = span.body_start_byte {
12110        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12111    }
12112    if let Some(body_end_byte) = span.body_end_byte {
12113        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12114    }
12115    if let Some(body_start_line) = span.body_start_line {
12116        properties.insert("body_start_line".to_string(), body_start_line.to_string());
12117    }
12118    if let Some(body_end_line) = span.body_end_line {
12119        properties.insert("body_end_line".to_string(), body_end_line.to_string());
12120    }
12121    if let Some(parent_handle) = &span.parent_handle {
12122        properties.insert("parent_handle".to_string(), parent_handle.clone());
12123    }
12124    if !span.child_handles.is_empty() {
12125        properties.insert("child_handles".to_string(), span.child_handles.join(","));
12126    }
12127    if let Some(parent_module) = &symbol.parent_module {
12128        properties.insert("parent_module".to_string(), parent_module.clone());
12129    }
12130    if let Some(markdown) = &span.markdown {
12131        properties.insert(
12132            "markdown_block_kind".to_string(),
12133            markdown_ast_block_kind(&symbol.kind),
12134        );
12135        if let Some(heading_level) = markdown.heading_level {
12136            properties.insert("heading_level".to_string(), heading_level.to_string());
12137        }
12138        if !markdown.section_path.is_empty() {
12139            properties.insert(
12140                "section_path".to_string(),
12141                markdown.section_path.join(" > "),
12142            );
12143        }
12144        if let Some(section_handle) = &markdown.section_handle {
12145            properties.insert("section_handle".to_string(), section_handle.clone());
12146        }
12147        if let Some(list_depth) = markdown.list_depth {
12148            properties.insert("list_depth".to_string(), list_depth.to_string());
12149        }
12150        if let Some(fence_language) = &markdown.fence_language {
12151            properties.insert("fence_language".to_string(), fence_language.clone());
12152        }
12153    }
12154
12155    let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
12156    let node = TraversalNode {
12157        handle: span.handle.clone(),
12158        kind: "ast_span".to_string(),
12159        label: symbol.name.clone(),
12160        ref_id: Some(symbol.name.clone()),
12161        path: Some(file.clone()),
12162        line: Some(line),
12163        detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
12164        properties,
12165        expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
12166    };
12167    let entry = TraversalAstSpanIndexEntry {
12168        handle: span.handle,
12169        symbol_handle: String::new(),
12170        file_handle: None,
12171        file,
12172        name: symbol.name.clone(),
12173        kind: symbol.kind.clone(),
12174        language: symbol.language.clone(),
12175        node_kind: span.node_kind,
12176        start_byte: span.start_byte,
12177        end_byte: span.end_byte,
12178        parent_module: symbol.parent_module.clone(),
12179        markdown: span.markdown,
12180    };
12181    Some((node, entry))
12182}
12183
12184fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
12185    let handle = stable_handle("gsym", &format!("symbol:{name}"));
12186    TraversalNode {
12187        handle: handle.clone(),
12188        kind: "symbol".to_string(),
12189        label: name.to_string(),
12190        ref_id: Some(name.to_string()),
12191        path: None,
12192        line: None,
12193        detail: Some("unresolved call target".to_string()),
12194        properties: BTreeMap::new(),
12195        expand: traversal_expand_command(root, &handle),
12196    }
12197}
12198
12199fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12200    let file = relativize(&route.file, root);
12201    let method = route.method.as_deref().unwrap_or("any");
12202    let key = format!(
12203        "route:{file}:{}:{}:{}",
12204        route.line, method, route.route_path
12205    );
12206    let handle = stable_handle("grte", &key);
12207    TraversalNode {
12208        handle: handle.clone(),
12209        kind: "route".to_string(),
12210        label: format!("{} {}", method.to_uppercase(), route.route_path),
12211        ref_id: Some(route.route_path.clone()),
12212        path: Some(file),
12213        line: Some(route.line),
12214        detail: Some(format!(
12215            "{} route handled by {}",
12216            route.framework, route.handler_name
12217        )),
12218        properties: BTreeMap::new(),
12219        expand: traversal_expand_command(root, &handle),
12220    }
12221}
12222
12223fn traversal_cargo_workspace_node(
12224    root: &Path,
12225    workspace: &multiplicity::CargoWorkspaceInfo,
12226) -> TraversalNode {
12227    let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12228        .to_string_lossy()
12229        .replace('\\', "/");
12230    let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12231        .to_string_lossy()
12232        .replace('\\', "/");
12233    let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12234    let mut properties = BTreeMap::new();
12235    properties.insert("layer".to_string(), "cargo_workspace".to_string());
12236    properties.insert("workspace_root".to_string(), workspace_root.clone());
12237    properties.insert("members".to_string(), workspace.members.join(","));
12238    properties.insert(
12239        "default_members".to_string(),
12240        workspace.default_members.join(","),
12241    );
12242    TraversalNode {
12243        handle: handle.clone(),
12244        kind: "cargo_workspace".to_string(),
12245        label: if workspace_root.is_empty() {
12246            "root cargo workspace".to_string()
12247        } else {
12248            workspace_root
12249        },
12250        ref_id: Some(workspace.id.clone()),
12251        path: Some(manifest),
12252        line: None,
12253        detail: Some("Cargo workspace manifest".to_string()),
12254        properties,
12255        expand: traversal_expand_command(root, &handle),
12256    }
12257}
12258
12259fn traversal_cargo_package_node(
12260    root: &Path,
12261    package: &multiplicity::CargoPackageInfo,
12262) -> TraversalNode {
12263    let manifest = relativize_pathbuf(&package.manifest_path, root)
12264        .to_string_lossy()
12265        .replace('\\', "/");
12266    let package_root = relativize_pathbuf(&package.package_root, root)
12267        .to_string_lossy()
12268        .replace('\\', "/");
12269    let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12270        .to_string_lossy()
12271        .replace('\\', "/");
12272    let handle = stable_handle(
12273        "gcpk",
12274        &format!("cargo-package:{manifest}:{}", package.name),
12275    );
12276    let mut properties = BTreeMap::new();
12277    properties.insert("layer".to_string(), "cargo_package".to_string());
12278    properties.insert("package_name".to_string(), package.name.clone());
12279    properties.insert(
12280        "normalized_name".to_string(),
12281        package.normalized_name.clone(),
12282    );
12283    properties.insert("package_root".to_string(), package_root.clone());
12284    properties.insert("workspace_root".to_string(), workspace_root);
12285    properties.insert("features".to_string(), package.features.join(","));
12286    properties.insert("targets".to_string(), package.targets.join(","));
12287    properties.insert(
12288        "dependencies".to_string(),
12289        package
12290            .dependencies
12291            .iter()
12292            .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12293            .collect::<Vec<_>>()
12294            .join(","),
12295    );
12296    TraversalNode {
12297        handle: handle.clone(),
12298        kind: "cargo_package".to_string(),
12299        label: package.name.clone(),
12300        ref_id: Some(package.scope_id.clone()),
12301        path: Some(manifest),
12302        line: None,
12303        detail: Some(format!(
12304            "Cargo package in {}",
12305            if package_root.is_empty() {
12306                "."
12307            } else {
12308                package_root.as_str()
12309            }
12310        )),
12311        properties,
12312        expand: traversal_expand_command(root, &handle),
12313    }
12314}
12315
12316fn traversal_session_node(
12317    root: &Path,
12318    markdown_path: &Path,
12319    session_id: Option<&str>,
12320) -> TraversalNode {
12321    let display = relativize_pathbuf(markdown_path, root)
12322        .to_string_lossy()
12323        .replace('\\', "/");
12324    let handle = stable_handle("gses", &format!("session:{display}"));
12325    TraversalNode {
12326        handle: handle.clone(),
12327        kind: "session".to_string(),
12328        label: session_id.unwrap_or(&display).to_string(),
12329        ref_id: session_id.map(str::to_string),
12330        path: Some(display),
12331        line: None,
12332        detail: Some("agent-doc session artifact".to_string()),
12333        properties: BTreeMap::new(),
12334        expand: traversal_expand_command(root, &handle),
12335    }
12336}
12337
12338fn traversal_backlog_node(
12339    root: &Path,
12340    markdown_path: &Path,
12341    id: &str,
12342    text: &str,
12343    line: i64,
12344) -> TraversalNode {
12345    let display = relativize_pathbuf(markdown_path, root)
12346        .to_string_lossy()
12347        .replace('\\', "/");
12348    let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12349    TraversalNode {
12350        handle: handle.clone(),
12351        kind: "backlog".to_string(),
12352        label: format!("#{id}"),
12353        ref_id: Some(id.to_string()),
12354        path: Some(display),
12355        line: Some(line),
12356        detail: Some(text.to_string()),
12357        properties: BTreeMap::new(),
12358        expand: traversal_expand_command(root, &handle),
12359    }
12360}
12361
12362fn traversal_job_packet_node(
12363    root: &Path,
12364    markdown_path: &Path,
12365    label: &str,
12366    ref_id: Option<&str>,
12367    detail: &str,
12368    line: i64,
12369) -> TraversalNode {
12370    let display = relativize_pathbuf(markdown_path, root)
12371        .to_string_lossy()
12372        .replace('\\', "/");
12373    let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12374    TraversalNode {
12375        handle: handle.clone(),
12376        kind: "job_packet".to_string(),
12377        label: label.to_string(),
12378        ref_id: ref_id.map(str::to_string),
12379        path: Some(display),
12380        line: Some(line),
12381        detail: Some(detail.to_string()),
12382        properties: BTreeMap::new(),
12383        expand: traversal_expand_command(root, &handle),
12384    }
12385}
12386
12387#[derive(Clone, Debug)]
12388struct ParsedWorkerResult {
12389    id: String,
12390    status: String,
12391    touched_files: Vec<String>,
12392    tests: Vec<String>,
12393    follow_up_ids: Vec<String>,
12394}
12395
12396fn traversal_worker_result_node(
12397    root: &Path,
12398    markdown_path: &Path,
12399    parsed: &ParsedWorkerResult,
12400    line_text: &str,
12401    line: i64,
12402) -> TraversalNode {
12403    let display = relativize_pathbuf(markdown_path, root)
12404        .to_string_lossy()
12405        .replace('\\', "/");
12406    let handle = stable_handle(
12407        "wres",
12408        &format!(
12409            "worker-result:{display}:{}:{}:{}",
12410            parsed.id, parsed.status, line
12411        ),
12412    );
12413    let mut properties = BTreeMap::new();
12414    properties.insert("status".to_string(), parsed.status.clone());
12415    if !parsed.touched_files.is_empty() {
12416        properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12417    }
12418    if !parsed.tests.is_empty() {
12419        properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12420    }
12421    if !parsed.follow_up_ids.is_empty() {
12422        properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12423    }
12424    TraversalNode {
12425        handle: handle.clone(),
12426        kind: "worker_result".to_string(),
12427        label: format!("{} #{}", parsed.status, parsed.id),
12428        ref_id: Some(parsed.id.clone()),
12429        path: Some(display),
12430        line: Some(line),
12431        detail: Some(line_text.trim().to_string()),
12432        properties,
12433        expand: traversal_expand_command(root, &handle),
12434    }
12435}
12436
12437fn traversal_tokens(input: &str) -> BTreeSet<String> {
12438    input
12439        .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
12440        .flat_map(|part| part.split(['_', '-']))
12441        .map(str::trim)
12442        .filter(|part| part.len() >= 3)
12443        .map(|part| part.to_ascii_lowercase())
12444        .collect()
12445}
12446
12447fn traversal_ast_span_contains(
12448    parent: &TraversalAstSpanIndexEntry,
12449    child: &TraversalAstSpanIndexEntry,
12450) -> bool {
12451    parent.handle != child.handle
12452        && parent.file == child.file
12453        && parent.start_byte <= child.start_byte
12454        && parent.end_byte >= child.end_byte
12455}
12456
12457fn traversal_ast_parent_handle<'a>(
12458    entry: &TraversalAstSpanIndexEntry,
12459    entries: &'a [TraversalAstSpanIndexEntry],
12460) -> Option<&'a str> {
12461    entries
12462        .iter()
12463        .filter(|candidate| traversal_ast_span_contains(candidate, entry))
12464        .min_by_key(|candidate| {
12465            (
12466                candidate.end_byte.saturating_sub(candidate.start_byte),
12467                candidate.start_byte,
12468                candidate.end_byte,
12469                candidate.kind.as_str(),
12470                candidate.name.as_str(),
12471                candidate.node_kind.as_str(),
12472            )
12473        })
12474        .map(|candidate| candidate.handle.as_str())
12475}
12476
12477fn traversal_ast_enclosing_module_handle<'a>(
12478    entry: &TraversalAstSpanIndexEntry,
12479    entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
12480    parent_by_handle: &BTreeMap<String, String>,
12481) -> Option<&'a str> {
12482    let mut current = parent_by_handle.get(&entry.handle);
12483    while let Some(handle) = current {
12484        let Some(parent) = entries_by_handle.get(handle) else {
12485            break;
12486        };
12487        if matches!(parent.kind.as_str(), "module" | "mod")
12488            || entry
12489                .parent_module
12490                .as_deref()
12491                .is_some_and(|module| module == parent.name)
12492        {
12493            return Some(parent.handle.as_str());
12494        }
12495        current = parent_by_handle.get(&parent.handle);
12496    }
12497    None
12498}
12499
12500fn link_ast_navigation_edges(
12501    graph: &mut TraversalGraphBuild,
12502    entries: &[TraversalAstSpanIndexEntry],
12503) {
12504    let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
12505    let entries_by_handle = entries
12506        .iter()
12507        .map(|entry| (entry.handle.clone(), entry.clone()))
12508        .collect::<BTreeMap<_, _>>();
12509    let mut parent_by_handle = BTreeMap::<String, String>::new();
12510    let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
12511
12512    for entry in entries {
12513        entries_by_file
12514            .entry(entry.file.clone())
12515            .or_default()
12516            .push(entry.clone());
12517    }
12518
12519    for file_entries in entries_by_file.values() {
12520        for entry in file_entries {
12521            let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
12522            if let Some(parent) = &parent {
12523                parent_by_handle.insert(entry.handle.clone(), parent.clone());
12524            }
12525            let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
12526            children_by_parent
12527                .entry(sibling_key)
12528                .or_default()
12529                .push(entry.clone());
12530        }
12531    }
12532
12533    for entry in entries {
12534        let parent = parent_by_handle.get(&entry.handle);
12535        if let Some(parent) = parent {
12536            graph.add_edge(
12537                parent,
12538                &entry.handle,
12539                "contains",
12540                Some("AST parent contains child span".to_string()),
12541                1,
12542            );
12543            graph.add_edge(
12544                parent,
12545                &entry.handle,
12546                "child",
12547                Some("AST child span".to_string()),
12548                1,
12549            );
12550            graph.add_edge(
12551                &entry.handle,
12552                parent,
12553                "parent",
12554                Some("AST parent span".to_string()),
12555                1,
12556            );
12557        } else if let Some(file_handle) = &entry.file_handle {
12558            graph.add_edge(
12559                file_handle,
12560                &entry.handle,
12561                "contains",
12562                Some("file contains top-level AST span".to_string()),
12563                1,
12564            );
12565        }
12566
12567        if let Some(module_handle) =
12568            traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
12569        {
12570            graph.add_edge(
12571                &entry.handle,
12572                module_handle,
12573                "enclosing_module",
12574                Some("nearest enclosing module AST span".to_string()),
12575                1,
12576            );
12577        }
12578
12579        if entry.language == "markdown"
12580            && let Some(markdown) = &entry.markdown
12581            && let Some(section_handle) = &markdown.section_handle
12582            && section_handle != &entry.handle
12583        {
12584            graph.add_edge(
12585                section_handle,
12586                &entry.handle,
12587                "contains_markdown_block",
12588                Some("Markdown section contains block".to_string()),
12589                1,
12590            );
12591            graph.add_edge(
12592                &entry.handle,
12593                section_handle,
12594                "enclosing_section",
12595                Some("Markdown enclosing section".to_string()),
12596                1,
12597            );
12598        }
12599    }
12600
12601    for siblings in children_by_parent.values_mut() {
12602        siblings.sort_by(|left, right| {
12603            left.start_byte
12604                .cmp(&right.start_byte)
12605                .then(left.end_byte.cmp(&right.end_byte))
12606                .then(left.kind.cmp(&right.kind))
12607                .then(left.name.cmp(&right.name))
12608                .then(left.node_kind.cmp(&right.node_kind))
12609                .then(left.handle.cmp(&right.handle))
12610        });
12611        for pair in siblings.windows(2) {
12612            let previous = &pair[0];
12613            let next = &pair[1];
12614            graph.add_edge(
12615                &previous.handle,
12616                &next.handle,
12617                "next_sibling",
12618                Some("next AST sibling span".to_string()),
12619                1,
12620            );
12621            graph.add_edge(
12622                &next.handle,
12623                &previous.handle,
12624                "previous_sibling",
12625                Some("previous AST sibling span".to_string()),
12626                1,
12627            );
12628        }
12629    }
12630}
12631
12632fn traversal_markdown_embedded_symbol_node(
12633    root: &Path,
12634    entry: &TraversalAstSpanIndexEntry,
12635    markdown: &MarkdownSpanMetadata,
12636    embedded: &MarkdownEmbeddedSymbol,
12637) -> TraversalNode {
12638    let mut properties = BTreeMap::new();
12639    properties.insert("layer".to_string(), "embedded_code".to_string());
12640    properties.insert("embedded".to_string(), "true".to_string());
12641    properties.insert("language".to_string(), embedded.language.clone());
12642    properties.insert("symbol_kind".to_string(), embedded.kind.clone());
12643    properties.insert("node_kind".to_string(), embedded.node_kind.clone());
12644    properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
12645    properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
12646    properties.insert("end_line".to_string(), embedded.end_line.to_string());
12647    properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
12648    properties.insert(
12649        "markdown_block_kind".to_string(),
12650        markdown_ast_block_kind(&entry.kind),
12651    );
12652    if let Some(body_start_byte) = embedded.body_start_byte {
12653        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12654    }
12655    if let Some(body_end_byte) = embedded.body_end_byte {
12656        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12657    }
12658    if let Some(body_start_line) = embedded.body_start_line {
12659        properties.insert("body_start_line".to_string(), body_start_line.to_string());
12660    }
12661    if let Some(body_end_line) = embedded.body_end_line {
12662        properties.insert("body_end_line".to_string(), body_end_line.to_string());
12663    }
12664    if let Some(fence_language) = &markdown.fence_language {
12665        properties.insert("fence_language".to_string(), fence_language.clone());
12666    }
12667    if !markdown.section_path.is_empty() {
12668        properties.insert(
12669            "section_path".to_string(),
12670            markdown.section_path.join(" > "),
12671        );
12672    }
12673    if let Some(section_handle) = &markdown.section_handle {
12674        properties.insert("section_handle".to_string(), section_handle.clone());
12675    }
12676    let line_count = embedded
12677        .end_line
12678        .saturating_sub(embedded.start_line)
12679        .saturating_add(1)
12680        .max(1);
12681    TraversalNode {
12682        handle: embedded.handle.clone(),
12683        kind: "ast_span".to_string(),
12684        label: embedded.name.clone(),
12685        ref_id: Some(embedded.name.clone()),
12686        path: Some(entry.file.clone()),
12687        line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
12688        detail: Some(format!(
12689            "{} {} embedded in Markdown fence",
12690            embedded.language, embedded.kind
12691        )),
12692        properties,
12693        expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
12694    }
12695}
12696
12697fn link_markdown_embedded_code_edges(
12698    graph: &mut TraversalGraphBuild,
12699    root: &Path,
12700    entries: &[TraversalAstSpanIndexEntry],
12701) {
12702    for entry in entries {
12703        let Some(markdown) = &entry.markdown else {
12704            continue;
12705        };
12706        for embedded in &markdown.embedded_symbols {
12707            let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
12708            graph.add_node(node);
12709            graph.add_edge(
12710                &entry.handle,
12711                &embedded.handle,
12712                "contains",
12713                Some("Markdown fence contains embedded AST symbol".to_string()),
12714                1,
12715            );
12716            graph.add_edge(
12717                &entry.handle,
12718                &embedded.handle,
12719                "child",
12720                Some("embedded code symbol".to_string()),
12721                1,
12722            );
12723            graph.add_edge(
12724                &entry.handle,
12725                &embedded.handle,
12726                "contains_embedded_symbol",
12727                Some("Markdown fence contains embedded code symbol".to_string()),
12728                1,
12729            );
12730            graph.add_edge(
12731                &embedded.handle,
12732                &entry.handle,
12733                "parent",
12734                Some("Markdown fence parent span".to_string()),
12735                1,
12736            );
12737            graph.add_edge(
12738                &embedded.handle,
12739                &entry.handle,
12740                "embedded_in_fence",
12741                Some("embedded code symbol belongs to Markdown fence".to_string()),
12742                1,
12743            );
12744            if let Some(section_handle) = &markdown.section_handle
12745                && section_handle != &entry.handle
12746            {
12747                graph.add_edge(
12748                    section_handle,
12749                    &embedded.handle,
12750                    "contains_embedded_code",
12751                    Some("Markdown section contains embedded code symbol".to_string()),
12752                    1,
12753                );
12754                graph.add_edge(
12755                    &embedded.handle,
12756                    section_handle,
12757                    "enclosing_section",
12758                    Some("Markdown enclosing section".to_string()),
12759                    1,
12760                );
12761            }
12762        }
12763    }
12764}
12765
12766fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
12767    let mut tokens = traversal_tokens(&node.label);
12768    if let Some(ref_id) = &node.ref_id {
12769        tokens.extend(traversal_tokens(ref_id));
12770    }
12771    if let Some(path) = &node.path {
12772        tokens.extend(traversal_tokens(path));
12773    }
12774    if let Some(detail) = &node.detail {
12775        tokens.extend(traversal_tokens(detail));
12776    }
12777    tokens
12778}
12779
12780fn parse_agent_doc_session_id(content: &str) -> Option<String> {
12781    content.lines().find_map(|line| {
12782        let trimmed = line.trim();
12783        trimmed
12784            .strip_prefix("agent_doc_session:")
12785            .map(str::trim)
12786            .filter(|value| !value.is_empty())
12787            .map(str::to_string)
12788    })
12789}
12790
12791fn parse_backlog_line(line: &str) -> Option<(String, String)> {
12792    let trimmed = line.trim();
12793    if !trimmed.starts_with("- [") {
12794        return None;
12795    }
12796    let start = trimmed.find("[#")?;
12797    let after_start = start + 2;
12798    let rest = &trimmed[after_start..];
12799    let end = rest.find(']')?;
12800    let id = rest[..end].trim();
12801    if id.is_empty() {
12802        return None;
12803    }
12804    let text = rest[end + 1..].trim().to_string();
12805    Some((id.to_string(), text))
12806}
12807
12808fn parse_queue_dispatch_line(line: &str) -> Option<String> {
12809    let trimmed = line.trim();
12810    ["dispatch ", "preset "].iter().find_map(|prefix| {
12811        trimmed
12812            .strip_prefix(prefix)
12813            .map(str::trim)
12814            .filter(|value| !value.is_empty())
12815            .map(str::to_string)
12816    })
12817}
12818
12819fn parse_queue_do_line(line: &str) -> Option<String> {
12820    let trimmed = line.trim();
12821    let rest = trimmed.strip_prefix("- do [#")?;
12822    let end = rest.find(']')?;
12823    let id = rest[..end].trim();
12824    (!id.is_empty()).then(|| id.to_string())
12825}
12826
12827fn markdown_code_spans(input: &str) -> Vec<String> {
12828    input
12829        .split('`')
12830        .enumerate()
12831        .filter(|(idx, _)| idx % 2 == 1)
12832        .map(|(_, part)| part.trim().to_string())
12833        .filter(|part| !part.is_empty())
12834        .collect()
12835}
12836
12837fn push_traversal_token_index(
12838    index: &mut HashMap<String, Vec<usize>>,
12839    tokens: &BTreeSet<String>,
12840    entry_index: usize,
12841) {
12842    for token in tokens {
12843        index.entry(token.clone()).or_default().push(entry_index);
12844    }
12845}
12846
12847impl<'a> TraversalCodeLookup<'a> {
12848    fn new(
12849        symbols: &'a [TraversalSymbolIndexEntry],
12850        files: &'a [TraversalFileIndexEntry],
12851        routes: &'a [TraversalRouteIndexEntry],
12852        multiplicities: &'a [TraversalMultiplicityIndexEntry],
12853    ) -> Self {
12854        let mut symbol_index = HashMap::new();
12855        for (idx, entry) in symbols.iter().enumerate() {
12856            push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
12857        }
12858        let mut file_index = HashMap::new();
12859        let mut file_path_index = HashMap::new();
12860        for (idx, entry) in files.iter().enumerate() {
12861            push_traversal_token_index(&mut file_index, &entry.tokens, idx);
12862            if let Some(path) = entry.node.path.as_ref() {
12863                file_path_index.insert(path.clone(), path.clone());
12864            }
12865        }
12866        let mut route_index = HashMap::new();
12867        for (idx, entry) in routes.iter().enumerate() {
12868            push_traversal_token_index(&mut route_index, &entry.tokens, idx);
12869        }
12870        let mut multiplicity_index = HashMap::new();
12871        for (idx, entry) in multiplicities.iter().enumerate() {
12872            push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
12873        }
12874        Self {
12875            symbols,
12876            files,
12877            routes,
12878            multiplicities,
12879            symbol_index,
12880            file_index,
12881            route_index,
12882            multiplicity_index,
12883            file_path_index,
12884        }
12885    }
12886
12887    fn touched_files_for_line(&self, line: &str) -> Vec<String> {
12888        let mut touched_files = BTreeSet::new();
12889        for candidate in markdown_code_spans(line)
12890            .into_iter()
12891            .chain(line.split_whitespace().map(str::to_string))
12892        {
12893            for path in traversal_path_candidates(&candidate) {
12894                if let Some(file) = self.file_path_index.get(&path) {
12895                    touched_files.insert(file.clone());
12896                }
12897            }
12898        }
12899        touched_files.into_iter().collect()
12900    }
12901}
12902
12903fn traversal_path_candidates(candidate: &str) -> Vec<String> {
12904    let trimmed = candidate.trim_matches(|ch: char| {
12905        matches!(
12906            ch,
12907            '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
12908        )
12909    });
12910    if trimmed.is_empty() {
12911        return Vec::new();
12912    }
12913    let mut candidates = vec![trimmed.to_string()];
12914    if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
12915        && !path.is_empty()
12916        && line_suffix.chars().all(|ch| ch.is_ascii_digit())
12917    {
12918        candidates.push(path.to_string());
12919    }
12920    candidates
12921}
12922
12923fn parse_worker_result_line(
12924    line: &str,
12925    lookup: &TraversalCodeLookup<'_>,
12926) -> Vec<ParsedWorkerResult> {
12927    if line.trim_start().starts_with("- [") {
12928        return Vec::new();
12929    }
12930    let lower = line.to_ascii_lowercase();
12931    let status =
12932        if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
12933        {
12934            "completed"
12935        } else if lower.contains("blocked") || lower.contains("externally blocked") {
12936            "blocked"
12937        } else {
12938            return Vec::new();
12939        };
12940    let result_prefix_end = ["follow-up", "follow up", "next:"]
12941        .iter()
12942        .filter_map(|marker| lower.find(marker))
12943        .min()
12944        .unwrap_or(line.len());
12945    let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
12946    if ids.is_empty() {
12947        return Vec::new();
12948    }
12949    let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
12950    let all_ids = extract_conflict_target_refs(line);
12951
12952    let touched_files = lookup.touched_files_for_line(line);
12953    let tests = markdown_code_spans(line)
12954        .into_iter()
12955        .filter(|span| span.to_ascii_lowercase().contains("test"))
12956        .collect::<Vec<_>>();
12957
12958    ids.iter()
12959        .map(|id| ParsedWorkerResult {
12960            id: id.clone(),
12961            status: status.to_string(),
12962            touched_files: touched_files.clone(),
12963            tests: tests.clone(),
12964            follow_up_ids: all_ids
12965                .iter()
12966                .filter(|other| *other != id && !result_ids.contains(*other))
12967                .cloned()
12968                .collect(),
12969        })
12970        .collect()
12971}
12972
12973fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
12974    let hinted_path = if path_hint.is_absolute() {
12975        path_hint.to_path_buf()
12976    } else {
12977        root.join(path_hint)
12978    };
12979    if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
12980        return Some(hinted_path);
12981    }
12982    None
12983}
12984
12985fn traversal_markdown_content_looks_like_session(content: &str) -> bool {
12986    parse_agent_doc_session_id(content).is_some()
12987        || content.contains("<!-- agent:exchange")
12988        || content.contains("<!-- agent:backlog")
12989        || content.contains("## Backlog")
12990}
12991
12992fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
12993    let candidate = if path.is_absolute() {
12994        path.to_path_buf()
12995    } else {
12996        source_root.join(path)
12997    };
12998    if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
12999        return false;
13000    }
13001    if !matches!(
13002        candidate.extension().and_then(|ext| ext.to_str()),
13003        Some("md" | "mdx")
13004    ) {
13005        return false;
13006    }
13007    fs::read_to_string(&candidate)
13008        .map(|content| traversal_markdown_content_looks_like_session(&content))
13009        .unwrap_or(false)
13010}
13011
13012fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
13013    if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
13014        return Ok(vec![hinted_path]);
13015    }
13016    let mut files = Vec::new();
13017    let walker = ignore::WalkBuilder::new(root)
13018        .hidden(true)
13019        .git_ignore(true)
13020        .git_global(true)
13021        .git_exclude(true)
13022        .build();
13023    for result in walker {
13024        let entry =
13025            result.with_context(|| format!("walking markdown files under {}", root.display()))?;
13026        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
13027            continue;
13028        }
13029        if traversal_path_is_generated_artifact(root, root, entry.path()) {
13030            continue;
13031        }
13032        if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
13033            files.push(entry.path().to_path_buf());
13034        }
13035    }
13036    files.sort();
13037    Ok(files)
13038}
13039
13040fn traversal_watermark_path(root: &Path, path: &Path) -> String {
13041    path.strip_prefix(root)
13042        .unwrap_or(path)
13043        .to_string_lossy()
13044        .replace('\\', "/")
13045}
13046
13047fn push_traversal_metadata_watermark_part(
13048    root: &Path,
13049    path: &Path,
13050    label: &str,
13051    parts: &mut Vec<String>,
13052) {
13053    let display = traversal_watermark_path(root, path);
13054    match fs::metadata(path) {
13055        Ok(metadata) => {
13056            let (secs, nanos) = metadata
13057                .modified()
13058                .ok()
13059                .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
13060                .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
13061                .unwrap_or((0, 0));
13062            parts.push(format!(
13063                "{label}:{display}:len={}:mtime={secs}.{nanos}",
13064                metadata.len()
13065            ));
13066        }
13067        Err(_) => parts.push(format!("{label}:{display}:missing")),
13068    }
13069}
13070
13071#[derive(Serialize)]
13072struct TraversalSummaryWatermarkRow<'a> {
13073    symbol_name: &'a str,
13074    file_path: &'a str,
13075    entities: &'a Option<Vec<summarize::Entity>>,
13076    relationships: &'a Option<Vec<summarize::Relationship>>,
13077    concept_labels: &'a Option<Vec<String>>,
13078}
13079
13080fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
13081    let summaries_db = root.join(".tsift/summaries.db");
13082    if !summaries_db.exists() {
13083        parts.push("summaries_db:absent".to_string());
13084        return Ok(());
13085    }
13086
13087    match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
13088        .and_then(|summary_db| summary_db.all())
13089    {
13090        Ok(summaries) => {
13091            let rows = summaries
13092                .iter()
13093                .map(|summary| TraversalSummaryWatermarkRow {
13094                    symbol_name: &summary.symbol_name,
13095                    file_path: &summary.file_path,
13096                    entities: &summary.entities,
13097                    relationships: &summary.relationships,
13098                    concept_labels: &summary.concept_labels,
13099                })
13100                .collect::<Vec<_>>();
13101            parts.push(format!(
13102                "summaries_db:rows={}:semantic_hash={}",
13103                rows.len(),
13104                content_hash(&rows)?
13105            ));
13106        }
13107        Err(_) => {
13108            push_traversal_metadata_watermark_part(
13109                root,
13110                &summaries_db,
13111                "summaries_db_unreadable",
13112                parts,
13113            );
13114        }
13115    }
13116    Ok(())
13117}
13118
13119#[cfg(test)]
13120fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
13121    resolution::relative_path_is_generated_artifact(relative)
13122}
13123
13124fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
13125    resolution::path_is_generated_artifact(root, source_root, path)
13126}
13127
13128fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
13129    resolution::index_snapshot_part_is_generated(root, source_root, part)
13130}
13131
13132pub(crate) fn traversal_source_watermark(
13133    root: &Path,
13134    path_hint: &Path,
13135    scope: Option<&str>,
13136    session_only: bool,
13137) -> Result<Option<String>> {
13138    let mut parts = vec![
13139        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
13140        format!("scope:{}", scope.unwrap_or("root")),
13141        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
13142        format!("session_only:{session_only}"),
13143    ];
13144
13145    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13146        let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13147            Ok(targets) => targets,
13148            Err(_) => return Ok(None),
13149        };
13150        let Some(target) = targets.into_iter().next() else {
13151            return Ok(None);
13152        };
13153        let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
13154            Ok(db) => db,
13155            Err(_) => return Ok(None),
13156        };
13157        parts.push(format!("index_label:{}", target.label));
13158        parts.push(format!(
13159            "index_scope:{}",
13160            target.scope_name.as_deref().unwrap_or("root")
13161        ));
13162        parts.push(format!(
13163            "index_source_root:{}",
13164            traversal_watermark_path(root, &target.source_root)
13165        ));
13166        let mut snapshot_rows = 0usize;
13167        for part in db.source_snapshot_parts()? {
13168            if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
13169                continue;
13170            }
13171            snapshot_rows += 1;
13172            parts.push(format!("index_snapshot:{part}"));
13173        }
13174        parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
13175    }
13176
13177    let markdown_files = markdown_files_for_traversal(root, path_hint)?;
13178    parts.push(format!("markdown_count:{}", markdown_files.len()));
13179    for markdown_path in markdown_files {
13180        push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
13181    }
13182
13183    push_traversal_summaries_watermark_part(root, &mut parts)?;
13184
13185    Ok(Some(content_hash(&parts)?))
13186}
13187
13188fn ranked_symbol_matches<'a>(
13189    query_tokens: &BTreeSet<String>,
13190    entries: &'a [TraversalSymbolIndexEntry],
13191    index: &HashMap<String, Vec<usize>>,
13192) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13193    let mut scores = BTreeMap::<usize, usize>::new();
13194    for token in query_tokens {
13195        if let Some(indices) = index.get(token) {
13196            for idx in indices {
13197                *scores.entry(*idx).or_default() += 1;
13198            }
13199        }
13200    }
13201    let mut matches = scores
13202        .into_iter()
13203        .map(|(idx, score)| (score, &entries[idx]))
13204        .collect::<Vec<_>>();
13205    matches.sort_by(|(left_score, left), (right_score, right)| {
13206        right_score
13207            .cmp(left_score)
13208            .then_with(|| left.node.label.cmp(&right.node.label))
13209            .then_with(|| left.handle.cmp(&right.handle))
13210    });
13211    matches
13212}
13213
13214fn ranked_file_matches<'a>(
13215    query_tokens: &BTreeSet<String>,
13216    entries: &'a [TraversalFileIndexEntry],
13217    index: &HashMap<String, Vec<usize>>,
13218) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13219    let mut scores = BTreeMap::<usize, usize>::new();
13220    for token in query_tokens {
13221        if let Some(indices) = index.get(token) {
13222            for idx in indices {
13223                *scores.entry(*idx).or_default() += 1;
13224            }
13225        }
13226    }
13227    let mut matches = scores
13228        .into_iter()
13229        .map(|(idx, score)| (score, &entries[idx]))
13230        .collect::<Vec<_>>();
13231    matches.sort_by(|(left_score, left), (right_score, right)| {
13232        right_score
13233            .cmp(left_score)
13234            .then_with(|| left.node.label.cmp(&right.node.label))
13235            .then_with(|| left.handle.cmp(&right.handle))
13236    });
13237    matches
13238}
13239
13240fn ranked_route_matches<'a>(
13241    query_tokens: &BTreeSet<String>,
13242    entries: &'a [TraversalRouteIndexEntry],
13243    index: &HashMap<String, Vec<usize>>,
13244) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13245    let mut scores = BTreeMap::<usize, usize>::new();
13246    for token in query_tokens {
13247        if let Some(indices) = index.get(token) {
13248            for idx in indices {
13249                *scores.entry(*idx).or_default() += 1;
13250            }
13251        }
13252    }
13253    let mut matches = scores
13254        .into_iter()
13255        .map(|(idx, score)| (score, &entries[idx]))
13256        .collect::<Vec<_>>();
13257    matches.sort_by(|(left_score, left), (right_score, right)| {
13258        right_score
13259            .cmp(left_score)
13260            .then_with(|| left.node.label.cmp(&right.node.label))
13261            .then_with(|| left.handle.cmp(&right.handle))
13262    });
13263    matches
13264}
13265
13266fn ranked_multiplicity_matches<'a>(
13267    query_tokens: &BTreeSet<String>,
13268    entries: &'a [TraversalMultiplicityIndexEntry],
13269    index: &HashMap<String, Vec<usize>>,
13270) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13271    let mut scores = BTreeMap::<usize, usize>::new();
13272    for token in query_tokens {
13273        if let Some(indices) = index.get(token) {
13274            for idx in indices {
13275                *scores.entry(*idx).or_default() += 1;
13276            }
13277        }
13278    }
13279    let mut matches = scores
13280        .into_iter()
13281        .map(|(idx, score)| (score, &entries[idx]))
13282        .collect::<Vec<_>>();
13283    matches.sort_by(|(left_score, left), (right_score, right)| {
13284        right_score
13285            .cmp(left_score)
13286            .then_with(|| left.node.kind.cmp(&right.node.kind))
13287            .then_with(|| left.node.label.cmp(&right.node.label))
13288            .then_with(|| left.handle.cmp(&right.handle))
13289    });
13290    matches
13291}
13292
13293fn link_backlog_to_code_nodes(
13294    graph: &mut TraversalGraphBuild,
13295    backlog: &TraversalNode,
13296    text: &str,
13297    lookup: &TraversalCodeLookup<'_>,
13298    limit: usize,
13299) {
13300    let mut query_tokens = traversal_tokens(text);
13301    if let Some(ref_id) = &backlog.ref_id {
13302        query_tokens.extend(traversal_tokens(ref_id));
13303    }
13304    if query_tokens.is_empty() {
13305        return;
13306    }
13307
13308    for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13309        .into_iter()
13310        .take(limit)
13311    {
13312        graph.add_edge(
13313            &backlog.handle,
13314            &entry.handle,
13315            "mentions",
13316            Some("backlog text matches symbol tokens".to_string()),
13317            score,
13318        );
13319    }
13320
13321    for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13322        .into_iter()
13323        .take(limit.min(5))
13324    {
13325        graph.add_edge(
13326            &backlog.handle,
13327            &entry.handle,
13328            "mentions",
13329            Some("backlog text matches file tokens".to_string()),
13330            score,
13331        );
13332    }
13333
13334    for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13335        .into_iter()
13336        .take(limit.min(5))
13337    {
13338        graph.add_edge(
13339            &backlog.handle,
13340            &entry.handle,
13341            "mentions",
13342            Some("backlog text matches route tokens".to_string()),
13343            score,
13344        );
13345    }
13346
13347    for (score, entry) in ranked_multiplicity_matches(
13348        &query_tokens,
13349        lookup.multiplicities,
13350        &lookup.multiplicity_index,
13351    )
13352    .into_iter()
13353    .take(limit.min(5))
13354    {
13355        graph.add_edge(
13356            &backlog.handle,
13357            &entry.handle,
13358            "mentions",
13359            Some("backlog text matches multiplicity tokens".to_string()),
13360            score,
13361        );
13362    }
13363}
13364
13365fn load_agent_doc_traversal_nodes(
13366    root: &Path,
13367    path_hint: &Path,
13368    graph: &mut TraversalGraphBuild,
13369    lookup: &TraversalCodeLookup<'_>,
13370) -> Result<()> {
13371    for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13372        let content = match fs::read_to_string(&markdown_path) {
13373            Ok(content) => content,
13374            Err(err) => {
13375                graph.warnings.push(format!(
13376                    "session artifact unavailable: {}: {err}",
13377                    markdown_path.display()
13378                ));
13379                continue;
13380            }
13381        };
13382        if !traversal_markdown_content_looks_like_session(&content) {
13383            continue;
13384        }
13385
13386        let session_id = parse_agent_doc_session_id(&content);
13387        let session = traversal_session_node(root, &markdown_path, session_id.as_deref());
13388        graph.add_node(session.clone());
13389        let lines = content.lines().collect::<Vec<_>>();
13390        let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13391        for (idx, line) in lines.iter().enumerate() {
13392            let Some((id, text)) = parse_backlog_line(line) else {
13393                continue;
13394            };
13395            let backlog = traversal_backlog_node(root, &markdown_path, &id, &text, idx as i64 + 1);
13396            graph.add_node(backlog.clone());
13397            backlog_by_id.insert(id.clone(), backlog.clone());
13398            graph.add_edge(
13399                &session.handle,
13400                &backlog.handle,
13401                "contains",
13402                Some("session backlog item".to_string()),
13403                1,
13404            );
13405            link_backlog_to_code_nodes(graph, &backlog, &text, lookup, 8);
13406        }
13407
13408        let mut in_queue = false;
13409        let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13410        for (idx, line) in lines.iter().enumerate() {
13411            let trimmed = line.trim();
13412            if trimmed.starts_with("<!-- agent:queue") {
13413                in_queue = true;
13414                continue;
13415            }
13416            if trimmed.starts_with("<!-- /agent:queue") {
13417                in_queue = false;
13418                continue;
13419            }
13420            if !in_queue {
13421                continue;
13422            }
13423            if let Some(dispatch) = parse_queue_dispatch_line(line) {
13424                let dispatch_ref = dispatch.strip_prefix('#').unwrap_or(dispatch.as_str());
13425                let node = traversal_job_packet_node(
13426                    root,
13427                    &markdown_path,
13428                    &format!("dispatch {dispatch}"),
13429                    Some(dispatch_ref),
13430                    "agent-doc dispatch preset",
13431                    idx as i64 + 1,
13432                );
13433                graph.add_node(node.clone());
13434                graph.add_edge(
13435                    &session.handle,
13436                    &node.handle,
13437                    "contains",
13438                    Some("session queued dispatch".to_string()),
13439                    1,
13440                );
13441                continue;
13442            }
13443            if let Some(id) = parse_queue_do_line(line) {
13444                let detail = backlog_by_id
13445                    .get(&id)
13446                    .and_then(|node| node.detail.clone())
13447                    .unwrap_or_else(|| "queued backlog item".to_string());
13448                let node = traversal_job_packet_node(
13449                    root,
13450                    &markdown_path,
13451                    &format!("do #{id}"),
13452                    Some(&id),
13453                    &detail,
13454                    idx as i64 + 1,
13455                );
13456                graph.add_node(node.clone());
13457                graph.add_edge(
13458                    &session.handle,
13459                    &node.handle,
13460                    "contains",
13461                    Some("session queued job packet".to_string()),
13462                    1,
13463                );
13464                if let Some(backlog) = backlog_by_id.get(&id) {
13465                    graph.add_edge(
13466                        &node.handle,
13467                        &backlog.handle,
13468                        "targets",
13469                        Some("queued backlog item".to_string()),
13470                        1,
13471                    );
13472                }
13473                job_by_id.insert(id, node);
13474            }
13475        }
13476
13477        let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13478        for (idx, line) in lines.iter().enumerate() {
13479            for parsed in parse_worker_result_line(line, lookup) {
13480                let line_no = idx as i64 + 1;
13481                if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13482                    continue;
13483                }
13484                let result =
13485                    traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13486                graph.add_node(result.clone());
13487                graph.add_edge(
13488                    &session.handle,
13489                    &result.handle,
13490                    "contains",
13491                    Some("session worker result".to_string()),
13492                    1,
13493                );
13494                if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13495                    graph.add_edge(
13496                        &backlog.handle,
13497                        &result.handle,
13498                        "has_result",
13499                        Some(format!("worker result {}", parsed.status)),
13500                        1,
13501                    );
13502                }
13503                if let Some(job) = job_by_id.get(&parsed.id) {
13504                    graph.add_edge(
13505                        &job.handle,
13506                        &result.handle,
13507                        "has_result",
13508                        Some(format!("queued worker result {}", parsed.status)),
13509                        1,
13510                    );
13511                }
13512                let mut result_text = line.to_string();
13513                if !parsed.touched_files.is_empty() {
13514                    result_text.push(' ');
13515                    result_text.push_str(&parsed.touched_files.join(" "));
13516                }
13517                link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
13518            }
13519        }
13520    }
13521    Ok(())
13522}
13523
13524#[derive(Debug, Clone)]
13525struct AgentDocIndexGate {
13526    db_path: Option<PathBuf>,
13527    source_root: PathBuf,
13528    diagnostics: Vec<String>,
13529}
13530
13531#[derive(Clone, Hash, PartialEq, Eq)]
13532struct AgentDocIndexGateCacheKey {
13533    root: PathBuf,
13534    path_hint: PathBuf,
13535    scope: Option<String>,
13536    packet_label: String,
13537}
13538
13539fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
13540    std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
13541> {
13542    static CACHE: std::sync::OnceLock<
13543        std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
13544    > = std::sync::OnceLock::new();
13545    CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
13546}
13547
13548fn prepare_agent_doc_index_gate_cached(
13549    root: &Path,
13550    path_hint: &Path,
13551    scope: Option<&str>,
13552    packet_label: &str,
13553) -> (AgentDocIndexGate, String) {
13554    let key = AgentDocIndexGateCacheKey {
13555        root: root.to_path_buf(),
13556        path_hint: path_hint.to_path_buf(),
13557        scope: scope.map(str::to_string),
13558        packet_label: packet_label.to_string(),
13559    };
13560    if let Ok(cache) = agent_doc_index_gate_cache().lock()
13561        && let Some(cached) = cache.get(&key)
13562    {
13563        return (
13564            cached.clone(),
13565            "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
13566        );
13567    }
13568    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
13569    if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
13570        cache.insert(key, gate.clone());
13571    }
13572    (
13573        gate,
13574        "fresh inspection/refresh — cache miss on this preparation key".to_string(),
13575    )
13576}
13577
13578fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
13579    match state {
13580        SearchIndexState::Fresh => None,
13581        SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
13582        SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
13583    }
13584}
13585
13586fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
13587    rebuild_search_target_detail(&RebuildSearchTarget {
13588        label: target.label.clone(),
13589        reason,
13590        reindex_cmd: target.reindex_cmd.clone(),
13591    })
13592}
13593
13594fn index_refresh_diagnostic(
13595    target: &SearchIndexTarget,
13596    reason: RebuildSearchReason,
13597    summary: &index::IndexSummary,
13598    packet_label: &str,
13599) -> String {
13600    let changed = summary.new + summary.modified + summary.deleted;
13601    format!(
13602        "index refreshed: {}; updated {} changed file{} before {}",
13603        index_reason_detail(target, reason),
13604        changed,
13605        if changed == 1 { "" } else { "s" },
13606        packet_label
13607    )
13608}
13609
13610fn index_refresh_fallback_diagnostic(
13611    target: &SearchIndexTarget,
13612    reason: RebuildSearchReason,
13613    err: &anyhow::Error,
13614    packet_label: &str,
13615) -> String {
13616    format!(
13617        "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
13618        index_reason_detail(target, reason),
13619        packet_label
13620    )
13621}
13622
13623fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
13624    if let Some(scope_name) = scope
13625        && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
13626    {
13627        return scope.source_root;
13628    }
13629    if let Some(scope_name) = scope
13630        && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
13631    {
13632        return package.package_root;
13633    }
13634    if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
13635        return scope.source_root;
13636    }
13637    if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
13638        return package.package_root;
13639    }
13640    if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
13641        return scope.source_root;
13642    }
13643    root.to_path_buf()
13644}
13645
13646fn prepare_agent_doc_index_gate(
13647    root: &Path,
13648    path_hint: &Path,
13649    scope: Option<&str>,
13650    packet_label: &str,
13651) -> AgentDocIndexGate {
13652    let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
13653    let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13654        Ok(targets) => targets,
13655        Err(err) => {
13656            return AgentDocIndexGate {
13657                db_path: None,
13658                source_root: fallback_source_root,
13659                diagnostics: vec![format!(
13660                    "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13661                )],
13662            };
13663        }
13664    };
13665    let Some(target) = targets.into_iter().next() else {
13666        return AgentDocIndexGate {
13667            db_path: None,
13668            source_root: fallback_source_root,
13669            diagnostics: vec![format!(
13670                "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
13671            )],
13672        };
13673    };
13674
13675    let state = match inspect_search_index(&target) {
13676        Ok(state) => state,
13677        Err(err) => {
13678            return AgentDocIndexGate {
13679                db_path: None,
13680                source_root: target.source_root,
13681                diagnostics: vec![format!(
13682                    "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13683                )],
13684            };
13685        }
13686    };
13687
13688    let Some(reason) = index_reason_for_state(state) else {
13689        return AgentDocIndexGate {
13690            db_path: Some(target.db_path),
13691            source_root: target.source_root,
13692            diagnostics: Vec::new(),
13693        };
13694    };
13695
13696    match apply_search_index_update(root, &target) {
13697        Ok(summary) => {
13698            // #gdbgatecold: the index was just rewritten, so any cached
13699            // pre-refresh inspection result for this scope (held by the
13700            // active lazily-backed `InspectScopeGuard`) is stale. Invalidate
13701            // the scope epoch so the next `inspect_read_only` re-reads the
13702            // fresh index.
13703            index::inspect_scope_invalidate_all();
13704            let diagnostics = vec![index_refresh_diagnostic(
13705                &target,
13706                reason,
13707                &summary,
13708                packet_label,
13709            )];
13710            AgentDocIndexGate {
13711                db_path: Some(target.db_path),
13712                source_root: target.source_root,
13713                diagnostics,
13714            }
13715        }
13716        Err(err) => {
13717            let diagnostics = vec![index_refresh_fallback_diagnostic(
13718                &target,
13719                reason,
13720                &err,
13721                packet_label,
13722            )];
13723            AgentDocIndexGate {
13724                db_path: None,
13725                source_root: target.source_root,
13726                diagnostics,
13727            }
13728        }
13729    }
13730}
13731
13732fn add_raw_source_file_nodes(
13733    root: &Path,
13734    source_root: &Path,
13735    graph: &mut TraversalGraphBuild,
13736    file_entries: &mut Vec<TraversalFileIndexEntry>,
13737) -> Result<()> {
13738    let mut entries = walk::walk_files(source_root)?;
13739    entries.sort_by(|left, right| left.path.cmp(&right.path));
13740    for entry in entries {
13741        let file = entry.path.to_string_lossy();
13742        let node = traversal_raw_source_file_node(root, file.as_ref());
13743        let entry = TraversalFileIndexEntry {
13744            handle: node.handle.clone(),
13745            tokens: traversal_node_tokens(&node),
13746            node: node.clone(),
13747        };
13748        graph.add_node(node);
13749        file_entries.push(entry);
13750    }
13751    Ok(())
13752}
13753
13754fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
13755    if scope_root.is_empty() {
13756        return true;
13757    }
13758    path == scope_root || path.starts_with(&format!("{scope_root}/"))
13759}
13760
13761fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
13762    let path = Path::new(file);
13763    if path.is_absolute() {
13764        return path.to_path_buf();
13765    }
13766    let source_candidate = source_root.join(path);
13767    if source_candidate.exists() {
13768        source_candidate
13769    } else {
13770        root.join(path)
13771    }
13772}
13773
13774fn cargo_import_alias_from_line(line: &str) -> Option<String> {
13775    let trimmed = line.trim();
13776    let rest = trimmed
13777        .strip_prefix("pub use ")
13778        .or_else(|| trimmed.strip_prefix("use "))
13779        .or_else(|| trimmed.strip_prefix("extern crate "))?;
13780    let alias = rest
13781        .split([':', ';', ' ', '\t'])
13782        .next()
13783        .unwrap_or_default()
13784        .trim();
13785    (!alias.is_empty()).then(|| alias.to_string())
13786}
13787
13788fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
13789    let mut aliases = BTreeSet::new();
13790    for entry in walk::walk_files(&package.package_root)? {
13791        if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
13792            continue;
13793        }
13794        let content = fs::read_to_string(&entry.path)
13795            .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
13796        aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
13797    }
13798    Ok(aliases)
13799}
13800
13801fn load_multiplicity_traversal_nodes(
13802    root: &Path,
13803    source_root: &Path,
13804    graph: &mut TraversalGraphBuild,
13805    file_handle_by_path: &HashMap<String, String>,
13806    multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
13807) -> Result<()> {
13808    let inventory = multiplicity::discover_cargo_inventory(source_root)?;
13809    let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
13810    for workspace in &inventory.workspaces {
13811        let node = traversal_cargo_workspace_node(root, workspace);
13812        workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
13813        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13814            handle: node.handle.clone(),
13815            tokens: traversal_node_tokens(&node),
13816            node: node.clone(),
13817        });
13818        graph.add_node(node);
13819    }
13820
13821    let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
13822    let mut package_nodes = Vec::new();
13823    for package in &inventory.packages {
13824        let node = traversal_cargo_package_node(root, package);
13825        package_handle_by_name
13826            .entry(package.name.clone())
13827            .or_default()
13828            .push(node.handle.clone());
13829        package_handle_by_name
13830            .entry(package.normalized_name.clone())
13831            .or_default()
13832            .push(node.handle.clone());
13833        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13834            handle: node.handle.clone(),
13835            tokens: traversal_node_tokens(&node),
13836            node: node.clone(),
13837        });
13838        graph.add_node(node.clone());
13839        package_nodes.push((package, node));
13840    }
13841
13842    for (package, node) in &package_nodes {
13843        if let Some(workspace_handle) =
13844            workspace_handle_by_root.get(&package.relative_workspace_root)
13845        {
13846            graph.add_edge(
13847                workspace_handle,
13848                &node.handle,
13849                "contains_package",
13850                Some("Cargo workspace member package".to_string()),
13851                1,
13852            );
13853        }
13854        let package_root = relativize_pathbuf(&package.package_root, root)
13855            .to_string_lossy()
13856            .replace('\\', "/");
13857        for (file, handle) in file_handle_by_path {
13858            if relative_path_inside_scope(file, &package_root) {
13859                graph.add_edge(
13860                    &node.handle,
13861                    handle,
13862                    "owns_file",
13863                    Some("Cargo package owns source file".to_string()),
13864                    1,
13865                );
13866            }
13867        }
13868        for dependency in &package.dependencies {
13869            if let Some(handles) = package_handle_by_name.get(&dependency.name)
13870                && handles.len() == 1
13871            {
13872                graph.add_edge(
13873                    &node.handle,
13874                    &handles[0],
13875                    "declares_dependency",
13876                    Some(format!("{} Cargo dependency", dependency.kind)),
13877                    1,
13878                );
13879            }
13880        }
13881        for alias in cargo_import_aliases(package)? {
13882            if let Some(handles) = package_handle_by_name.get(&alias)
13883                && handles.len() == 1
13884                && handles[0] != node.handle
13885            {
13886                graph.add_edge(
13887                    &node.handle,
13888                    &handles[0],
13889                    "uses_crate",
13890                    Some("Rust use/extern crate reference".to_string()),
13891                    1,
13892                );
13893                graph.add_edge(
13894                    &node.handle,
13895                    &handles[0],
13896                    "imports",
13897                    Some("Rust use/extern crate import".to_string()),
13898                    1,
13899                );
13900            }
13901        }
13902    }
13903
13904    Ok(())
13905}
13906
13907fn build_traversal_graph_source_with_options(
13908    root: &Path,
13909    path_hint: &Path,
13910    scope: Option<&str>,
13911    session_only: bool,
13912) -> Result<TraversalGraphBuild> {
13913    let mut graph = TraversalGraphBuild::default();
13914    let mut symbol_entries = Vec::new();
13915    let mut file_entries = Vec::new();
13916    let mut route_entries = Vec::new();
13917    let mut multiplicity_entries = Vec::new();
13918    let mut file_handle_by_path = HashMap::<String, String>::new();
13919    let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
13920    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13921        let (gate, _cache_detail) =
13922            prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
13923        graph.warnings.extend(gate.diagnostics);
13924        let gate_source_root = gate.source_root.clone();
13925
13926        match gate.db_path {
13927            Some(db_path) if db_path.exists() => {
13928                let db = index::IndexDb::open_read_only_resilient(&db_path)?;
13929                let file_paths = db.file_paths()?;
13930                for file in file_paths {
13931                    if traversal_path_is_generated_artifact(
13932                        root,
13933                        &gate_source_root,
13934                        Path::new(&file),
13935                    ) {
13936                        continue;
13937                    }
13938                    let node = traversal_file_node(root, &file);
13939                    let entry = TraversalFileIndexEntry {
13940                        handle: node.handle.clone(),
13941                        tokens: traversal_node_tokens(&node),
13942                        node: node.clone(),
13943                    };
13944                    if let Some(path) = entry.node.path.as_ref() {
13945                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
13946                    }
13947                    graph.add_node(node);
13948                    file_entries.push(entry);
13949                }
13950
13951                let symbols = db.all_symbols()?;
13952                let mut symbol_by_file_name_line = HashMap::new();
13953                let mut span_by_file_name_line = HashMap::new();
13954                let mut first_symbol_by_name = BTreeMap::<String, String>::new();
13955                let mut first_span_by_name = BTreeMap::<String, String>::new();
13956                let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
13957                let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
13958                for symbol in symbols.iter().filter(|symbol| {
13959                    !traversal_path_is_generated_artifact(
13960                        root,
13961                        &gate_source_root,
13962                        Path::new(&symbol.file),
13963                    )
13964                }) {
13965                    let node = traversal_symbol_node(root, symbol);
13966                    let file = relativize(&symbol.file, root);
13967                    symbol_by_file_name_line.insert(
13968                        format!("{file}:{}:{}", symbol.line, symbol.name),
13969                        node.handle.clone(),
13970                    );
13971                    first_symbol_by_name
13972                        .entry(symbol.name.clone())
13973                        .or_insert_with(|| node.handle.clone());
13974                    let entry = TraversalSymbolIndexEntry {
13975                        handle: node.handle.clone(),
13976                        tokens: traversal_node_tokens(&node),
13977                        node: node.clone(),
13978                    };
13979                    graph.add_node(node.clone());
13980                    if let Some(file_handle) = file_handle_by_path.get(&file) {
13981                        graph.add_edge(
13982                            file_handle,
13983                            &node.handle,
13984                            "defines",
13985                            Some("file defines symbol".to_string()),
13986                            1,
13987                        );
13988                    }
13989                    if !source_by_file.contains_key(&symbol.file) {
13990                        let source_path =
13991                            traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
13992                        source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
13993                    }
13994                    if let Some(Some(source)) = source_by_file.get(&symbol.file)
13995                        && let Some((ast_node, mut ast_entry)) =
13996                            traversal_ast_span_node(root, symbol, source, &symbols)
13997                    {
13998                        ast_entry.symbol_handle = node.handle.clone();
13999                        ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
14000                        span_by_file_name_line.insert(
14001                            format!("{file}:{}:{}", symbol.line, symbol.name),
14002                            ast_node.handle.clone(),
14003                        );
14004                        first_span_by_name
14005                            .entry(symbol.name.clone())
14006                            .or_insert_with(|| ast_node.handle.clone());
14007                        graph.add_node(ast_node.clone());
14008                        graph.add_edge(
14009                            &node.handle,
14010                            &ast_node.handle,
14011                            "has_ast_span",
14012                            Some("symbol projects to indexed AST span".to_string()),
14013                            1,
14014                        );
14015                        graph.add_edge(
14016                            &ast_node.handle,
14017                            &node.handle,
14018                            "represents_symbol",
14019                            Some("AST span represents indexed symbol".to_string()),
14020                            1,
14021                        );
14022                        ast_entries.push(ast_entry);
14023                    }
14024                    symbol_entries.push(entry);
14025                }
14026                link_ast_navigation_edges(&mut graph, &ast_entries);
14027                link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
14028
14029                if !bounded_session_projection {
14030                    for edge in db.all_stored_edges()? {
14031                        if traversal_path_is_generated_artifact(
14032                            root,
14033                            &gate_source_root,
14034                            Path::new(&edge.caller_file),
14035                        ) {
14036                            continue;
14037                        }
14038                        let caller_file = relativize(&edge.caller_file, root);
14039                        let caller_key =
14040                            format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
14041                        let Some(caller_handle) =
14042                            symbol_by_file_name_line.get(&caller_key).cloned()
14043                        else {
14044                            continue;
14045                        };
14046                        let callee_handle = if let Some(handle) =
14047                            first_symbol_by_name.get(&edge.callee_name)
14048                        {
14049                            handle.clone()
14050                        } else {
14051                            let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
14052                            let handle = node.handle.clone();
14053                            graph.add_node(node);
14054                            handle
14055                        };
14056                        graph.add_edge(
14057                            &caller_handle,
14058                            &callee_handle,
14059                            "calls",
14060                            Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
14061                            1,
14062                        );
14063                        if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
14064                            && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
14065                        {
14066                            graph.add_edge(
14067                                caller_span,
14068                                callee_span,
14069                                "calls",
14070                                Some(format!(
14071                                    "AST call site {}:{}",
14072                                    caller_file, edge.call_site_line
14073                                )),
14074                                1,
14075                            );
14076                        }
14077                    }
14078                }
14079
14080                for route in db.all_routes()? {
14081                    if traversal_path_is_generated_artifact(
14082                        root,
14083                        &gate_source_root,
14084                        Path::new(&route.file),
14085                    ) {
14086                        continue;
14087                    }
14088                    let node = traversal_route_node(root, &route);
14089                    let entry = TraversalRouteIndexEntry {
14090                        handle: node.handle.clone(),
14091                        tokens: traversal_node_tokens(&node),
14092                        node: node.clone(),
14093                    };
14094                    graph.add_node(node.clone());
14095                    if let Some(path) = node.path.as_ref()
14096                        && let Some(file_handle) = file_handle_by_path.get(path)
14097                    {
14098                        graph.add_edge(
14099                            file_handle,
14100                            &node.handle,
14101                            "defines_route",
14102                            Some("file declares route".to_string()),
14103                            1,
14104                        );
14105                    }
14106                    let handler_handle =
14107                        if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
14108                            handle.clone()
14109                        } else {
14110                            let node = traversal_unresolved_symbol_node(root, &route.handler_name);
14111                            let handle = node.handle.clone();
14112                            graph.add_node(node);
14113                            handle
14114                        };
14115                    graph.add_edge(
14116                        &entry.handle,
14117                        &handler_handle,
14118                        "handled_by",
14119                        Some("route handler reference".to_string()),
14120                        1,
14121                    );
14122                    if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
14123                        graph.add_edge(
14124                            &entry.handle,
14125                            handler_span,
14126                            "handled_by",
14127                            Some("route handler AST span".to_string()),
14128                            1,
14129                        );
14130                        graph.add_edge(
14131                            handler_span,
14132                            &entry.handle,
14133                            "handles_route",
14134                            Some("AST span handles route".to_string()),
14135                            1,
14136                        );
14137                    }
14138                    route_entries.push(entry);
14139                }
14140            }
14141            _ => {
14142                add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
14143                    .with_context(|| {
14144                    format!(
14145                        "loading raw source fallback nodes from {}",
14146                        gate_source_root.display()
14147                    )
14148                })?;
14149                for entry in &file_entries {
14150                    if let Some(path) = entry.node.path.as_ref() {
14151                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
14152                    }
14153                }
14154            }
14155        }
14156        load_multiplicity_traversal_nodes(
14157            root,
14158            &gate_source_root,
14159            &mut graph,
14160            &file_handle_by_path,
14161            &mut multiplicity_entries,
14162        )?;
14163    }
14164
14165    let code_lookup = TraversalCodeLookup::new(
14166        &symbol_entries,
14167        &file_entries,
14168        &route_entries,
14169        &multiplicity_entries,
14170    );
14171    load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
14172    Ok(graph)
14173}
14174
14175#[cfg(test)]
14176fn build_traversal_graph_source(
14177    root: &Path,
14178    path_hint: &Path,
14179    scope: Option<&str>,
14180) -> Result<TraversalGraphBuild> {
14181    build_traversal_graph_source_with_options(root, path_hint, scope, false)
14182}
14183
14184pub(crate) fn write_traversal_graph_store_with_options(
14185    root: &Path,
14186    path_hint: &Path,
14187    scope: Option<&str>,
14188    session_only: bool,
14189) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14190    let source_graph =
14191        build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14192    let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14193    let graph_db = graph_substrate_db_path(root, scope);
14194    let mut store = SqliteGraphStore::open(&graph_db)?;
14195    let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14196        .ok()
14197        .flatten()
14198        .or_else(|| graph_projection_content_hash(&projection));
14199    let refresh = store.replace_projection_with_version(
14200        scope.unwrap_or("root"),
14201        &projection,
14202        Some(GRAPH_PROJECTION_VERSION),
14203        source_watermark,
14204    )?;
14205    Ok((source_graph, refresh))
14206}
14207
14208pub(crate) fn write_traversal_graph_store(
14209    root: &Path,
14210    path_hint: &Path,
14211    scope: Option<&str>,
14212) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14213    write_traversal_graph_store_with_options(root, path_hint, scope, false)
14214}
14215
14216fn refresh_traversal_graph_store_with_options(
14217    root: &Path,
14218    path_hint: &Path,
14219    scope: Option<&str>,
14220    session_only: bool,
14221) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14222    let (source_graph, refresh) =
14223        write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14224    let graph_db = graph_substrate_db_path(root, scope);
14225    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14226    let mut graph = traversal_graph_from_store(root, &store)?;
14227    graph.warnings = source_graph.warnings;
14228    Ok((graph, refresh))
14229}
14230
14231fn refresh_traversal_graph_store(
14232    root: &Path,
14233    path_hint: &Path,
14234    scope: Option<&str>,
14235) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14236    refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14237}
14238
14239pub(crate) fn build_traversal_graph(
14240    root: &Path,
14241    path_hint: &Path,
14242    scope: Option<&str>,
14243) -> Result<TraversalGraphBuild> {
14244    let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14245    Ok(graph)
14246}
14247
14248fn traversal_query_kind_priority(kind: &str) -> usize {
14249    match kind {
14250        "backlog" => 0,
14251        "job_packet" => 1,
14252        "worker_result" => 2,
14253        "symbol" => 3,
14254        "ast_span" => 4,
14255        "file" => 5,
14256        "route" => 6,
14257        "cargo_package" => 7,
14258        "cargo_workspace" => 8,
14259        "session" => 9,
14260        "semantic_concept" => 10,
14261        "semantic_entity" => 11,
14262        _ => 12,
14263    }
14264}
14265
14266fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14267    let trimmed = query.trim();
14268    if trimmed.is_empty() {
14269        return None;
14270    }
14271    let kind_priority = traversal_query_kind_priority(&node.kind);
14272    if node.handle == trimmed {
14273        return Some((0, kind_priority, node.handle.clone()));
14274    }
14275    if node.path.as_deref() == Some(trimmed) {
14276        let path_priority = if node.kind == "file" {
14277            0
14278        } else {
14279            kind_priority.saturating_add(1)
14280        };
14281        return Some((1, path_priority, node.handle.clone()));
14282    }
14283    let normalized_backlog = trimmed.trim_start_matches('#');
14284    if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14285    {
14286        return Some((2, kind_priority, node.handle.clone()));
14287    }
14288    if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14289        return Some((3, kind_priority, node.handle.clone()));
14290    }
14291    None
14292}
14293
14294fn resolve_traversal_node<'a>(
14295    graph: &'a TraversalGraphBuild,
14296    query: &str,
14297) -> Option<&'a TraversalNode> {
14298    graph
14299        .nodes
14300        .values()
14301        .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14302        .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14303        .map(|(_, node)| node)
14304}
14305
14306fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14307    let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14308    for edge in edges {
14309        adj.entry(edge.from.clone())
14310            .or_default()
14311            .insert(edge.to.clone());
14312        adj.entry(edge.to.clone())
14313            .or_default()
14314            .insert(edge.from.clone());
14315    }
14316    adj.into_iter()
14317        .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14318        .collect()
14319}
14320
14321fn traversal_shortest_handles(
14322    edges: &[TraversalEdge],
14323    from: &str,
14324    to: &str,
14325) -> Option<Vec<String>> {
14326    if from == to {
14327        return Some(vec![from.to_string()]);
14328    }
14329    let adj = traversal_adjacency(edges);
14330    if !adj.contains_key(from) || !adj.contains_key(to) {
14331        return None;
14332    }
14333    let mut visited = BTreeSet::new();
14334    let mut queue = VecDeque::new();
14335    let mut parent = BTreeMap::<String, String>::new();
14336    visited.insert(from.to_string());
14337    queue.push_back(from.to_string());
14338    while let Some(current) = queue.pop_front() {
14339        if let Some(neighbors) = adj.get(&current) {
14340            for neighbor in neighbors {
14341                if visited.insert(neighbor.clone()) {
14342                    parent.insert(neighbor.clone(), current.clone());
14343                    if neighbor == to {
14344                        let mut path = vec![to.to_string()];
14345                        let mut cursor = to.to_string();
14346                        while let Some(prev) = parent.get(&cursor) {
14347                            path.push(prev.clone());
14348                            cursor = prev.clone();
14349                        }
14350                        path.reverse();
14351                        return Some(path);
14352                    }
14353                    queue.push_back(neighbor.clone());
14354                }
14355            }
14356        }
14357    }
14358    None
14359}
14360
14361fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14362    let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14363    for edge in edges {
14364        let neighbor = if edge.from == current {
14365            edge.to.as_str()
14366        } else if edge.to == current {
14367            edge.from.as_str()
14368        } else {
14369            continue;
14370        };
14371        let score = traversal_relation_score(edge, current);
14372        best_score_by_neighbor
14373            .entry(neighbor.to_string())
14374            .and_modify(|best| *best = (*best).max(score))
14375            .or_insert(score);
14376    }
14377    let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14378    ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14379        right_score
14380            .cmp(left_score)
14381            .then_with(|| left_handle.cmp(right_handle))
14382    });
14383    ranked.into_iter().map(|(handle, _)| handle).collect()
14384}
14385
14386fn traversal_neighborhood_handles(
14387    edges: &[TraversalEdge],
14388    origin: &str,
14389    depth: usize,
14390    limit: usize,
14391) -> BTreeSet<String> {
14392    let mut seen = BTreeSet::new();
14393    let mut queue = VecDeque::new();
14394    seen.insert(origin.to_string());
14395    queue.push_back((origin.to_string(), 0usize));
14396    while let Some((current, current_depth)) = queue.pop_front() {
14397        if current_depth >= depth {
14398            continue;
14399        }
14400        for neighbor in traversal_scored_neighbors(edges, &current) {
14401            if limit > 0 && seen.len() >= limit {
14402                return seen;
14403            }
14404            if seen.insert(neighbor.clone()) {
14405                queue.push_back((neighbor, current_depth + 1));
14406            }
14407        }
14408    }
14409    seen
14410}
14411
14412fn traversal_edges_between(
14413    handles: &BTreeSet<String>,
14414    edges: &[TraversalEdge],
14415) -> Vec<TraversalEdge> {
14416    edges
14417        .iter()
14418        .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14419        .cloned()
14420        .collect()
14421}
14422
14423fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14424    let mut result = Vec::new();
14425    for pair in path.windows(2) {
14426        if let Some(edge) = edges.iter().find(|edge| {
14427            (edge.from == pair[0] && edge.to == pair[1])
14428                || (edge.from == pair[1] && edge.to == pair[0])
14429        }) {
14430            result.push(edge.clone());
14431        }
14432    }
14433    result
14434}
14435
14436fn sorted_traversal_nodes<'a>(
14437    nodes: impl IntoIterator<Item = &'a TraversalNode>,
14438) -> Vec<TraversalNode> {
14439    let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14440    nodes.sort_by(|left, right| {
14441        left.kind
14442            .cmp(&right.kind)
14443            .then_with(|| left.label.cmp(&right.label))
14444            .then_with(|| left.path.cmp(&right.path))
14445            .then_with(|| left.handle.cmp(&right.handle))
14446    });
14447    nodes
14448}
14449
14450fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14451    let base = match edge.relation.as_str() {
14452        "mentions" => 100,
14453        "contains" => 80,
14454        "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14455        "contains_embedded_symbol" | "embedded_in_fence" => 77,
14456        "contains_markdown_block"
14457        | "contains_embedded_code"
14458        | "enclosing_module"
14459        | "enclosing_section" => 76,
14460        "calls" => {
14461            if edge.from == origin {
14462                70
14463            } else {
14464                65
14465            }
14466        }
14467        "handled_by" | "handles_route" => 68,
14468        "defines_route" => 62,
14469        "imports" => 62,
14470        "previous_sibling" | "next_sibling" => 54,
14471        "mentions_concept" | "mentions_entity" => 66,
14472        "semantic_relation" => 64,
14473        "tagged_concept" | "related_concept" => 58,
14474        "defines" => {
14475            if edge.from == origin {
14476                60
14477            } else {
14478                55
14479            }
14480        }
14481        _ => 10,
14482    };
14483    base + edge.weight
14484}
14485
14486fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
14487    match edge.relation.as_str() {
14488        "mentions" => "matched from backlog/session text".to_string(),
14489        "contains" => "contained in the selected session artifact".to_string(),
14490        "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
14491        "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
14492        "parent" => "parent AST span".to_string(),
14493        "child" => "child AST span".to_string(),
14494        "previous_sibling" => "previous AST sibling".to_string(),
14495        "next_sibling" => "next AST sibling".to_string(),
14496        "contains_markdown_block" => "Markdown section block".to_string(),
14497        "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
14498        "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
14499        "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
14500        "enclosing_module" => "nearest enclosing module".to_string(),
14501        "enclosing_section" => "nearest enclosing Markdown section".to_string(),
14502        "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
14503        "defines" => "file that defines the selected symbol".to_string(),
14504        "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
14505        "defines_route" => "file that declares the selected route".to_string(),
14506        "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
14507        "handled_by" => "route handled by the selected symbol".to_string(),
14508        "handles_route" => "route handled by the selected AST span".to_string(),
14509        "imports" => "import dependency from the selected package".to_string(),
14510        "mentions_concept" => "cached summary concept for the selected source".to_string(),
14511        "mentions_entity" => "cached summary entity for the selected source".to_string(),
14512        "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
14513        "tagged_concept" => "concept label attached to the selected entity".to_string(),
14514        "related_concept" => "co-occurring cached summary concept".to_string(),
14515        "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
14516        "calls" => "caller of the selected symbol".to_string(),
14517        other => format!("connected by {other}"),
14518    }
14519}
14520
14521fn traversal_recommendations(
14522    graph: &TraversalGraphBuild,
14523    origin: Option<&str>,
14524    shortest_path: Option<&[String]>,
14525    limit: usize,
14526) -> Vec<TraversalRecommendation> {
14527    let Some(origin) = origin else {
14528        return Vec::new();
14529    };
14530    let mut recommendations = Vec::new();
14531    let mut seen = BTreeSet::new();
14532
14533    if let Some(path) = shortest_path
14534        && path.len() > 1
14535        && path.first().is_some_and(|handle| handle == origin)
14536        && let Some(next) = graph.nodes.get(&path[1])
14537    {
14538        seen.insert(next.handle.clone());
14539        recommendations.push(TraversalRecommendation {
14540            handle: next.handle.clone(),
14541            kind: next.kind.clone(),
14542            label: next.label.clone(),
14543            reason: "next hop on shortest path".to_string(),
14544            score: 1_000,
14545            expand: next.expand.clone(),
14546        });
14547    }
14548
14549    let mut candidates = graph
14550        .edges
14551        .iter()
14552        .filter_map(|edge| {
14553            let neighbor = if edge.from == origin {
14554                edge.to.as_str()
14555            } else if edge.to == origin {
14556                edge.from.as_str()
14557            } else {
14558                return None;
14559            };
14560            let node = graph.nodes.get(neighbor)?;
14561            Some((traversal_relation_score(edge, origin), edge, node))
14562        })
14563        .collect::<Vec<_>>();
14564    candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
14565        right_score
14566            .cmp(left_score)
14567            .then_with(|| left.kind.cmp(&right.kind))
14568            .then_with(|| left.label.cmp(&right.label))
14569            .then_with(|| left.handle.cmp(&right.handle))
14570    });
14571
14572    let max = if limit == 0 { usize::MAX } else { limit };
14573    for (score, edge, node) in candidates {
14574        if recommendations.len() >= max {
14575            break;
14576        }
14577        if seen.insert(node.handle.clone()) {
14578            recommendations.push(TraversalRecommendation {
14579                handle: node.handle.clone(),
14580                kind: node.kind.clone(),
14581                label: node.label.clone(),
14582                reason: traversal_recommendation_reason(edge, origin),
14583                score,
14584                expand: node.expand.clone(),
14585            });
14586        }
14587    }
14588
14589    recommendations
14590}
14591
14592fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
14593    let scale = nodes.saturating_add(edges);
14594    if scale <= 80 {
14595        ExplorationBudget {
14596            project_size: "small".to_string(),
14597            max_source_windows: 8,
14598            lines_per_window: 96,
14599            relationship_limit: 40,
14600        }
14601    } else if scale <= 800 {
14602        ExplorationBudget {
14603            project_size: "medium".to_string(),
14604            max_source_windows: 6,
14605            lines_per_window: 80,
14606            relationship_limit: 32,
14607        }
14608    } else {
14609        ExplorationBudget {
14610            project_size: "large".to_string(),
14611            max_source_windows: 4,
14612            lines_per_window: 64,
14613            relationship_limit: 24,
14614        }
14615    }
14616}
14617
14618fn exploration_node_label(node: &TraversalNode) -> String {
14619    format!("{}:{}", node.kind, node.label)
14620}
14621
14622fn exploration_source_window_for_node(
14623    root: &Path,
14624    node: &TraversalNode,
14625    budget: &ExplorationBudget,
14626) -> Option<ExplorationSourceWindow> {
14627    let file = node.path.as_ref()?;
14628    let anchor = node
14629        .line
14630        .and_then(|line| usize::try_from(line).ok())
14631        .and_then(|line| line.checked_add(1))
14632        .unwrap_or(1);
14633    let context_before = budget.lines_per_window / 3;
14634    let start = anchor.saturating_sub(context_before).max(1);
14635    let end = start
14636        .saturating_add(budget.lines_per_window)
14637        .saturating_sub(1);
14638    let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
14639    Some(ExplorationSourceWindow {
14640        handle,
14641        file: file.clone(),
14642        start,
14643        end,
14644        reason: format!("cluster around {}", exploration_node_label(node)),
14645        expand: source_read_command(root, file, start, budget.lines_per_window),
14646    })
14647}
14648
14649fn build_exploration_packet(
14650    root: &Path,
14651    totals: &TraversalTotals,
14652    selected_nodes: &[TraversalNode],
14653    selected_edges: &[TraversalEdge],
14654) -> ExplorationPacket {
14655    let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
14656    let node_by_handle = selected_nodes
14657        .iter()
14658        .map(|node| (node.handle.as_str(), node))
14659        .collect::<BTreeMap<_, _>>();
14660    let relationship_map = selected_edges
14661        .iter()
14662        .take(budget.relationship_limit)
14663        .filter_map(|edge| {
14664            let from = node_by_handle.get(edge.from.as_str())?;
14665            let to = node_by_handle.get(edge.to.as_str())?;
14666            Some(ExplorationRelation {
14667                from: exploration_node_label(from),
14668                relation: edge.relation.clone(),
14669                to: exploration_node_label(to),
14670                label: edge.label.clone(),
14671            })
14672        })
14673        .collect::<Vec<_>>();
14674
14675    let mut seen_windows = BTreeSet::new();
14676    let mut source_windows = Vec::new();
14677    for node in selected_nodes {
14678        if source_windows.len() >= budget.max_source_windows {
14679            break;
14680        }
14681        let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
14682            continue;
14683        };
14684        let key = (window.file.clone(), window.start, window.end);
14685        if seen_windows.insert(key) {
14686            source_windows.push(window);
14687        }
14688    }
14689
14690    ExplorationPacket {
14691        budget,
14692        relationship_map,
14693        source_windows,
14694        worker_context: Vec::new(),
14695        no_reread_guidance:
14696            "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
14697                .to_string(),
14698    }
14699}
14700
14701pub(crate) fn traversal_report(
14702    root: &Path,
14703    scope: Option<&str>,
14704    graph: TraversalGraphBuild,
14705    query: Option<&str>,
14706    target: Option<&str>,
14707    depth: usize,
14708    limit: usize,
14709) -> Result<TraversalReport> {
14710    let totals = TraversalTotals {
14711        nodes: graph.nodes.len(),
14712        edges: graph.edges.len(),
14713    };
14714    let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
14715    let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
14716    if let Some(query) = query
14717        && origin_node.is_none()
14718    {
14719        bail!("traversal node not found: {}", query);
14720    }
14721    if let Some(target) = target
14722        && target_node.is_none()
14723    {
14724        bail!("traversal target not found: {}", target);
14725    }
14726
14727    let (mode, selected_nodes, selected_edges, shortest_path) =
14728        if let (Some(origin), Some(target)) = (origin_node, target_node) {
14729            if let Some(handles) =
14730                traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
14731            {
14732                let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
14733                let nodes = handles
14734                    .iter()
14735                    .filter_map(|handle| graph.nodes.get(handle).cloned())
14736                    .collect::<Vec<_>>();
14737                let edges = traversal_path_edges(&handles, &graph.edges);
14738                let path = TraversalPathReport {
14739                    from: origin.clone(),
14740                    to: target.clone(),
14741                    hops: handles.len().saturating_sub(1),
14742                    nodes: nodes.clone(),
14743                    edges: edges.clone(),
14744                };
14745                (
14746                    "path".to_string(),
14747                    nodes,
14748                    traversal_edges_between(&handle_set, &graph.edges),
14749                    Some(path),
14750                )
14751            } else {
14752                (
14753                    "path".to_string(),
14754                    vec![origin.clone(), target.clone()],
14755                    Vec::new(),
14756                    None,
14757                )
14758            }
14759        } else if let Some(origin) = origin_node {
14760            let handles =
14761                traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
14762            let nodes =
14763                sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
14764            let edges = traversal_edges_between(&handles, &graph.edges);
14765            ("neighborhood".to_string(), nodes, edges, None)
14766        } else {
14767            let mut nodes = sorted_traversal_nodes(graph.nodes.values());
14768            let truncated_nodes = limit > 0 && nodes.len() > limit;
14769            if truncated_nodes {
14770                nodes.truncate(limit);
14771            }
14772            let handles = nodes
14773                .iter()
14774                .map(|node| node.handle.clone())
14775                .collect::<BTreeSet<_>>();
14776            let mut edges = traversal_edges_between(&handles, &graph.edges);
14777            let truncated_edges = limit > 0 && edges.len() > limit;
14778            if truncated_edges {
14779                edges.truncate(limit);
14780            }
14781            ("export".to_string(), nodes, edges, None)
14782        };
14783
14784    let shortest_handles = shortest_path.as_ref().map(|path| {
14785        path.nodes
14786            .iter()
14787            .map(|node| node.handle.clone())
14788            .collect::<Vec<_>>()
14789    });
14790    let recommendations = traversal_recommendations(
14791        &graph,
14792        origin_node.map(|node| node.handle.as_str()),
14793        shortest_handles.as_deref(),
14794        if limit == 0 { 10 } else { limit.min(10) },
14795    );
14796    let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
14797    let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
14798
14799    Ok(TraversalReport {
14800        root: root.to_string_lossy().to_string(),
14801        scope: scope.map(str::to_string),
14802        mode,
14803        totals,
14804        query: query.map(str::to_string),
14805        target: target.map(str::to_string),
14806        nodes: selected_nodes,
14807        edges: selected_edges,
14808        shortest_path,
14809        recommendations,
14810        exploration,
14811        truncated,
14812        warnings: graph.warnings,
14813    })
14814}
14815
14816fn html_escape(input: &str) -> String {
14817    input
14818        .replace('&', "&amp;")
14819        .replace('<', "&lt;")
14820        .replace('>', "&gt;")
14821        .replace('"', "&quot;")
14822        .replace('\'', "&#39;")
14823}
14824
14825pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
14826    let json = serde_json::to_string(report)?.replace("</", "<\\/");
14827    let mut html = String::new();
14828    html.push_str(
14829        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
14830    );
14831    html.push_str(
14832        r#"<style>
14833:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
14834@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
14835*{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}}
14836</style>"#,
14837    );
14838    html.push_str("</head><body>");
14839    html.push_str("<div class=\"page\">");
14840    html.push_str(&format!(
14841        "<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>",
14842        html_escape(&report.mode),
14843        report.nodes.len(),
14844        report.totals.nodes,
14845        report.edges.len(),
14846        report.totals.edges
14847    ));
14848    html.push_str(
14849        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>"#,
14850    );
14851    html.push_str("<script id=\"graph-data\" type=\"application/json\">");
14852    html.push_str(&json);
14853    html.push_str(
14854        r##"</script><script>
14855const report = JSON.parse(document.getElementById("graph-data").textContent);
14856const svg = document.getElementById("graph-canvas");
14857const list = document.getElementById("node-list");
14858const selected = document.getElementById("selected");
14859const filter = document.getElementById("filter");
14860const legend = document.getElementById("legend");
14861const nodes = report.nodes.map((node, index) => ({...node, index}));
14862const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
14863const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
14864const colorByKind = new Map([
14865  ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
14866  ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
14867  ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
14868  ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
14869]);
14870function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
14871function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
14872function text(value){ return value == null ? "" : String(value); }
14873function matches(node, query){
14874  if (!query) return true;
14875  const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
14876  return haystack.includes(query);
14877}
14878function layout(){
14879  const rect = svg.getBoundingClientRect();
14880  const width = rect.width || 900;
14881  const height = rect.height || 650;
14882  const cx = width / 2;
14883  const cy = height / 2;
14884  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14885  const counts = new Map();
14886  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
14887  const offsets = new Map();
14888  for (const node of nodes) {
14889    const group = kinds.indexOf(node.kind);
14890    const index = offsets.get(node.kind) || 0;
14891    offsets.set(node.kind, index + 1);
14892    const groupCount = counts.get(node.kind) || 1;
14893    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
14894    const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
14895    node.x = cx + Math.cos(angle) * ring;
14896    node.y = cy + Math.sin(angle) * ring;
14897  }
14898}
14899function draw(){
14900  const query = filter.value.trim().toLowerCase();
14901  const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
14902  svg.innerHTML = "";
14903  for (const edge of edges) {
14904    if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
14905    const from = nodeByHandle.get(edge.from);
14906    const to = nodeByHandle.get(edge.to);
14907    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
14908    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
14909    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
14910    line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
14911    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
14912    svg.appendChild(line);
14913  }
14914  for (const node of nodes) {
14915    if (!visible.has(node.handle)) continue;
14916    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
14917    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
14918    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
14919    circle.setAttribute("fill", color(node.kind));
14920    circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
14921    circle.addEventListener("click", () => selectNode(node));
14922    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
14923    svg.appendChild(circle);
14924    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
14925    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
14926    label.setAttribute("class", "node-label");
14927    label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
14928    svg.appendChild(label);
14929  }
14930  renderList(query);
14931}
14932function renderLegend(){
14933  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14934  legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">&#9679;</b> ${kind}</span>`).join("");
14935}
14936function renderList(query){
14937  const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
14938  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("");
14939  for (const row of list.querySelectorAll(".row")) {
14940    row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
14941  }
14942}
14943function selectNode(node){
14944  const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
14945  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>`;
14946}
14947function escapeHtml(value){
14948  return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));
14949}
14950filter.addEventListener("input", draw);
14951window.addEventListener("resize", () => { layout(); draw(); });
14952renderLegend();
14953layout();
14954draw();
14955if (nodes.length) selectNode(nodes[0]);
14956</script></div></body></html>"##,
14957    );
14958    Ok(html)
14959}
14960
14961fn semantic_related_report_from_store(
14962    root: &Path,
14963    scope: Option<&str>,
14964    query: &str,
14965    limit: usize,
14966    kind: SemanticRelatedKind,
14967    store: &impl GraphStore,
14968) -> Result<SemanticRelatedReport> {
14969    if query.trim().is_empty() {
14970        bail!("semantic query cannot be empty");
14971    }
14972
14973    let query_embedding = semantic_embedding(query);
14974    let node_kinds: &[&str] = match kind {
14975        SemanticRelatedKind::Concept => &["semantic_concept"],
14976        SemanticRelatedKind::Entity => &["semantic_entity"],
14977        SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
14978    };
14979
14980    let mut items = Vec::new();
14981    for node_kind in node_kinds {
14982        for node in store.nodes_by_kind(node_kind)? {
14983            let Some(embedding) = node
14984                .properties
14985                .get("embedding")
14986                .and_then(|value| parse_semantic_embedding_property(value))
14987            else {
14988                continue;
14989            };
14990            let score = semantic_cosine(&query_embedding, &embedding);
14991            items.push(SemanticRelatedItem {
14992                handle: node
14993                    .properties
14994                    .get("handle")
14995                    .cloned()
14996                    .unwrap_or_else(|| node.id.clone()),
14997                kind: node.kind,
14998                label: node.label,
14999                score,
15000                file_path: node
15001                    .properties
15002                    .get("source_file")
15003                    .or_else(|| node.properties.get("path"))
15004                    .cloned(),
15005                source_symbol: node.properties.get("source_symbol").cloned(),
15006                detail: node
15007                    .properties
15008                    .get("description")
15009                    .or_else(|| node.properties.get("detail"))
15010                    .cloned(),
15011                expand: node
15012                    .properties
15013                    .get("expand")
15014                    .cloned()
15015                    .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
15016            });
15017        }
15018    }
15019
15020    items.sort_by(|left, right| {
15021        right
15022            .score
15023            .partial_cmp(&left.score)
15024            .unwrap_or(Ordering::Equal)
15025            .then_with(|| left.kind.cmp(&right.kind))
15026            .then_with(|| left.label.cmp(&right.label))
15027            .then_with(|| left.handle.cmp(&right.handle))
15028    });
15029    if limit > 0 && items.len() > limit {
15030        items.truncate(limit);
15031    }
15032
15033    let mut warnings = Vec::new();
15034    if items.is_empty() {
15035        warnings.push(
15036            "no semantic graph rows found; run `tsift summarize --extract <path>` first"
15037                .to_string(),
15038        );
15039    }
15040
15041    Ok(SemanticRelatedReport {
15042        root: root.to_string_lossy().to_string(),
15043        scope: scope.map(str::to_string),
15044        query: query.to_string(),
15045        embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
15046        count: items.len(),
15047        items,
15048        warnings,
15049    })
15050}
15051
15052fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
15053    Ok(store.nodes_by_kind("semantic_concept")?.len()
15054        + store.nodes_by_kind("semantic_entity")?.len())
15055}
15056
15057fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
15058    if limit == 0 {
15059        return 0;
15060    }
15061    limit.saturating_mul(4).clamp(
15062        GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
15063        GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
15064    )
15065}
15066
15067fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
15068    if limit == 0 {
15069        return usize::MAX;
15070    }
15071    limit.saturating_mul(3).max(limit).max(seed_count)
15072}
15073
15074fn graph_db_semantic_edge_other_id<'a>(
15075    edge: &'a SubstrateGraphEdge,
15076    current_id: &str,
15077) -> Option<&'a str> {
15078    if edge.from_id == current_id {
15079        Some(edge.to_id.as_str())
15080    } else if edge.to_id == current_id {
15081        Some(edge.from_id.as_str())
15082    } else {
15083        None
15084    }
15085}
15086
15087fn graph_db_semantic_edge_score(edge: &SubstrateGraphEdge, current_id: &str) -> i64 {
15088    let mut score = resolution::edge_kind_rank_score(&edge.kind).saturating_mul(10);
15089    score += if edge.from_id == current_id { 8 } else { 4 };
15090    score += match edge.kind.as_str() {
15091        "mentions_concept" | "mentions_entity" | "tagged_concept" | "tagged_entity"
15092        | "related_concept" => 30,
15093        "semantic_relation" => 28,
15094        "calls" => 24,
15095        "mentions" => 22,
15096        "requests_context" | "scopes_context" | "scopes_source" | "explains_result" => 18,
15097        "defines" | "contains" | "belongs_to" => 12,
15098        _ => 0,
15099    };
15100    score
15101}
15102
15103fn graph_db_semantic_seeded_neighborhood(
15104    store: &impl GraphStore,
15105    seed_ids: &[String],
15106    depth: usize,
15107    limit: usize,
15108) -> Result<GraphDbSemanticSeededSubgraph> {
15109    let seed_rank = seed_ids
15110        .iter()
15111        .enumerate()
15112        .map(|(idx, seed)| (seed.clone(), idx))
15113        .collect::<BTreeMap<_, _>>();
15114    let mut nodes = BTreeMap::<String, SubstrateGraphNode>::new();
15115    let mut edges = BTreeMap::<String, SubstrateGraphEdge>::new();
15116    let mut node_score_by_id = BTreeMap::<String, i64>::new();
15117    let mut queue = VecDeque::<(String, usize)>::new();
15118    let mut seen_at_depth = BTreeMap::<String, usize>::new();
15119    let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
15120    let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
15121    let mut skipped_by_edge_cap = 0usize;
15122    let mut skipped_by_node_cap = 0usize;
15123    let mut diagnostics = vec![
15124        "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
15125        "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(),
15126        format!(
15127            "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
15128            if edge_scan_cap == 0 {
15129                "unbounded".to_string()
15130            } else {
15131                edge_scan_cap.to_string()
15132            },
15133            if node_discovery_cap == usize::MAX {
15134                "unbounded".to_string()
15135            } else {
15136                node_discovery_cap.to_string()
15137            }
15138        ),
15139    ];
15140
15141    for (idx, seed_id) in seed_ids.iter().enumerate() {
15142        if let Some(node) = store.node(seed_id)? {
15143            nodes.entry(seed_id.clone()).or_insert(node);
15144            node_score_by_id
15145                .entry(seed_id.clone())
15146                .or_insert(1_000_000i64.saturating_sub(idx as i64));
15147            queue.push_back((seed_id.clone(), 0));
15148            seen_at_depth.entry(seed_id.clone()).or_insert(0);
15149        } else {
15150            diagnostics.push(format!(
15151                "semantic seed {seed_id} was not present in the graph store"
15152            ));
15153        }
15154    }
15155
15156    while let Some((current_id, current_depth)) = queue.pop_front() {
15157        if current_depth >= depth {
15158            continue;
15159        }
15160
15161        let mut expansion_edges_by_key = BTreeMap::<String, SubstrateGraphEdge>::new();
15162        for edge in store.outgoing_edges(&current_id, None)? {
15163            expansion_edges_by_key
15164                .entry(graph_db_edge_key(&edge))
15165                .or_insert(edge);
15166        }
15167        for edge in store.incident_edges(&current_id, None)? {
15168            expansion_edges_by_key
15169                .entry(graph_db_edge_key(&edge))
15170                .or_insert(edge);
15171        }
15172        let mut expansion_edges = expansion_edges_by_key.into_values().collect::<Vec<_>>();
15173        expansion_edges.sort_by(|left, right| {
15174            graph_db_semantic_edge_score(right, &current_id)
15175                .cmp(&graph_db_semantic_edge_score(left, &current_id))
15176                .then_with(|| graph_db_edge_key(left).cmp(&graph_db_edge_key(right)))
15177        });
15178        if edge_scan_cap > 0 && expansion_edges.len() > edge_scan_cap {
15179            skipped_by_edge_cap += expansion_edges.len() - edge_scan_cap;
15180            expansion_edges.truncate(edge_scan_cap);
15181        }
15182
15183        for edge in expansion_edges {
15184            let Some(other_id) = graph_db_semantic_edge_other_id(&edge, &current_id) else {
15185                continue;
15186            };
15187            let other_known = nodes.contains_key(other_id);
15188            if !other_known && nodes.len() >= node_discovery_cap {
15189                skipped_by_node_cap += 1;
15190                continue;
15191            }
15192            let other_id = other_id.to_string();
15193            let edge_score = graph_db_semantic_edge_score(&edge, &current_id)
15194                .saturating_add((depth.saturating_sub(current_depth) as i64).saturating_mul(5));
15195            node_score_by_id
15196                .entry(other_id.clone())
15197                .and_modify(|score| *score = (*score).max(edge_score))
15198                .or_insert(edge_score);
15199            let edge_key = graph_db_edge_key(&edge);
15200            edges.entry(edge_key).or_insert_with(|| edge.clone());
15201            if let std::collections::btree_map::Entry::Vacant(entry) = nodes.entry(other_id.clone())
15202                && let Some(node) = store.node(&other_id)?
15203            {
15204                entry.insert(node);
15205            }
15206            if !nodes.contains_key(&other_id) {
15207                continue;
15208            }
15209            let next_depth = current_depth + 1;
15210            let should_queue = seen_at_depth
15211                .get(&other_id)
15212                .is_none_or(|seen_depth| next_depth < *seen_depth);
15213            if should_queue {
15214                seen_at_depth.insert(other_id.clone(), next_depth);
15215                queue.push_back((other_id, next_depth));
15216            }
15217        }
15218    }
15219
15220    if skipped_by_edge_cap > 0 {
15221        diagnostics.push(format!(
15222            "semantic-seeded expansion skipped {skipped_by_edge_cap} lower-scoring incident/outgoing edge(s) after per-node caps"
15223        ));
15224    }
15225    if skipped_by_node_cap > 0 {
15226        diagnostics.push(format!(
15227            "semantic-seeded expansion skipped {skipped_by_node_cap} lower-scoring node discovery edge(s) after the discovery cap"
15228        ));
15229    }
15230
15231    let mut nodes = nodes.into_values().collect::<Vec<_>>();
15232    nodes.sort_by(|left, right| {
15233        seed_rank
15234            .get(&left.id)
15235            .copied()
15236            .unwrap_or(usize::MAX)
15237            .cmp(&seed_rank.get(&right.id).copied().unwrap_or(usize::MAX))
15238            .then_with(|| {
15239                node_score_by_id
15240                    .get(&right.id)
15241                    .copied()
15242                    .unwrap_or_default()
15243                    .cmp(&node_score_by_id.get(&left.id).copied().unwrap_or_default())
15244            })
15245            .then(left.id.cmp(&right.id))
15246    });
15247
15248    let before_limit = nodes.len();
15249    let truncated = limit > 0 && nodes.len() > limit;
15250    if truncated {
15251        nodes.truncate(limit);
15252        diagnostics.push(format!(
15253            "semantic-seeded neighborhood truncated from {before_limit} to {limit} node(s)"
15254        ));
15255    }
15256
15257    let node_ids = nodes
15258        .iter()
15259        .map(|node| node.id.as_str())
15260        .collect::<BTreeSet<_>>();
15261    let mut edges = edges
15262        .into_values()
15263        .filter(|edge| {
15264            node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
15265        })
15266        .collect::<Vec<_>>();
15267    edges.sort_by_key(graph_db_edge_key);
15268
15269    Ok(GraphDbSemanticSeededSubgraph {
15270        nodes,
15271        edges,
15272        truncated,
15273        diagnostics,
15274    })
15275}
15276
15277#[allow(clippy::too_many_arguments)]
15278fn cmd_semantic_related(
15279    query: &str,
15280    path: &Path,
15281    scope: Option<&str>,
15282    limit: usize,
15283    kind: SemanticRelatedKind,
15284    json_output: bool,
15285    compact: bool,
15286    pretty: bool,
15287    terse: bool,
15288    schema: bool,
15289) -> Result<()> {
15290    let root = lint::resolve_project_root_or_canonical_path(path)?;
15291    write_traversal_graph_store(&root, path, scope)?;
15292    let graph_db = graph_substrate_db_path(&root, scope);
15293    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
15294    let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
15295    if let Some(recovery) = store.read_only_recovery() {
15296        report
15297            .warnings
15298            .push(graph_db_read_recovery_diagnostic(recovery));
15299    }
15300
15301    if json_output {
15302        println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
15303    } else if compact {
15304        for item in &report.items {
15305            println!(
15306                "{:.3}\t{}\t{}\t{}",
15307                item.score, item.kind, item.label, item.handle
15308            );
15309        }
15310        for warning in &report.warnings {
15311            eprintln!("warning: {warning}");
15312        }
15313    } else {
15314        println!(
15315            "Related semantic graph rows for {:?} ({})",
15316            report.query, report.embedding_model
15317        );
15318        for item in &report.items {
15319            println!(
15320                "  {:.3} [{}] {} ({})",
15321                item.score, item.kind, item.label, item.handle
15322            );
15323            if let Some(detail) = &item.detail {
15324                println!("      {}", detail);
15325            }
15326            if let Some(file_path) = &item.file_path {
15327                println!("      file: {}", file_path);
15328            }
15329            println!("      expand: {}", item.expand);
15330        }
15331        for warning in &report.warnings {
15332            eprintln!("warning: {warning}");
15333        }
15334    }
15335
15336    Ok(())
15337}
15338
15339#[derive(Serialize)]
15340struct SourceLinePreview {
15341    line: usize,
15342    text: String,
15343}
15344
15345#[derive(Serialize)]
15346pub(crate) struct SourceRangePreview {
15347    start: usize,
15348    end: usize,
15349    total_lines: usize,
15350    truncated_before: bool,
15351    truncated_after: bool,
15352}
15353
15354#[derive(Serialize)]
15355struct SourceExpandCommands {
15356    #[serde(skip_serializing_if = "Option::is_none")]
15357    before: Option<String>,
15358    #[serde(skip_serializing_if = "Option::is_none")]
15359    after: Option<String>,
15360    #[serde(skip_serializing_if = "Option::is_none")]
15361    body: Option<String>,
15362    file: String,
15363    #[serde(skip_serializing_if = "Option::is_none")]
15364    markdown_ast: Option<String>,
15365}
15366
15367#[derive(Serialize)]
15368struct SourceSymbolRef {
15369    handle: String,
15370    name: String,
15371    kind: String,
15372    language: String,
15373    file: String,
15374    line: usize,
15375    #[serde(skip_serializing_if = "Option::is_none")]
15376    end_line: Option<usize>,
15377    #[serde(skip_serializing_if = "Option::is_none")]
15378    signature: Option<String>,
15379    #[serde(skip_serializing_if = "Option::is_none")]
15380    span: Option<AstSpanPreview>,
15381    expand: String,
15382}
15383
15384#[derive(Serialize)]
15385struct SourceSummaryRef {
15386    handle: String,
15387    symbol_name: String,
15388    file_path: String,
15389    summary: String,
15390    expand: String,
15391}
15392
15393#[derive(Serialize)]
15394struct SourceReadReport {
15395    handle: String,
15396    root: String,
15397    file: String,
15398    range: SourceRangePreview,
15399    preview: Vec<SourceLinePreview>,
15400    symbols: Vec<SourceSymbolRef>,
15401    summaries: Vec<SourceSummaryRef>,
15402    #[serde(skip_serializing_if = "Option::is_none")]
15403    markdown: Option<SourceReadMarkdownProjection>,
15404    expand: SourceExpandCommands,
15405    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15406    warnings: Vec<String>,
15407}
15408
15409#[derive(Serialize)]
15410struct SourceReadAstExpandCommands {
15411    window: String,
15412    file_window: String,
15413    #[serde(skip_serializing_if = "Option::is_none")]
15414    markdown_ast: Option<String>,
15415}
15416
15417#[derive(Serialize)]
15418struct SourceReadAstReport {
15419    handle: String,
15420    root: String,
15421    file: String,
15422    range: SourceRangePreview,
15423    symbols: Vec<SourceSymbolRef>,
15424    summaries: Vec<SourceSummaryRef>,
15425    #[serde(skip_serializing_if = "Option::is_none")]
15426    markdown: Option<SourceReadMarkdownProjection>,
15427    expand: SourceReadAstExpandCommands,
15428    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15429    warnings: Vec<String>,
15430}
15431
15432#[derive(Serialize)]
15433struct SymbolReadTarget {
15434    handle: String,
15435    name: String,
15436    kind: String,
15437    language: String,
15438    file: String,
15439    line: usize,
15440    #[serde(skip_serializing_if = "Option::is_none")]
15441    end_line: Option<usize>,
15442    #[serde(skip_serializing_if = "Option::is_none")]
15443    signature: Option<String>,
15444    #[serde(skip_serializing_if = "Option::is_none")]
15445    parent_module: Option<String>,
15446    #[serde(skip_serializing_if = "Option::is_none")]
15447    visibility: Option<String>,
15448    #[serde(skip_serializing_if = "Option::is_none")]
15449    span: Option<AstSpanPreview>,
15450}
15451
15452#[derive(Serialize)]
15453struct SymbolReadExpandCommands {
15454    source_window: String,
15455    #[serde(skip_serializing_if = "Option::is_none")]
15456    body: Option<String>,
15457    file: String,
15458    explain: String,
15459    callers: String,
15460    callees: String,
15461    #[serde(skip_serializing_if = "Option::is_none")]
15462    markdown_ast: Option<String>,
15463}
15464
15465#[derive(Serialize)]
15466struct SymbolReadReport {
15467    handle: String,
15468    root: String,
15469    query: String,
15470    symbol: SymbolReadTarget,
15471    range: SourceRangePreview,
15472    body: Vec<SourceLinePreview>,
15473    child_symbols: Vec<SourceSymbolRef>,
15474    summaries: Vec<SourceSummaryRef>,
15475    expand: SymbolReadExpandCommands,
15476    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15477    warnings: Vec<String>,
15478}
15479
15480#[derive(Clone)]
15481pub(crate) struct MarkdownAstRawNode {
15482    handle: String,
15483    span_handle: String,
15484    name: String,
15485    kind: String,
15486    block_kind: String,
15487    node_kind: String,
15488    start_byte: usize,
15489    end_byte: usize,
15490    body_start_byte: Option<usize>,
15491    body_end_byte: Option<usize>,
15492}
15493
15494#[derive(Clone)]
15495pub(crate) struct MarkdownAstProjection {
15496    source_hash: String,
15497    nodes: Vec<MarkdownAstRawNode>,
15498    parse_duration_micros: u128,
15499    cache_hit: bool,
15500}
15501
15502#[derive(Clone)]
15503struct MarkdownAstCacheEntry {
15504    source_hash: String,
15505    nodes: Vec<MarkdownAstRawNode>,
15506    parse_duration_micros: u128,
15507}
15508
15509static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15510    OnceLock::new();
15511
15512#[derive(Serialize, Clone)]
15513struct MarkdownAstNodeMetadata {
15514    #[serde(skip_serializing_if = "Option::is_none")]
15515    heading_level: Option<usize>,
15516    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15517    section_path: Vec<String>,
15518    #[serde(skip_serializing_if = "Option::is_none")]
15519    section_handle: Option<String>,
15520    #[serde(skip_serializing_if = "Option::is_none")]
15521    list_depth: Option<usize>,
15522    #[serde(skip_serializing_if = "Option::is_none")]
15523    list_marker: Option<String>,
15524    #[serde(skip_serializing_if = "Option::is_none")]
15525    list_order: Option<usize>,
15526    #[serde(skip_serializing_if = "Option::is_none")]
15527    fence_language: Option<String>,
15528    #[serde(skip_serializing_if = "Option::is_none")]
15529    fence_marker: Option<String>,
15530    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15531    embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15532}
15533
15534#[derive(Serialize, Clone)]
15535struct MarkdownAstNodeExpand {
15536    source_window: String,
15537    source_body: String,
15538    symbol_read: String,
15539    edit_intents: String,
15540}
15541
15542#[derive(Serialize, Clone)]
15543struct MarkdownAstCacheReport {
15544    source_hash: String,
15545    cache_hit: bool,
15546    parse_duration_micros: u128,
15547    node_count: usize,
15548    section_count: usize,
15549    list_item_count: usize,
15550    code_block_count: usize,
15551}
15552
15553#[derive(Serialize, Clone)]
15554struct MarkdownAstPhaseTiming {
15555    name: String,
15556    duration_micros: u128,
15557    detail: String,
15558}
15559
15560#[derive(Serialize, Clone)]
15561struct MarkdownAstOutlineEntry {
15562    handle: String,
15563    span_handle: String,
15564    name: String,
15565    kind: String,
15566    block_kind: String,
15567    line: usize,
15568    end_line: usize,
15569    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15570    section_path: Vec<String>,
15571    child_count: usize,
15572    expand: String,
15573}
15574
15575#[derive(Serialize, Clone)]
15576struct MarkdownAstProjectionPreview {
15577    mode: String,
15578    total_nodes: usize,
15579    returned_nodes: usize,
15580    omitted_nodes: usize,
15581    selected_node: Option<String>,
15582    cache: MarkdownAstCacheReport,
15583    outline: Vec<MarkdownAstOutlineEntry>,
15584    phase_timings: Vec<MarkdownAstPhaseTiming>,
15585}
15586
15587#[derive(Serialize)]
15588struct SourceReadMarkdownProjection {
15589    handle: String,
15590    mode: String,
15591    total_nodes: usize,
15592    visible_nodes: usize,
15593    outline: Vec<MarkdownAstOutlineEntry>,
15594    expand: String,
15595}
15596
15597#[derive(Serialize, Clone)]
15598struct SourceByteRangePreview {
15599    start: usize,
15600    end: usize,
15601}
15602
15603#[derive(Serialize, Clone)]
15604struct MarkdownAstNode {
15605    handle: String,
15606    span_handle: String,
15607    name: String,
15608    kind: String,
15609    block_kind: String,
15610    node_kind: String,
15611    line: usize,
15612    end_line: usize,
15613    byte_span: SourceByteRangePreview,
15614    #[serde(skip_serializing_if = "Option::is_none")]
15615    body_byte_span: Option<SourceByteRangePreview>,
15616    parent_handle: Option<String>,
15617    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15618    child_handles: Vec<String>,
15619    metadata: MarkdownAstNodeMetadata,
15620    expand: MarkdownAstNodeExpand,
15621}
15622
15623#[derive(Serialize)]
15624struct MarkdownAstExpandCommands {
15625    file: String,
15626    source_read: String,
15627    edit_intents: String,
15628}
15629
15630#[derive(Serialize)]
15631struct MarkdownAstReport {
15632    handle: String,
15633    root: String,
15634    file: String,
15635    range: SourceRangePreview,
15636    projection: MarkdownAstProjectionPreview,
15637    nodes: Vec<MarkdownAstNode>,
15638    expand: MarkdownAstExpandCommands,
15639    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15640    warnings: Vec<String>,
15641}
15642
15643pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
15644    let candidate = if file.is_absolute() {
15645        file.to_path_buf()
15646    } else {
15647        root.join(file)
15648    };
15649    let canonical = candidate
15650        .canonicalize()
15651        .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
15652    if !canonical.is_file() {
15653        bail!("source file is not a regular file: {}", canonical.display());
15654    }
15655    let canonical_root = root
15656        .canonicalize()
15657        .with_context(|| format!("canonicalizing project root {}", root.display()))?;
15658    if !canonical.starts_with(&canonical_root) {
15659        bail!(
15660            "source file {} is outside project root {}",
15661            canonical.display(),
15662            canonical_root.display()
15663        );
15664    }
15665    Ok(canonical)
15666}
15667
15668pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
15669    source_read_window_command(root, file, start, lines)
15670}
15671
15672pub(crate) fn source_read_window_command(
15673    root: &Path,
15674    file: &str,
15675    start: usize,
15676    lines: usize,
15677) -> String {
15678    format!(
15679        "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
15680        shell_quote(file),
15681        shell_quote(&root.to_string_lossy()),
15682        start,
15683        lines
15684    )
15685}
15686
15687pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
15688    format!(
15689        "tsift --envelope source-read {} --path {} --budget normal",
15690        shell_quote(file),
15691        shell_quote(&root.to_string_lossy())
15692    )
15693}
15694
15695pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
15696    format!(
15697        "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
15698        shell_quote(symbol),
15699        shell_quote(&root.to_string_lossy()),
15700        shell_quote(file)
15701    )
15702}
15703
15704fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
15705    format!(
15706        "tsift --envelope explain {} --path {} --budget normal",
15707        shell_quote(symbol),
15708        shell_quote(&root.to_string_lossy())
15709    )
15710}
15711
15712fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
15713    format!(
15714        "tsift graph {} --path {} --{} --json",
15715        shell_quote(symbol),
15716        shell_quote(&root.to_string_lossy()),
15717        relation
15718    )
15719}
15720
15721fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
15722    format!(
15723        "tsift summarize {} --path {} --json",
15724        shell_quote(symbol),
15725        shell_quote(&root.to_string_lossy())
15726    )
15727}
15728
15729pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
15730    let mut command = format!(
15731        "tsift --envelope markdown-ast {} --path {} --budget normal",
15732        shell_quote(file),
15733        shell_quote(&root.to_string_lossy())
15734    );
15735    if let Some(node) = node {
15736        command.push_str(" --node ");
15737        command.push_str(&shell_quote(node));
15738    }
15739    command
15740}
15741
15742fn markdown_edit_intents_command(root: &Path) -> String {
15743    format!(
15744        "tsift --envelope edit-intents --path {} --budget normal",
15745        shell_quote(&root.to_string_lossy())
15746    )
15747}
15748
15749pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
15750    usize::try_from(symbol.line)
15751        .ok()
15752        .and_then(|line| line.checked_add(1))
15753        .unwrap_or(1)
15754}
15755
15756fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
15757    symbol
15758        .end_line
15759        .and_then(|line| usize::try_from(line).ok())
15760        .and_then(|line| line.checked_add(1))
15761}
15762
15763fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
15764    value.and_then(|byte| usize::try_from(byte).ok())
15765}
15766
15767fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
15768    let byte = byte.min(source.len());
15769    source[..byte]
15770        .iter()
15771        .filter(|value| **value == b'\n')
15772        .count()
15773        .saturating_add(1)
15774}
15775
15776fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
15777    source_line_for_byte(source, end_byte.saturating_sub(1))
15778}
15779
15780fn ast_span_handle(
15781    file: &str,
15782    name: &str,
15783    kind: &str,
15784    start_byte: usize,
15785    end_byte: usize,
15786) -> String {
15787    stable_handle(
15788        "span",
15789        &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
15790    )
15791}
15792
15793pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
15794    Some((
15795        symbol_span_byte(symbol.start_byte)?,
15796        symbol_span_byte(symbol.end_byte)?,
15797    ))
15798}
15799
15800pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
15801    Some((
15802        symbol_span_byte(symbol.start_byte)?,
15803        symbol_span_byte(symbol.end_byte)?,
15804    ))
15805}
15806
15807pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
15808    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15809    Some(ast_span_handle(
15810        &symbol.file,
15811        &symbol.name,
15812        &symbol.kind,
15813        start_byte,
15814        end_byte,
15815    ))
15816}
15817
15818fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
15819    left.file == right.file
15820        && left.name == right.name
15821        && left.kind == right.kind
15822        && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
15823}
15824
15825fn stored_symbol_parent_span_handle(
15826    symbol: &index::StoredSymbol,
15827    symbols: &[index::StoredSymbol],
15828) -> Option<String> {
15829    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15830    symbols
15831        .iter()
15832        .filter(|candidate| {
15833            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15834                return false;
15835            }
15836            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15837            else {
15838                return false;
15839            };
15840            candidate_start <= start_byte && candidate_end >= end_byte
15841        })
15842        .min_by_key(|candidate| {
15843            stored_symbol_span_bounds(candidate)
15844                .map(|(start, end)| end.saturating_sub(start))
15845                .unwrap_or(usize::MAX)
15846        })
15847        .and_then(stored_symbol_span_handle)
15848}
15849
15850fn stored_symbol_child_span_handles(
15851    symbol: &index::StoredSymbol,
15852    symbols: &[index::StoredSymbol],
15853    limit: usize,
15854) -> Vec<String> {
15855    let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
15856        return Vec::new();
15857    };
15858    symbols
15859        .iter()
15860        .filter(|candidate| {
15861            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15862                return false;
15863            }
15864            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15865            else {
15866                return false;
15867            };
15868            candidate_start >= start_byte && candidate_end <= end_byte
15869        })
15870        .take(limit)
15871        .filter_map(stored_symbol_span_handle)
15872        .collect()
15873}
15874
15875fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
15876    let start = start_byte.min(source.len());
15877    let line_end = source[start..]
15878        .iter()
15879        .position(|value| *value == b'\n')
15880        .map(|pos| start + pos)
15881        .unwrap_or(source.len());
15882    let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
15883    let marker = line.trim_start();
15884    let level = marker.chars().take_while(|ch| *ch == '#').count();
15885    (1..=6).contains(&level).then_some(level)
15886}
15887
15888fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
15889    let start = start_byte.min(source.len());
15890    let line_start = source[..start]
15891        .iter()
15892        .rposition(|value| *value == b'\n')
15893        .map(|pos| pos + 1)
15894        .unwrap_or(0);
15895    source[line_start..start]
15896        .iter()
15897        .map(|byte| match byte {
15898            b'\t' => 4,
15899            b' ' => 1,
15900            _ => 0,
15901        })
15902        .sum::<usize>()
15903        / 2
15904}
15905
15906fn markdown_enclosing_heading_symbols<'a>(
15907    file: &str,
15908    start_byte: usize,
15909    end_byte: usize,
15910    symbols: &'a [index::StoredSymbol],
15911) -> Vec<&'a index::StoredSymbol> {
15912    let mut headings = symbols
15913        .iter()
15914        .filter(|candidate| candidate.file == file && candidate.kind == "heading")
15915        .filter(|candidate| {
15916            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15917            else {
15918                return false;
15919            };
15920            candidate_start <= start_byte && candidate_end >= end_byte
15921        })
15922        .collect::<Vec<_>>();
15923    headings.sort_by(|left, right| {
15924        stored_symbol_span_bounds(left)
15925            .map(|(start, _)| start)
15926            .unwrap_or(usize::MAX)
15927            .cmp(
15928                &stored_symbol_span_bounds(right)
15929                    .map(|(start, _)| start)
15930                    .unwrap_or(usize::MAX),
15931            )
15932            .then(left.name.cmp(&right.name))
15933    });
15934    headings
15935}
15936
15937fn markdown_stored_symbol_metadata(
15938    symbol: &index::StoredSymbol,
15939    source: &[u8],
15940    symbols: &[index::StoredSymbol],
15941) -> Option<MarkdownSpanMetadata> {
15942    if symbol.language != "markdown" {
15943        return None;
15944    }
15945    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15946    let section_symbols =
15947        markdown_enclosing_heading_symbols(&symbol.file, start_byte, end_byte, symbols);
15948    let section_path = section_symbols
15949        .iter()
15950        .map(|heading| heading.name.clone())
15951        .collect::<Vec<_>>();
15952    let section_handle = section_symbols
15953        .last()
15954        .and_then(|heading| stored_symbol_span_handle(heading));
15955    let heading_level = (symbol.kind == "heading")
15956        .then(|| markdown_heading_level(source, start_byte))
15957        .flatten();
15958    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15959    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15960    let embedded_symbols = if symbol.kind == "code_block" {
15961        markdown_embedded_symbols(
15962            &symbol.file,
15963            source,
15964            symbol_span_byte(symbol.body_start_byte),
15965            symbol_span_byte(symbol.body_end_byte),
15966            fence_language.as_deref(),
15967        )
15968    } else {
15969        Vec::new()
15970    };
15971
15972    (heading_level.is_some()
15973        || !section_path.is_empty()
15974        || section_handle.is_some()
15975        || list_depth.is_some()
15976        || fence_language.is_some()
15977        || !embedded_symbols.is_empty())
15978    .then_some(MarkdownSpanMetadata {
15979        heading_level,
15980        section_path,
15981        section_handle,
15982        list_depth,
15983        fence_language,
15984        embedded_symbols,
15985    })
15986}
15987
15988fn markdown_symbol_hit_metadata(
15989    symbol: &index::SymbolHit,
15990    source: &[u8],
15991    start_byte: usize,
15992) -> Option<MarkdownSpanMetadata> {
15993    if symbol.language != "markdown" {
15994        return None;
15995    }
15996    let heading_level = (symbol.kind == "heading")
15997        .then(|| markdown_heading_level(source, start_byte))
15998        .flatten();
15999    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16000    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16001    let embedded_symbols = if symbol.kind == "code_block" {
16002        markdown_embedded_symbols(
16003            &symbol.file,
16004            source,
16005            symbol_span_byte(symbol.body_start_byte),
16006            symbol_span_byte(symbol.body_end_byte),
16007            fence_language.as_deref(),
16008        )
16009    } else {
16010        Vec::new()
16011    };
16012    (heading_level.is_some()
16013        || list_depth.is_some()
16014        || fence_language.is_some()
16015        || !embedded_symbols.is_empty())
16016    .then_some(MarkdownSpanMetadata {
16017        heading_level,
16018        section_path: Vec::new(),
16019        section_handle: None,
16020        list_depth,
16021        fence_language,
16022        embedded_symbols,
16023    })
16024}
16025
16026fn is_markdown_path(path: &Path) -> bool {
16027    path.extension()
16028        .and_then(|ext| ext.to_str())
16029        .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
16030        .unwrap_or(false)
16031}
16032
16033fn markdown_ast_block_kind(kind: &str) -> String {
16034    match kind {
16035        "heading" => "section",
16036        "code_block" => "fenced_code_block",
16037        "list_item" => "list_item",
16038        other => other,
16039    }
16040    .to_string()
16041}
16042
16043fn markdown_embedded_language_key(language: &str) -> Option<String> {
16044    let key = language
16045        .split_whitespace()
16046        .next()
16047        .unwrap_or("")
16048        .trim()
16049        .trim_start_matches("language-")
16050        .trim_start_matches("lang-")
16051        .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
16052        .to_ascii_lowercase();
16053    (!key.is_empty()).then_some(key)
16054}
16055
16056fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
16057    let key = markdown_embedded_language_key(language)?;
16058    let extension = match key.as_str() {
16059        "rust" => "rs",
16060        "python" => "py",
16061        "typescript" => "ts",
16062        "javascript" => "js",
16063        "kotlin" => "kt",
16064        "shell" | "sh" | "zsh" => "bash",
16065        other => other,
16066    };
16067    let lang = graph::Lang::from_extension(extension)?;
16068    (lang.name() != "markdown").then_some(lang)
16069}
16070
16071fn markdown_embedded_ast_span_handle(
16072    file: &str,
16073    language: &str,
16074    name: &str,
16075    kind: &str,
16076    start_byte: usize,
16077    end_byte: usize,
16078) -> String {
16079    stable_handle(
16080        "span",
16081        &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
16082    )
16083}
16084
16085fn markdown_embedded_symbols(
16086    file: &str,
16087    source: &[u8],
16088    body_start_byte: Option<usize>,
16089    body_end_byte: Option<usize>,
16090    fence_language: Option<&str>,
16091) -> Vec<MarkdownEmbeddedSymbol> {
16092    let Some(fence_language) = fence_language else {
16093        return Vec::new();
16094    };
16095    let Some(lang) = markdown_embedded_lang(fence_language) else {
16096        return Vec::new();
16097    };
16098    let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
16099        return Vec::new();
16100    };
16101    let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
16102    else {
16103        return Vec::new();
16104    };
16105    if body.is_empty() {
16106        return Vec::new();
16107    }
16108
16109    let Ok(symbols) = lang.extract_symbols(body) else {
16110        return Vec::new();
16111    };
16112    let language = lang.name().to_string();
16113    symbols
16114        .into_iter()
16115        .map(|symbol| {
16116            let start_byte = body_start_byte.saturating_add(symbol.start_byte);
16117            let end_byte = body_start_byte.saturating_add(symbol.end_byte);
16118            let body_start = symbol
16119                .body_start_byte
16120                .map(|byte| body_start_byte.saturating_add(byte));
16121            let body_end = symbol
16122                .body_end_byte
16123                .map(|byte| body_start_byte.saturating_add(byte));
16124            let start_line = source_line_for_byte(source, start_byte);
16125            let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
16126            MarkdownEmbeddedSymbol {
16127                handle: markdown_embedded_ast_span_handle(
16128                    file,
16129                    &language,
16130                    &symbol.name,
16131                    &symbol.kind,
16132                    start_byte,
16133                    end_byte,
16134                ),
16135                name: symbol.name,
16136                kind: symbol.kind,
16137                language: language.clone(),
16138                node_kind: symbol.node_kind,
16139                start_byte,
16140                end_byte,
16141                start_line,
16142                end_line,
16143                body_start_byte: body_start,
16144                body_end_byte: body_end,
16145                body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
16146                body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
16147            }
16148        })
16149        .collect()
16150}
16151
16152fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
16153    let start = start_byte.min(source.len());
16154    let line_start = source[..start]
16155        .iter()
16156        .rposition(|value| *value == b'\n')
16157        .map(|pos| pos + 1)
16158        .unwrap_or(0);
16159    let line_end = source[start..]
16160        .iter()
16161        .position(|value| *value == b'\n')
16162        .map(|pos| start + pos)
16163        .unwrap_or(source.len());
16164    std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
16165}
16166
16167fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
16168    let line = markdown_source_line(source, start_byte);
16169    let trimmed = line.trim_start();
16170    for marker in ["-", "*", "+"] {
16171        if trimmed
16172            .strip_prefix(marker)
16173            .and_then(|rest| rest.strip_prefix(' '))
16174            .is_some()
16175        {
16176            return (Some(marker.to_string()), None);
16177        }
16178    }
16179
16180    let digit_end = trimmed
16181        .find(|ch: char| !ch.is_ascii_digit())
16182        .unwrap_or(trimmed.len());
16183    let (digits, rest) = trimmed.split_at(digit_end);
16184    if !digits.is_empty() {
16185        for marker in [".", ")"] {
16186            if rest
16187                .strip_prefix(marker)
16188                .and_then(|value| value.strip_prefix(' '))
16189                .is_some()
16190            {
16191                return (
16192                    Some(format!("{digits}{marker}")),
16193                    digits.parse::<usize>().ok(),
16194                );
16195            }
16196        }
16197    }
16198    (None, None)
16199}
16200
16201fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
16202    let line = markdown_source_line(source, start_byte);
16203    let trimmed = line.trim_start();
16204    ["```", "~~~"]
16205        .into_iter()
16206        .find(|marker| trimmed.starts_with(marker))
16207        .map(str::to_string)
16208}
16209
16210fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16211    let mut nodes = graph::Lang::Markdown
16212        .extract_symbols(source)
16213        .context("extracting Markdown AST nodes")?
16214        .into_iter()
16215        .map(|symbol| {
16216            let body_start_byte = symbol.body_start_byte;
16217            let body_end_byte = symbol.body_end_byte;
16218            let span_handle = ast_span_handle(
16219                file,
16220                &symbol.name,
16221                &symbol.kind,
16222                symbol.start_byte,
16223                symbol.end_byte,
16224            );
16225            MarkdownAstRawNode {
16226                handle: stable_handle(
16227                    "mdast",
16228                    &format!(
16229                        "{}:{}:{}:{}:{}",
16230                        file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16231                    ),
16232                ),
16233                span_handle,
16234                name: symbol.name,
16235                kind: symbol.kind.clone(),
16236                block_kind: markdown_ast_block_kind(&symbol.kind),
16237                node_kind: symbol.node_kind,
16238                start_byte: symbol.start_byte,
16239                end_byte: symbol.end_byte,
16240                body_start_byte,
16241                body_end_byte,
16242            }
16243        })
16244        .collect::<Vec<_>>();
16245    nodes.sort_by(|left, right| {
16246        left.start_byte
16247            .cmp(&right.start_byte)
16248            .then(left.end_byte.cmp(&right.end_byte))
16249            .then(left.kind.cmp(&right.kind))
16250            .then(left.name.cmp(&right.name))
16251    });
16252    Ok(nodes)
16253}
16254
16255pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16256    let source_hash = blake3::hash(source).to_hex().to_string();
16257    let cache_key = format!("{file}:{source_hash}");
16258    let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16259    if let Some(entry) = cache
16260        .lock()
16261        .expect("markdown ast cache poisoned")
16262        .get(&cache_key)
16263    {
16264        return Ok(MarkdownAstProjection {
16265            source_hash: entry.source_hash.clone(),
16266            nodes: entry.nodes.clone(),
16267            parse_duration_micros: entry.parse_duration_micros,
16268            cache_hit: true,
16269        });
16270    }
16271
16272    let started = Instant::now();
16273    let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16274    let parse_duration_micros = started.elapsed().as_micros();
16275    cache.lock().expect("markdown ast cache poisoned").insert(
16276        cache_key,
16277        MarkdownAstCacheEntry {
16278            source_hash: source_hash.clone(),
16279            nodes: nodes.clone(),
16280            parse_duration_micros,
16281        },
16282    );
16283    Ok(MarkdownAstProjection {
16284        source_hash,
16285        nodes,
16286        parse_duration_micros,
16287        cache_hit: false,
16288    })
16289}
16290
16291fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16292    MarkdownAstCacheReport {
16293        source_hash: projection.source_hash.clone(),
16294        cache_hit: projection.cache_hit,
16295        parse_duration_micros: projection.parse_duration_micros,
16296        node_count: projection.nodes.len(),
16297        section_count: projection
16298            .nodes
16299            .iter()
16300            .filter(|node| node.kind == "heading")
16301            .count(),
16302        list_item_count: projection
16303            .nodes
16304            .iter()
16305            .filter(|node| node.kind == "list_item")
16306            .count(),
16307        code_block_count: projection
16308            .nodes
16309            .iter()
16310            .filter(|node| node.kind == "code_block")
16311            .count(),
16312    }
16313}
16314
16315fn markdown_ast_node_direct_child_count(
16316    node: &MarkdownAstRawNode,
16317    nodes: &[MarkdownAstRawNode],
16318) -> usize {
16319    nodes
16320        .iter()
16321        .filter(|candidate| {
16322            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16323        })
16324        .count()
16325}
16326
16327fn markdown_ast_outline_entry(
16328    root: &Path,
16329    file: &str,
16330    source: &[u8],
16331    nodes: &[MarkdownAstRawNode],
16332    node: &MarkdownAstRawNode,
16333    max_bytes: usize,
16334) -> MarkdownAstOutlineEntry {
16335    let line = source_line_for_byte(source, node.start_byte);
16336    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16337    MarkdownAstOutlineEntry {
16338        handle: node.handle.clone(),
16339        span_handle: node.span_handle.clone(),
16340        name: truncate_for_budget(&node.name, max_bytes),
16341        kind: node.kind.clone(),
16342        block_kind: node.block_kind.clone(),
16343        line,
16344        end_line,
16345        section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16346        child_count: markdown_ast_node_direct_child_count(node, nodes),
16347        expand: markdown_ast_command(root, file, Some(&node.handle)),
16348    }
16349}
16350
16351fn markdown_ast_outline_entries(
16352    root: &Path,
16353    file: &str,
16354    source: &[u8],
16355    nodes: &[MarkdownAstRawNode],
16356    limit: usize,
16357    max_bytes: usize,
16358) -> Vec<MarkdownAstOutlineEntry> {
16359    let mut headings = nodes
16360        .iter()
16361        .filter(|node| node.kind == "heading")
16362        .collect::<Vec<_>>();
16363    let mut blocks = nodes
16364        .iter()
16365        .filter(|node| node.kind != "heading")
16366        .collect::<Vec<_>>();
16367    headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16368    blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16369    headings
16370        .into_iter()
16371        .chain(blocks)
16372        .take(limit)
16373        .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16374        .collect()
16375}
16376
16377fn markdown_ast_node_intersects_lines(
16378    source: &[u8],
16379    node: &MarkdownAstRawNode,
16380    start: usize,
16381    end: usize,
16382) -> bool {
16383    let line = source_line_for_byte(source, node.start_byte);
16384    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16385    line <= end && end_line >= start
16386}
16387
16388fn source_read_markdown_projection(
16389    root: &Path,
16390    file: &str,
16391    source: &[u8],
16392    start: usize,
16393    end: usize,
16394    budget: ResponseBudget,
16395) -> Result<SourceReadMarkdownProjection> {
16396    let projection = markdown_ast_projection(file, source)?;
16397    let visible_nodes = projection
16398        .nodes
16399        .iter()
16400        .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16401        .collect::<Vec<_>>();
16402    let mut outline_nodes = visible_nodes.clone();
16403    outline_nodes.sort_by_key(|node| {
16404        (
16405            node.kind != "heading",
16406            node.start_byte,
16407            node.end_byte,
16408            node.name.as_str(),
16409        )
16410    });
16411    let outline = outline_nodes
16412        .into_iter()
16413        .take(budget.preview_items())
16414        .map(|node| {
16415            markdown_ast_outline_entry(
16416                root,
16417                file,
16418                source,
16419                &projection.nodes,
16420                node,
16421                budget.preview_bytes(),
16422            )
16423        })
16424        .collect::<Vec<_>>();
16425    Ok(SourceReadMarkdownProjection {
16426        handle: stable_handle(
16427            "mdproj",
16428            &format!("{file}:{start}:{end}:{}", projection.source_hash),
16429        ),
16430        mode: "window_outline".to_string(),
16431        total_nodes: projection.nodes.len(),
16432        visible_nodes: visible_nodes.len(),
16433        outline,
16434        expand: markdown_ast_command(root, file, None),
16435    })
16436}
16437
16438fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16439    if parent.handle == child.handle {
16440        return false;
16441    }
16442    parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16443}
16444
16445fn markdown_ast_parent_handle(
16446    node: &MarkdownAstRawNode,
16447    nodes: &[MarkdownAstRawNode],
16448) -> Option<String> {
16449    nodes
16450        .iter()
16451        .filter(|candidate| markdown_ast_contains(candidate, node))
16452        .min_by_key(|candidate| {
16453            (
16454                candidate.end_byte.saturating_sub(candidate.start_byte),
16455                candidate.start_byte,
16456            )
16457        })
16458        .map(|candidate| candidate.handle.clone())
16459}
16460
16461fn markdown_ast_child_handles(
16462    node: &MarkdownAstRawNode,
16463    nodes: &[MarkdownAstRawNode],
16464    limit: usize,
16465) -> Vec<String> {
16466    nodes
16467        .iter()
16468        .filter(|candidate| {
16469            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16470        })
16471        .take(limit)
16472        .map(|candidate| candidate.handle.clone())
16473        .collect()
16474}
16475
16476fn markdown_ast_section_nodes<'a>(
16477    node: &MarkdownAstRawNode,
16478    nodes: &'a [MarkdownAstRawNode],
16479) -> Vec<&'a MarkdownAstRawNode> {
16480    let mut headings = nodes
16481        .iter()
16482        .filter(|candidate| candidate.kind == "heading")
16483        .filter(|candidate| {
16484            candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16485        })
16486        .collect::<Vec<_>>();
16487    headings.sort_by(|left, right| {
16488        left.start_byte
16489            .cmp(&right.start_byte)
16490            .then(left.end_byte.cmp(&right.end_byte))
16491            .then(left.name.cmp(&right.name))
16492    });
16493    headings
16494}
16495
16496fn markdown_ast_node_metadata(
16497    file: &str,
16498    node: &MarkdownAstRawNode,
16499    source: &[u8],
16500    nodes: &[MarkdownAstRawNode],
16501) -> MarkdownAstNodeMetadata {
16502    let section_nodes = markdown_ast_section_nodes(node, nodes);
16503    let section_path = section_nodes
16504        .iter()
16505        .map(|heading| heading.name.clone())
16506        .collect::<Vec<_>>();
16507    let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16508    let heading_level = (node.kind == "heading")
16509        .then(|| markdown_heading_level(source, node.start_byte))
16510        .flatten();
16511    let (list_marker, list_order) = if node.kind == "list_item" {
16512        markdown_list_attributes(source, node.start_byte)
16513    } else {
16514        (None, None)
16515    };
16516    let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16517    let embedded_symbols = if node.kind == "code_block" {
16518        markdown_embedded_symbols(
16519            file,
16520            source,
16521            node.body_start_byte,
16522            node.body_end_byte,
16523            fence_language.as_deref(),
16524        )
16525    } else {
16526        Vec::new()
16527    };
16528    MarkdownAstNodeMetadata {
16529        heading_level,
16530        section_path,
16531        section_handle,
16532        list_depth: (node.kind == "list_item")
16533            .then(|| markdown_list_depth(source, node.start_byte)),
16534        list_marker,
16535        list_order,
16536        fence_language,
16537        fence_marker: (node.kind == "code_block")
16538            .then(|| markdown_fence_marker(source, node.start_byte))
16539            .flatten(),
16540        embedded_symbols,
16541    }
16542}
16543
16544fn markdown_ast_node_expand(
16545    root: &Path,
16546    file: &str,
16547    node: &MarkdownAstRawNode,
16548    source: &[u8],
16549) -> MarkdownAstNodeExpand {
16550    let start_line = source_line_for_byte(source, node.start_byte);
16551    let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16552    let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16553    let body_start_line = node
16554        .body_start_byte
16555        .map(|byte| source_line_for_byte(source, byte))
16556        .unwrap_or(start_line);
16557    let body_end_line = node
16558        .body_end_byte
16559        .map(|byte| source_line_for_end_byte(source, byte))
16560        .unwrap_or(end_line)
16561        .max(body_start_line);
16562    let body_line_count = body_end_line
16563        .saturating_sub(body_start_line)
16564        .saturating_add(1)
16565        .max(1);
16566    MarkdownAstNodeExpand {
16567        source_window: source_read_command(root, file, start_line, line_count),
16568        source_body: source_read_command(root, file, body_start_line, body_line_count),
16569        symbol_read: source_symbol_read_command(root, &node.name, file),
16570        edit_intents: markdown_edit_intents_command(root),
16571    }
16572}
16573
16574fn markdown_ast_node(
16575    root: &Path,
16576    file: &str,
16577    node: &MarkdownAstRawNode,
16578    source: &[u8],
16579    nodes: &[MarkdownAstRawNode],
16580    child_limit: usize,
16581) -> MarkdownAstNode {
16582    let line = source_line_for_byte(source, node.start_byte);
16583    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16584    let body_byte_span = node
16585        .body_start_byte
16586        .zip(node.body_end_byte)
16587        .map(|(start, end)| SourceByteRangePreview { start, end });
16588    MarkdownAstNode {
16589        handle: node.handle.clone(),
16590        span_handle: node.span_handle.clone(),
16591        name: node.name.clone(),
16592        kind: node.kind.clone(),
16593        block_kind: node.block_kind.clone(),
16594        node_kind: node.node_kind.clone(),
16595        line,
16596        end_line,
16597        byte_span: SourceByteRangePreview {
16598            start: node.start_byte,
16599            end: node.end_byte,
16600        },
16601        body_byte_span,
16602        parent_handle: markdown_ast_parent_handle(node, nodes),
16603        child_handles: markdown_ast_child_handles(node, nodes, child_limit),
16604        metadata: markdown_ast_node_metadata(file, node, source, nodes),
16605        expand: markdown_ast_node_expand(root, file, node, source),
16606    }
16607}
16608
16609pub(crate) fn stored_symbol_ast_span(
16610    symbol: &index::StoredSymbol,
16611    source: &[u8],
16612    symbols: &[index::StoredSymbol],
16613    child_limit: usize,
16614) -> Option<AstSpanPreview> {
16615    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16616    let node_kind = symbol.node_kind.clone()?;
16617    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16618    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16619    Some(AstSpanPreview {
16620        handle: ast_span_handle(
16621            &symbol.file,
16622            &symbol.name,
16623            &symbol.kind,
16624            start_byte,
16625            end_byte,
16626        ),
16627        node_kind,
16628        start_byte,
16629        end_byte,
16630        start_line: source_line_for_byte(source, start_byte),
16631        end_line: source_line_for_end_byte(source, end_byte),
16632        body_start_byte,
16633        body_end_byte,
16634        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16635        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16636        parent_handle: stored_symbol_parent_span_handle(symbol, symbols),
16637        child_handles: stored_symbol_child_span_handles(symbol, symbols, child_limit),
16638        markdown: markdown_stored_symbol_metadata(symbol, source, symbols),
16639    })
16640}
16641
16642pub(crate) fn symbol_hit_ast_span(symbol: &index::SymbolHit, source: &[u8]) -> Option<AstSpanPreview> {
16643    let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
16644    let node_kind = symbol.node_kind.clone()?;
16645    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16646    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16647    Some(AstSpanPreview {
16648        handle: ast_span_handle(
16649            &symbol.file,
16650            &symbol.name,
16651            &symbol.kind,
16652            start_byte,
16653            end_byte,
16654        ),
16655        node_kind,
16656        start_byte,
16657        end_byte,
16658        start_line: source_line_for_byte(source, start_byte),
16659        end_line: source_line_for_end_byte(source, end_byte),
16660        body_start_byte,
16661        body_end_byte,
16662        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16663        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16664        parent_handle: None,
16665        child_handles: Vec::new(),
16666        markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
16667    })
16668}
16669
16670pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
16671    usize::try_from(symbol.line)
16672        .ok()
16673        .and_then(|line| line.checked_add(1))
16674        .unwrap_or(1)
16675}
16676
16677pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
16678    symbol
16679        .end_line
16680        .and_then(|line| usize::try_from(line).ok())
16681        .and_then(|line| line.checked_add(1))
16682}
16683
16684fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
16685    if end == 0 {
16686        return false;
16687    }
16688    let symbol_start = source_symbol_line(symbol);
16689    let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
16690    symbol_start <= end && symbol_end >= start
16691}
16692
16693#[allow(clippy::too_many_arguments)]
16694fn load_source_symbols(
16695    root: &Path,
16696    file_abs: &Path,
16697    file_display: &str,
16698    source: &[u8],
16699    scope: Option<&str>,
16700    start: usize,
16701    end: usize,
16702    limit: usize,
16703    max_bytes: usize,
16704    warnings: &mut Vec<String>,
16705) -> Vec<SourceSymbolRef> {
16706    let db_path = match resolve_query_db_path(root, file_abs, scope) {
16707        Ok(path) => path,
16708        Err(err) => {
16709            warnings.push(format!("index refs unavailable: {err:#}"));
16710            return Vec::new();
16711        }
16712    };
16713    if !db_path.exists() {
16714        warnings.push(format!(
16715            "index refs unavailable: no index found at {}",
16716            db_path.display()
16717        ));
16718        return Vec::new();
16719    }
16720
16721    let db = match index::IndexDb::open_read_only_resilient(&db_path) {
16722        Ok(db) => db,
16723        Err(err) => {
16724            warnings.push(format!("index refs unavailable: {err:#}"));
16725            return Vec::new();
16726        }
16727    };
16728
16729    let file_key = file_abs.to_string_lossy().to_string();
16730    let symbols = match db.symbols_for_file(&file_key) {
16731        Ok(symbols) => symbols,
16732        Err(err) => {
16733            warnings.push(format!("symbol refs unavailable: {err:#}"));
16734            return Vec::new();
16735        }
16736    };
16737
16738    symbols
16739        .iter()
16740        .filter(|symbol| source_symbol_intersects(symbol, start, end))
16741        .take(limit)
16742        .map(|symbol| {
16743            let line = source_symbol_line(symbol);
16744            let end_line = source_symbol_end_line(symbol);
16745            let handle = stable_handle(
16746                "ssym",
16747                &format!("{}:{}:{}", file_display, symbol.name, line),
16748            );
16749            SourceSymbolRef {
16750                handle,
16751                name: truncate_for_budget(&symbol.name, max_bytes),
16752                kind: symbol.kind.clone(),
16753                language: symbol.language.clone(),
16754                file: file_display.to_string(),
16755                line,
16756                end_line,
16757                signature: symbol
16758                    .signature
16759                    .clone()
16760                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
16761                span: stored_symbol_ast_span(symbol, source, &symbols, limit),
16762                expand: source_symbol_read_command(root, &symbol.name, file_display),
16763            }
16764        })
16765        .collect()
16766}
16767
16768fn load_source_summaries(
16769    root: &Path,
16770    file_display: &str,
16771    limit: usize,
16772    max_bytes: usize,
16773    warnings: &mut Vec<String>,
16774) -> Vec<SourceSummaryRef> {
16775    let db_path = root.join(".tsift/summaries.db");
16776    if !db_path.exists() {
16777        return Vec::new();
16778    }
16779    let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
16780        Ok(db) => db,
16781        Err(err) => {
16782            warnings.push(format!("summary refs unavailable: {err:#}"));
16783            return Vec::new();
16784        }
16785    };
16786    let summaries = match db.get_by_file(file_display) {
16787        Ok(summaries) => summaries,
16788        Err(err) => {
16789            warnings.push(format!("summary refs unavailable: {err:#}"));
16790            return Vec::new();
16791        }
16792    };
16793
16794    summaries
16795        .into_iter()
16796        .take(limit)
16797        .map(|summary| SourceSummaryRef {
16798            handle: stable_handle(
16799                "sum",
16800                &format!(
16801                    "{}:{}:{}",
16802                    summary.file_path, summary.symbol_name, summary.id
16803                ),
16804            ),
16805            symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
16806            file_path: summary.file_path,
16807            summary: truncate_for_budget(&summary.summary, max_bytes),
16808            expand: source_summary_expand_command(root, &summary.symbol_name),
16809        })
16810        .collect()
16811}
16812
16813fn cmd_markdown_ast(
16814    file: &Path,
16815    path: &Path,
16816    node: Option<&str>,
16817    format: OutputFormat,
16818    absolute: bool,
16819    budget: ResponseBudget,
16820) -> Result<()> {
16821    let root = lint::resolve_project_root_or_canonical_path(path)?;
16822    let file_abs = resolve_source_file(&root, file)?;
16823    if !is_markdown_path(&file_abs) {
16824        bail!(
16825            "markdown-ast only supports Markdown files (.md/.mdx): {}",
16826            file_abs.display()
16827        );
16828    }
16829    let file_display = if absolute {
16830        file_abs.to_string_lossy().to_string()
16831    } else {
16832        relativize_pathbuf(&file_abs, &root)
16833            .to_string_lossy()
16834            .to_string()
16835    };
16836    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16837    let text = String::from_utf8_lossy(&source);
16838    let total_lines = text.lines().count();
16839    let projection = markdown_ast_projection(&file_display, &source)?;
16840    let raw_nodes = &projection.nodes;
16841    let max_items = budget.preview_items();
16842    let max_bytes = budget.preview_bytes();
16843
16844    let selected_nodes = if let Some(handle) = node {
16845        let matches = raw_nodes
16846            .iter()
16847            .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
16848            .collect::<Vec<_>>();
16849        if matches.is_empty() {
16850            bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
16851        }
16852        matches
16853    } else {
16854        raw_nodes.iter().take(max_items).collect::<Vec<_>>()
16855    };
16856    let nodes = selected_nodes
16857        .into_iter()
16858        .map(|raw| {
16859            let mut node =
16860                markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
16861            node.name = truncate_for_budget(&node.name, max_bytes);
16862            node
16863        })
16864        .collect::<Vec<_>>();
16865    let outline_started = Instant::now();
16866    let outline = markdown_ast_outline_entries(
16867        &root,
16868        &file_display,
16869        &source,
16870        raw_nodes,
16871        max_items,
16872        max_bytes,
16873    );
16874    let outline_duration_micros = outline_started.elapsed().as_micros();
16875    let projection_preview = MarkdownAstProjectionPreview {
16876        mode: if node.is_some() {
16877            "selected_node".to_string()
16878        } else {
16879            "outline_first".to_string()
16880        },
16881        total_nodes: raw_nodes.len(),
16882        returned_nodes: nodes.len(),
16883        omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
16884        selected_node: node.map(str::to_string),
16885        cache: markdown_ast_cache_report(&projection),
16886        outline,
16887        phase_timings: vec![
16888            MarkdownAstPhaseTiming {
16889                name: "parse_extract".to_string(),
16890                duration_micros: projection.parse_duration_micros,
16891                detail: if projection.cache_hit {
16892                    "reused cached tree-sitter Markdown symbol extraction".to_string()
16893                } else {
16894                    "tree-sitter Markdown symbol extraction".to_string()
16895                },
16896            },
16897            MarkdownAstPhaseTiming {
16898                name: "outline_projection".to_string(),
16899                duration_micros: outline_duration_micros,
16900                detail: "outline-first section/block preview construction".to_string(),
16901            },
16902        ],
16903    };
16904    let report = MarkdownAstReport {
16905        handle: stable_handle("mdastrep", &file_display),
16906        root: root.to_string_lossy().to_string(),
16907        file: file_display.clone(),
16908        range: SourceRangePreview {
16909            start: 1,
16910            end: total_lines,
16911            total_lines,
16912            truncated_before: false,
16913            truncated_after: false,
16914        },
16915        projection: projection_preview,
16916        nodes,
16917        expand: MarkdownAstExpandCommands {
16918            file: markdown_ast_command(&root, &file_display, None),
16919            source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
16920            edit_intents: markdown_edit_intents_command(&root),
16921        },
16922        warnings: Vec::new(),
16923    };
16924
16925    if format.json_output {
16926        let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
16927        let mut follow_up = vec![
16928            report.expand.file.clone(),
16929            report.expand.source_read.clone(),
16930            report.expand.edit_intents.clone(),
16931        ];
16932        follow_up.extend(
16933            report
16934                .nodes
16935                .iter()
16936                .map(|node| node.expand.source_window.clone()),
16937        );
16938        print_json_or_envelope(
16939            &report,
16940            &format,
16941            "markdown-ast",
16942            "ast",
16943            ToolEnvelopeSummary {
16944                text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
16945                metrics: vec![
16946                    envelope_metric("nodes", report.nodes.len()),
16947                    envelope_metric("total_nodes", report.projection.total_nodes),
16948                    envelope_metric(
16949                        "parse_duration_micros",
16950                        report.projection.cache.parse_duration_micros,
16951                    ),
16952                    envelope_metric("total_lines", report.range.total_lines),
16953                ],
16954            },
16955            truncated,
16956            follow_up,
16957        )?;
16958    } else if format.compact {
16959        println!(
16960            "markdown-ast {} nodes:{} handle:{}",
16961            report.file,
16962            report.nodes.len(),
16963            report.handle
16964        );
16965        for node in &report.nodes {
16966            println!(
16967                "  {} {} {}:{}-{}",
16968                node.handle, node.kind, node.name, node.line, node.end_line
16969            );
16970        }
16971        if node.is_none() && raw_nodes.len() > report.nodes.len() {
16972            println!("expand: {}", report.expand.file);
16973        }
16974    } else {
16975        println!(
16976            "Markdown AST `{}` nodes {} of {} ({})",
16977            report.file,
16978            report.nodes.len(),
16979            raw_nodes.len(),
16980            report.handle
16981        );
16982        for node in &report.nodes {
16983            println!(
16984                "  {} `{}` {}:{}-{} — {}",
16985                node.handle,
16986                node.name,
16987                node.kind,
16988                node.line,
16989                node.end_line,
16990                node.expand.source_window
16991            );
16992        }
16993        if node.is_none() && raw_nodes.len() > report.nodes.len() {
16994            println!();
16995            println!("Expand:");
16996            println!("  file: {}", report.expand.file);
16997        }
16998    }
16999
17000    Ok(())
17001}
17002
17003#[allow(clippy::too_many_arguments)]
17004fn cmd_source_read(
17005    file: &Path,
17006    path: &Path,
17007    style: SourceReadStyle,
17008    start: usize,
17009    lines: usize,
17010    end: Option<usize>,
17011    scope: Option<&str>,
17012    format: OutputFormat,
17013    absolute: bool,
17014    budget: ResponseBudget,
17015) -> Result<()> {
17016    if start == 0 {
17017        bail!("--start is 1-based and must be greater than zero");
17018    }
17019    if lines == 0 {
17020        bail!("--lines must be greater than zero");
17021    }
17022    if let Some(end) = end
17023        && end < start
17024    {
17025        bail!("--end must be greater than or equal to --start");
17026    }
17027
17028    let root = lint::resolve_project_root_or_canonical_path(path)?;
17029    let file_abs = resolve_source_file(&root, file)?;
17030    let file_display = if absolute {
17031        file_abs.to_string_lossy().to_string()
17032    } else {
17033        relativize_pathbuf(&file_abs, &root)
17034            .to_string_lossy()
17035            .to_string()
17036    };
17037
17038    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17039    let text = String::from_utf8_lossy(&source);
17040    let all_lines: Vec<&str> = text.lines().collect();
17041    let total_lines = all_lines.len();
17042    if total_lines > 0 && start > total_lines {
17043        bail!(
17044            "--start {} is beyond end of {} ({} lines)",
17045            start,
17046            file_display,
17047            total_lines
17048        );
17049    }
17050    let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
17051    let end_line = requested_end.min(total_lines);
17052    let mut warnings = Vec::new();
17053    let max_items = budget.preview_items();
17054    let max_bytes = budget.preview_bytes();
17055    if style == SourceReadStyle::Ast {
17056        let symbols = load_source_symbols(
17057            &root,
17058            &file_abs,
17059            &file_display,
17060            &source,
17061            scope,
17062            start,
17063            end_line,
17064            max_items,
17065            max_bytes,
17066            &mut warnings,
17067        );
17068        let summaries =
17069            load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17070        let markdown = if is_markdown_path(&file_abs) {
17071            match source_read_markdown_projection(
17072                &root,
17073                &file_display,
17074                &source,
17075                start,
17076                end_line,
17077                budget,
17078            ) {
17079                Ok(markdown) => Some(markdown),
17080                Err(err) => {
17081                    warnings.push(format!("markdown projection unavailable: {err:#}"));
17082                    None
17083                }
17084            }
17085        } else {
17086            None
17087        };
17088        let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
17089        let report = SourceReadAstReport {
17090            handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
17091            root: root.to_string_lossy().to_string(),
17092            file: file_display.clone(),
17093            range: SourceRangePreview {
17094                start,
17095                end: end_line,
17096                total_lines,
17097                truncated_before: start > 1,
17098                truncated_after: end_line < total_lines,
17099            },
17100            symbols,
17101            summaries,
17102            markdown,
17103            expand: SourceReadAstExpandCommands {
17104                window: source_read_window_command(&root, &file_display, start, window_lines),
17105                file_window: source_read_window_command(
17106                    &root,
17107                    &file_display,
17108                    1,
17109                    total_lines.max(window_lines),
17110                ),
17111                markdown_ast: is_markdown_path(&file_abs)
17112                    .then(|| markdown_ast_command(&root, &file_display, None)),
17113            },
17114            warnings,
17115        };
17116
17117        if format.json_output {
17118            let truncated = report.range.truncated_before
17119                || report.range.truncated_after
17120                || report.symbols.len() >= max_items
17121                || report.summaries.len() >= max_items;
17122            let follow_up = [
17123                Some(report.expand.window.clone()),
17124                Some(report.expand.file_window.clone()),
17125                report.expand.markdown_ast.clone(),
17126            ]
17127            .into_iter()
17128            .flatten()
17129            .collect::<Vec<_>>();
17130            print_json_or_envelope(
17131                &report,
17132                &format,
17133                "source-read",
17134                "ast",
17135                ToolEnvelopeSummary {
17136                    text: format!(
17137                        "source ast {}:{}-{}",
17138                        report.file, report.range.start, report.range.end
17139                    ),
17140                    metrics: vec![
17141                        envelope_metric("symbols", report.symbols.len()),
17142                        envelope_metric("summaries", report.summaries.len()),
17143                        envelope_metric(
17144                            "markdown_nodes",
17145                            report
17146                                .markdown
17147                                .as_ref()
17148                                .map_or(0, |markdown| markdown.visible_nodes),
17149                        ),
17150                    ],
17151                },
17152                truncated,
17153                follow_up,
17154            )?;
17155        } else if format.compact {
17156            println!(
17157                "source-ast {}:{}-{} / {} handle:{}",
17158                report.file,
17159                report.range.start,
17160                report.range.end,
17161                report.range.total_lines,
17162                report.handle
17163            );
17164            for symbol in &report.symbols {
17165                println!(
17166                    "  {} {}:{} {}",
17167                    symbol.name, symbol.file, symbol.line, symbol.expand
17168                );
17169            }
17170            if !report.summaries.is_empty() {
17171                println!("summaries[{}]", report.summaries.len());
17172            }
17173            for warning in &report.warnings {
17174                eprintln!("warning: {warning}");
17175            }
17176        } else {
17177            println!(
17178                "Source AST `{}` lines {}-{} of {} ({})",
17179                report.file,
17180                report.range.start,
17181                report.range.end,
17182                report.range.total_lines,
17183                report.handle
17184            );
17185            if !report.symbols.is_empty() {
17186                println!();
17187                println!("Symbol refs:");
17188                for symbol in &report.symbols {
17189                    println!(
17190                        "  {} `{}` {}:{} — {}",
17191                        symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17192                    );
17193                }
17194            }
17195            if !report.summaries.is_empty() {
17196                println!();
17197                println!("Summary refs:");
17198                for summary in &report.summaries {
17199                    println!(
17200                        "  {} `{}` — {}",
17201                        summary.handle, summary.symbol_name, summary.expand
17202                    );
17203                }
17204            }
17205            println!();
17206            println!("Expand:");
17207            println!("  window:      {}", report.expand.window);
17208            println!("  file window: {}", report.expand.file_window);
17209            if let Some(markdown_ast) = &report.expand.markdown_ast {
17210                println!("  markdown:    {}", markdown_ast);
17211            }
17212            for warning in &report.warnings {
17213                eprintln!("warning: {warning}");
17214            }
17215        }
17216
17217        return Ok(());
17218    }
17219    let max_bytes = budget.preview_bytes();
17220    let token_cap = budget.body_token_cap();
17221    let (preview, preview_end, body_truncated) = if total_lines == 0 {
17222        (Vec::new(), end_line, false)
17223    } else {
17224        let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17225        (capped.preview, capped.capped_end, capped.was_capped)
17226    };
17227    let effective_end = if body_truncated { preview_end } else { end_line };
17228
17229    if body_truncated {
17230        warnings.push(format!(
17231            "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17232        ));
17233    }
17234    let symbols = load_source_symbols(
17235        &root,
17236        &file_abs,
17237        &file_display,
17238        &source,
17239        scope,
17240        start,
17241        effective_end,
17242        max_items,
17243        max_bytes,
17244        &mut warnings,
17245    );
17246    let summaries =
17247        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17248    let markdown = if is_markdown_path(&file_abs) {
17249        match source_read_markdown_projection(
17250            &root,
17251            &file_display,
17252            &source,
17253            start,
17254            effective_end,
17255            budget,
17256        ) {
17257            Ok(markdown) => Some(markdown),
17258            Err(err) => {
17259                warnings.push(format!("markdown projection unavailable: {err:#}"));
17260                None
17261            }
17262        }
17263    } else {
17264        None
17265    };
17266
17267    let expand = SourceExpandCommands {
17268        before: (start > 1).then(|| {
17269            let before_start = start.saturating_sub(lines).max(1);
17270            source_read_window_command(&root, &file_display, before_start, start - before_start)
17271        }),
17272        after: (effective_end < total_lines)
17273            .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17274        body: body_truncated.then(|| {
17275            let remaining = end_line.saturating_sub(effective_end);
17276            source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17277        }),
17278        file: source_read_ast_command(&root, &file_display),
17279        markdown_ast: is_markdown_path(&file_abs)
17280            .then(|| markdown_ast_command(&root, &file_display, None)),
17281    };
17282
17283    let report = SourceReadReport {
17284        handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17285        root: root.to_string_lossy().to_string(),
17286        file: file_display,
17287        range: SourceRangePreview {
17288            start,
17289            end: effective_end,
17290            total_lines,
17291            truncated_before: start > 1,
17292            truncated_after: effective_end < total_lines,
17293        },
17294        preview,
17295        symbols,
17296        summaries,
17297        markdown,
17298        expand,
17299        warnings,
17300    };
17301
17302    if format.json_output {
17303        let truncated = report.range.truncated_before || report.range.truncated_after;
17304        let follow_up = [
17305            report.expand.before.clone(),
17306            report.expand.after.clone(),
17307            report.expand.body.clone(),
17308            Some(report.expand.file.clone()),
17309            report.expand.markdown_ast.clone(),
17310        ]
17311        .into_iter()
17312        .flatten()
17313        .collect::<Vec<_>>();
17314        print_json_or_envelope(
17315            &report,
17316            &format,
17317            "source-read",
17318            "window",
17319            ToolEnvelopeSummary {
17320                text: format!(
17321                    "source window {}:{}-{}",
17322                    report.file, report.range.start, report.range.end
17323                ),
17324                metrics: vec![
17325                    envelope_metric("lines", report.preview.len()),
17326                    envelope_metric("symbols", report.symbols.len()),
17327                    envelope_metric("summaries", report.summaries.len()),
17328                    envelope_metric(
17329                        "markdown_nodes",
17330                        report
17331                            .markdown
17332                            .as_ref()
17333                            .map_or(0, |markdown| markdown.visible_nodes),
17334                    ),
17335                ],
17336            },
17337            truncated,
17338            follow_up,
17339        )?;
17340    } else if format.compact {
17341        println!(
17342            "source {}:{}-{} / {} handle:{}",
17343            report.file,
17344            report.range.start,
17345            report.range.end,
17346            report.range.total_lines,
17347            report.handle
17348        );
17349        for line in &report.preview {
17350            println!("{:>5} {}", line.line, line.text);
17351        }
17352        if !report.symbols.is_empty() {
17353            println!("syms[{}]:", report.symbols.len());
17354            for symbol in &report.symbols {
17355                println!("  {} {}:{}", symbol.name, symbol.file, symbol.line);
17356            }
17357        }
17358        if report.range.truncated_before || report.range.truncated_after {
17359            println!("expand: {}", report.expand.file);
17360        }
17361    } else {
17362        println!(
17363            "Source window `{}` lines {}-{} of {} ({})",
17364            report.file,
17365            report.range.start,
17366            report.range.end,
17367            report.range.total_lines,
17368            report.handle
17369        );
17370        for line in &report.preview {
17371            println!("{:>5} | {}", line.line, line.text);
17372        }
17373        if !report.symbols.is_empty() {
17374            println!();
17375            println!("Symbol refs:");
17376            for symbol in &report.symbols {
17377                println!(
17378                    "  {} `{}` {}:{} — {}",
17379                    symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17380                );
17381            }
17382        }
17383        if !report.summaries.is_empty() {
17384            println!();
17385            println!("Summary refs:");
17386            for summary in &report.summaries {
17387                println!(
17388                    "  {} `{}` — {}",
17389                    summary.handle, summary.symbol_name, summary.expand
17390                );
17391            }
17392        }
17393        if report.range.truncated_before || report.range.truncated_after {
17394            println!();
17395            println!("Expand:");
17396            if let Some(before) = &report.expand.before {
17397                println!("  before: {}", before);
17398            }
17399            if let Some(after) = &report.expand.after {
17400                println!("  after: {}", after);
17401            }
17402            println!("  file:   {}", report.expand.file);
17403        }
17404        for warning in &report.warnings {
17405            eprintln!("warning: {warning}");
17406        }
17407    }
17408
17409    Ok(())
17410}
17411
17412#[allow(clippy::too_many_arguments)]
17413fn cmd_symbol_read(
17414    symbol: &str,
17415    file_hint: Option<&Path>,
17416    path: &Path,
17417    scope: Option<&str>,
17418    format: OutputFormat,
17419    absolute: bool,
17420    budget: ResponseBudget,
17421) -> Result<()> {
17422    let root = lint::resolve_project_root_or_canonical_path(path)?;
17423    let hinted_file_abs = file_hint
17424        .map(|file| resolve_source_file(&root, file))
17425        .transpose()?;
17426    let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17427    let db_path = resolve_query_db_path(&root, path_hint, scope)?;
17428    if !db_path.exists() {
17429        bail!(
17430            "index refs unavailable: no index found at {}",
17431            db_path.display()
17432        );
17433    }
17434    let db = index::IndexDb::open_read_only_resilient(&db_path)
17435        .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17436    let search_limit = budget.follow_up_items().max(10);
17437    let hits = db
17438        .symbol_search(symbol, search_limit)
17439        .with_context(|| format!("searching symbols for {symbol:?}"))?;
17440    let selected = hits
17441        .into_iter()
17442        .find(|hit| {
17443            let Some(hinted_file_abs) = &hinted_file_abs else {
17444                return true;
17445            };
17446            resolve_source_file(&root, Path::new(&hit.file))
17447                .map(|hit_file| hit_file == *hinted_file_abs)
17448                .unwrap_or(false)
17449        })
17450        .with_context(|| {
17451            let hint = file_hint
17452                .map(|file| format!(" in {}", file.display()))
17453                .unwrap_or_default();
17454            format!("no indexed symbol matched {symbol:?}{hint}")
17455        })?;
17456
17457    let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17458    let file_display = if absolute {
17459        file_abs.to_string_lossy().to_string()
17460    } else {
17461        relativize_pathbuf(&file_abs, &root)
17462            .to_string_lossy()
17463            .to_string()
17464    };
17465    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17466    let content_hash = blake3::hash(&source).to_hex().to_string();
17467    let text = String::from_utf8_lossy(&source);
17468    let all_lines: Vec<&str> = text.lines().collect();
17469    let total_lines = all_lines.len();
17470    let file_symbols = db
17471        .symbols_for_file(&file_abs.to_string_lossy())
17472        .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17473    let max_items = budget.preview_items();
17474    let max_bytes = budget.preview_bytes();
17475    let selected_start = symbol_hit_line(&selected);
17476    let selected_end = symbol_hit_end_line(&selected)
17477        .unwrap_or(selected_start)
17478        .max(selected_start);
17479    let stored_target = file_symbols.iter().find(|candidate| {
17480        candidate.name == selected.name
17481            && candidate.kind == selected.kind
17482            && source_symbol_line(candidate) == selected_start
17483    });
17484    let target_span = stored_target
17485        .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17486        .or_else(|| symbol_hit_ast_span(&selected, &source));
17487    let target_start = target_span
17488        .as_ref()
17489        .map(|span| span.start_line)
17490        .unwrap_or(selected_start);
17491    let target_end = target_span
17492        .as_ref()
17493        .map(|span| span.end_line)
17494        .or_else(|| stored_target.and_then(source_symbol_end_line))
17495        .unwrap_or(selected_end)
17496        .max(target_start);
17497    let target_bounds = stored_target
17498        .and_then(stored_symbol_span_bounds)
17499        .or_else(|| symbol_hit_span_bounds(&selected));
17500    let target_end = stored_target
17501        .and_then(source_symbol_end_line)
17502        .unwrap_or(target_end)
17503        .max(target_start);
17504    let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17505    let line_capped_end = target_start
17506        .saturating_add(body_line_budget)
17507        .saturating_sub(1)
17508        .min(target_end)
17509        .min(total_lines.max(target_start));
17510    let token_cap = budget.body_token_cap();
17511    let (body, effective_preview_end, body_truncated) = if total_lines == 0 || target_start > total_lines {
17512        (Vec::new(), line_capped_end, false)
17513    } else {
17514        let capped = build_token_capped_preview(&all_lines, target_start, line_capped_end, max_bytes, token_cap);
17515        (capped.preview, capped.capped_end, capped.was_capped)
17516    };
17517    let preview_end = if body_truncated { effective_preview_end } else { line_capped_end };
17518    let child_symbols = file_symbols
17519        .iter()
17520        .filter(|candidate| {
17521            if let Some((target_start_byte, target_end_byte)) = target_bounds {
17522                let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17523                else {
17524                    return false;
17525                };
17526                return candidate_start >= target_start_byte
17527                    && candidate_end <= target_end_byte
17528                    && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17529            }
17530            let line = source_symbol_line(candidate);
17531            line > target_start && line <= target_end
17532        })
17533        .take(max_items)
17534        .map(|symbol| {
17535            let line = source_symbol_line(symbol);
17536            let end_line = source_symbol_end_line(symbol);
17537            SourceSymbolRef {
17538                handle: stable_handle(
17539                    "ssym",
17540                    &format!("{}:{}:{}", file_display, symbol.name, line),
17541                ),
17542                name: truncate_for_budget(&symbol.name, max_bytes),
17543                kind: symbol.kind.clone(),
17544                language: symbol.language.clone(),
17545                file: file_display.clone(),
17546                line,
17547                end_line,
17548                signature: symbol
17549                    .signature
17550                    .clone()
17551                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
17552                span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
17553                expand: source_symbol_read_command(&root, &symbol.name, &file_display),
17554            }
17555        })
17556        .collect::<Vec<_>>();
17557    let mut warnings = Vec::new();
17558    if body_truncated {
17559        warnings.push(format!(
17560            "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
17561        ));
17562    }
17563    let summaries =
17564        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17565    let symbol_handle = stable_handle(
17566        "sread",
17567        &format!("{}:{}:{}", file_display, selected.name, target_start),
17568    );
17569    let source_lines = preview_end
17570        .saturating_sub(target_start)
17571        .saturating_add(1)
17572        .max(1);
17573    let expand = SymbolReadExpandCommands {
17574        source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
17575        body: body_truncated.then(|| {
17576            let remaining = target_end.saturating_sub(preview_end);
17577            source_read_window_command(&root, &file_display, preview_end + 1, remaining)
17578        }),
17579        file: source_read_ast_command(&root, &file_display),
17580        explain: source_symbol_expand_command(&root, &selected.name),
17581        callers: source_symbol_graph_command(&root, &selected.name, "callers"),
17582        callees: source_symbol_graph_command(&root, &selected.name, "callees"),
17583        markdown_ast: (selected.language == "markdown").then(|| {
17584            markdown_ast_command(
17585                &root,
17586                &file_display,
17587                target_span.as_ref().map(|span| span.handle.as_str()),
17588            )
17589        }),
17590    };
17591    let report = SymbolReadReport {
17592        handle: symbol_handle.clone(),
17593        root: root.to_string_lossy().to_string(),
17594        query: symbol.to_string(),
17595        symbol: SymbolReadTarget {
17596            handle: symbol_handle,
17597            name: selected.name.clone(),
17598            kind: selected.kind.clone(),
17599            language: selected.language.clone(),
17600            file: file_display.clone(),
17601            line: target_start,
17602            end_line: Some(target_end),
17603            signature: stored_target
17604                .and_then(|stored| stored.signature.clone())
17605                .map(|signature| truncate_for_budget(&signature, max_bytes)),
17606            parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
17607            visibility: stored_target.and_then(|stored| stored.visibility.clone()),
17608            span: target_span,
17609        },
17610        range: SourceRangePreview {
17611            start: target_start,
17612            end: preview_end,
17613            total_lines,
17614            truncated_before: false,
17615            truncated_after: preview_end < target_end,
17616        },
17617        body,
17618        child_symbols,
17619        summaries,
17620        expand,
17621        warnings,
17622    };
17623
17624    if format.json_output {
17625        let truncated = report.range.truncated_after
17626            || report.body.iter().any(|line| line.text.len() >= max_bytes)
17627            || report.child_symbols.len() >= max_items;
17628        let follow_up = [
17629            Some(report.expand.source_window.clone()),
17630            report.expand.body.clone(),
17631            Some(report.expand.file.clone()),
17632            Some(report.expand.explain.clone()),
17633            Some(report.expand.callers.clone()),
17634            Some(report.expand.callees.clone()),
17635        ]
17636        .into_iter()
17637        .flatten()
17638        .chain(report.expand.markdown_ast.clone())
17639        .collect::<Vec<_>>();
17640        print_json_or_envelope(
17641            &report,
17642            &format,
17643            "symbol-read",
17644            "symbol",
17645            ToolEnvelopeSummary {
17646                text: format!(
17647                    "symbol {} {}:{}-{}",
17648                    report.symbol.name, report.symbol.file, report.range.start, report.range.end
17649                ),
17650                metrics: vec![
17651                    envelope_metric("body_lines", report.body.len()),
17652                    envelope_metric("child_symbols", report.child_symbols.len()),
17653                    envelope_metric("summaries", report.summaries.len()),
17654                ],
17655            },
17656            truncated,
17657            follow_up,
17658        )?;
17659    } else if format.compact {
17660        println!(
17661            "symbol {} {}:{}-{} handle:{} hash:{}",
17662            report.symbol.name,
17663            report.symbol.file,
17664            report.range.start,
17665            report.range.end,
17666            report.handle,
17667            content_hash
17668        );
17669        for line in &report.body {
17670            println!("{:>5} {}", line.line, line.text);
17671        }
17672        if !report.child_symbols.is_empty() {
17673            println!("children[{}]:", report.child_symbols.len());
17674            for child in &report.child_symbols {
17675                println!("  {} {}:{}", child.name, child.file, child.line);
17676            }
17677        }
17678    } else {
17679        println!(
17680            "Symbol `{}` in `{}` lines {}-{} ({})",
17681            report.symbol.name,
17682            report.symbol.file,
17683            report.range.start,
17684            report.range.end,
17685            report.handle
17686        );
17687        for line in &report.body {
17688            println!("{:>5} | {}", line.line, line.text);
17689        }
17690        if !report.child_symbols.is_empty() {
17691            println!();
17692            println!("Child symbols:");
17693            for child in &report.child_symbols {
17694                println!(
17695                    "  {} `{}` {}:{} — {}",
17696                    child.handle, child.name, child.file, child.line, child.expand
17697                );
17698            }
17699        }
17700        println!();
17701        println!("Expand:");
17702        println!("  source:  {}", report.expand.source_window);
17703        println!("  file:    {}", report.expand.file);
17704        println!("  explain: {}", report.expand.explain);
17705        println!("  callers: {}", report.expand.callers);
17706        println!("  callees: {}", report.expand.callees);
17707        for warning in &report.warnings {
17708            eprintln!("warning: {warning}");
17709        }
17710    }
17711
17712    Ok(())
17713}
17714
17715#[allow(clippy::too_many_arguments)]
17716#[derive(Serialize)]
17717struct ExplainBudgetDefinitionPreview {
17718    handle: String,
17719    #[serde(skip_serializing_if = "Option::is_none")]
17720    tag_alias: Option<String>,
17721    kind: String,
17722    name: String,
17723    file: String,
17724    line: i64,
17725    expand: String,
17726}
17727
17728#[derive(Serialize)]
17729struct ExplainBudgetEdgePreview {
17730    handle: String,
17731    #[serde(skip_serializing_if = "Option::is_none")]
17732    tag_alias: Option<String>,
17733    name: String,
17734    file: String,
17735    line: i64,
17736    expand: String,
17737}
17738
17739#[derive(Serialize)]
17740struct ExplainBudgetCommunityPreview {
17741    size: usize,
17742    members: Vec<String>,
17743}
17744
17745#[derive(Serialize)]
17746struct ExplainBudgetReport {
17747    symbol: String,
17748    max_items: usize,
17749    max_bytes: usize,
17750    definition_total: usize,
17751    callers_total: usize,
17752    callers_truncated_by_limit: bool,
17753    callees_total: usize,
17754    callees_truncated_by_limit: bool,
17755    truncated: bool,
17756    definitions: Vec<ExplainBudgetDefinitionPreview>,
17757    callers: Vec<ExplainBudgetEdgePreview>,
17758    callees: Vec<ExplainBudgetEdgePreview>,
17759    #[serde(skip_serializing_if = "Option::is_none")]
17760    community: Option<ExplainBudgetCommunityPreview>,
17761}
17762
17763#[allow(clippy::too_many_arguments)]
17764pub(crate) fn build_explain_budget_report(
17765    symbol: &str,
17766    _root: &Path,
17767    symbols: &[index::StoredSymbol],
17768    callers: &[index::StoredEdge],
17769    callers_total: usize,
17770    callers_truncated_by_limit: bool,
17771    callees: &[index::StoredEdge],
17772    callees_total: usize,
17773    callees_truncated_by_limit: bool,
17774    community: Option<&graph::Community>,
17775    budget: ResponseBudget,
17776) -> ExplainBudgetReport {
17777    let max_items = budget.preview_items();
17778    let max_bytes = budget.preview_bytes();
17779    let definitions = symbols
17780        .iter()
17781        .take(max_items)
17782        .map(|entry| {
17783            let symbol_ref = build_compact_symbol_ref(
17784                "edef",
17785                &format!(
17786                    "{}:{}:{}:{}",
17787                    entry.kind, entry.name, entry.file, entry.line
17788                ),
17789                &entry.name,
17790                entry.tags.as_deref(),
17791                max_bytes,
17792            );
17793            ExplainBudgetDefinitionPreview {
17794                handle: symbol_ref.handle,
17795                tag_alias: symbol_ref.tag_alias,
17796                kind: entry.kind.clone(),
17797                name: symbol_ref.name,
17798                file: truncate_for_budget(&entry.file, max_bytes),
17799                line: entry.line,
17800                expand: format!(
17801                    "tsift search {} --exact --path {} --limit 20",
17802                    shell_quote(&entry.name),
17803                    shell_quote(&entry.file)
17804                ),
17805            }
17806        })
17807        .collect();
17808    let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
17809        .iter()
17810        .take(max_items)
17811        .map(|entry| {
17812            let symbol_ref = build_compact_symbol_ref(
17813                "ecall",
17814                &format!(
17815                    "{}:{}:{}:{}",
17816                    entry.caller_name, entry.caller_file, entry.call_site_line, symbol
17817                ),
17818                &entry.caller_name,
17819                None,
17820                max_bytes,
17821            );
17822            ExplainBudgetEdgePreview {
17823                handle: symbol_ref.handle,
17824                tag_alias: symbol_ref.tag_alias,
17825                name: symbol_ref.name,
17826                file: truncate_for_budget(&entry.caller_file, max_bytes),
17827                line: entry.call_site_line,
17828                expand: format!(
17829                    "tsift explain {} --path {} --limit 0",
17830                    shell_quote(&entry.caller_name),
17831                    shell_quote(&entry.caller_file)
17832                ),
17833            }
17834        })
17835        .collect();
17836    let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
17837        .iter()
17838        .take(max_items)
17839        .map(|entry| {
17840            let symbol_ref = build_compact_symbol_ref(
17841                "eces",
17842                &format!(
17843                    "{}:{}:{}:{}",
17844                    entry.callee_name, entry.caller_file, entry.call_site_line, symbol
17845                ),
17846                &entry.callee_name,
17847                None,
17848                max_bytes,
17849            );
17850            ExplainBudgetEdgePreview {
17851                handle: symbol_ref.handle,
17852                tag_alias: symbol_ref.tag_alias,
17853                name: symbol_ref.name,
17854                file: truncate_for_budget(&entry.caller_file, max_bytes),
17855                line: entry.call_site_line,
17856                expand: format!(
17857                    "tsift explain {} --path {} --limit 0",
17858                    shell_quote(&entry.callee_name),
17859                    shell_quote(&entry.caller_file)
17860                ),
17861            }
17862        })
17863        .collect();
17864    let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
17865        size: entry.members.len(),
17866        members: entry
17867            .members
17868            .iter()
17869            .take(max_items)
17870            .map(|member| truncate_for_budget(&member.name, max_bytes))
17871            .collect(),
17872    });
17873
17874    ExplainBudgetReport {
17875        symbol: symbol.to_string(),
17876        max_items,
17877        max_bytes,
17878        definition_total: symbols.len(),
17879        callers_total,
17880        callers_truncated_by_limit,
17881        callees_total,
17882        callees_truncated_by_limit,
17883        truncated: symbols.len() > max_items
17884            || callers_total > callers_preview.len()
17885            || callees_total > callees_preview.len()
17886            || community
17887                .map(|entry| entry.members.len() > max_items)
17888                .unwrap_or(false),
17889        definitions,
17890        callers: callers_preview,
17891        callees: callees_preview,
17892        community: community_preview,
17893    }
17894}
17895
17896pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
17897    println!(
17898        "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
17899        shell_quote(&report.symbol),
17900        report.definitions.len(),
17901        report.definition_total,
17902        report.callers.len(),
17903        report.callers_total,
17904        report.callees.len(),
17905        report.callees_total
17906    );
17907    for entry in &report.definitions {
17908        println!(
17909            "def {} {} {}:{} expand:{}",
17910            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17911            entry.kind,
17912            entry.file,
17913            entry.line,
17914            entry.expand
17915        );
17916    }
17917    for entry in &report.callers {
17918        println!(
17919            "caller {} {}:{} expand:{}",
17920            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17921            entry.file,
17922            entry.line,
17923            entry.expand
17924        );
17925    }
17926    for entry in &report.callees {
17927        println!(
17928            "callee {} {}:{} expand:{}",
17929            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17930            entry.file,
17931            entry.line,
17932            entry.expand
17933        );
17934    }
17935    if let Some(community) = &report.community {
17936        println!(
17937            "community size:{} members:{}",
17938            community.size,
17939            community.members.join(", ")
17940        );
17941    }
17942    if report.truncated {
17943        println!(
17944            "budget truncated items:{} bytes:{}",
17945            report.max_items, report.max_bytes
17946        );
17947    }
17948}
17949
17950/// Reconcile the tsift symbol index against the tagpath `.naming/index.json`
17951/// source set and report files covered by one but not the other.
17952///
17953/// Today silent recall loss happens when tagpath's `[exclude]` / `extends`
17954/// chain or its hard-coded `SKIP_DIRS` skip files or languages that tsift
17955/// still indexes — the tsift symbols in those files cannot resolve a
17956/// `tagpath_handle` even with a fresh tagpath index. This audit surfaces
17957/// the diff so operators can decide whether to broaden the tagpath walk,
17958/// add an `[exclude]` to tsift, or accept the gap.
17959const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
17960    ".git",
17961    "node_modules",
17962    "target",
17963    "__pycache__",
17964    ".venv",
17965    "vendor",
17966];
17967
17968const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
17969    "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
17970    "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
17971    "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
17972    "sh", "bash", "zsh", "sql", "css", "tsx",
17973];
17974
17975pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
17976    let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
17977        .iter()
17978        .map(|ext| (*ext).to_string())
17979        .collect::<BTreeSet<_>>();
17980
17981    let config_path = root.join(".naming.toml");
17982    if !config_path.exists() {
17983        return extensions;
17984    }
17985
17986    match tagpath::config::resolve(&config_path) {
17987        Ok(config) => {
17988            if let Some(grammars) = config.grammars {
17989                for grammar in grammars.languages.values() {
17990                    for ext in &grammar.extensions {
17991                        if let Some(normalized) = normalize_extension(ext) {
17992                            extensions.insert(normalized);
17993                        }
17994                    }
17995                }
17996            }
17997        }
17998        Err(err) => {
17999            eprintln!("tagpath_policy_hint_config_unreadable: {err}");
18000        }
18001    }
18002    extensions
18003}
18004
18005pub(crate) fn tagpath_audit_policy_hints(
18006    rel_path: &str,
18007    supported_extensions: &BTreeSet<String>,
18008) -> Vec<String> {
18009    let path = Path::new(rel_path);
18010    let mut hints = BTreeSet::new();
18011    if let Some(parent) = path.parent() {
18012        for component in parent.components() {
18013            if let std::path::Component::Normal(name) = component {
18014                let name = name.to_string_lossy();
18015                if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
18016                    hints.insert(format!("skip_dir:{name}"));
18017                }
18018            }
18019        }
18020    }
18021    if path
18022        .extension()
18023        .and_then(|ext| ext.to_str())
18024        .and_then(normalize_extension)
18025        .is_some_and(|ext| !supported_extensions.contains(&ext))
18026    {
18027        hints.insert("extension_unsupported".to_string());
18028    }
18029    hints.into_iter().collect()
18030}
18031
18032fn normalize_extension(ext: &str) -> Option<String> {
18033    let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
18034    if normalized.is_empty() {
18035        None
18036    } else {
18037        Some(normalized)
18038    }
18039}
18040
18041pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
18042    match status {
18043        diff_digest::DiffDigestFileStatus::Added => "added",
18044        diff_digest::DiffDigestFileStatus::Modified => "modified",
18045        diff_digest::DiffDigestFileStatus::Deleted => "deleted",
18046    }
18047}
18048
18049pub(crate) fn diff_digest_summary_label(
18050    state: diff_digest::DiffDigestSummaryState,
18051) -> &'static str {
18052    match state {
18053        diff_digest::DiffDigestSummaryState::Current => "current",
18054        diff_digest::DiffDigestSummaryState::Stale => "stale",
18055        diff_digest::DiffDigestSummaryState::Missing => "missing",
18056        diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
18057    }
18058}
18059
18060fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
18061    match state {
18062        test_digest::TestDigestSummaryState::Current => "current",
18063        test_digest::TestDigestSummaryState::Stale => "stale",
18064        test_digest::TestDigestSummaryState::Missing => "missing",
18065        test_digest::TestDigestSummaryState::Unavailable => "unavailable",
18066    }
18067}
18068
18069fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
18070    match state {
18071        log_digest::LogDigestSummaryState::Current => "current",
18072        log_digest::LogDigestSummaryState::Stale => "stale",
18073        log_digest::LogDigestSummaryState::Missing => "missing",
18074        log_digest::LogDigestSummaryState::Unavailable => "unavailable",
18075    }
18076}
18077
18078pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
18079    match mode {
18080        diff_digest::DiffDigestMode::WorkingTree => "worktree",
18081        diff_digest::DiffDigestMode::Cached => "cached",
18082        diff_digest::DiffDigestMode::Revision => "revision",
18083    }
18084}
18085
18086pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
18087    match (&report.mode, &report.revision) {
18088        (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
18089        (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
18090        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18091            format!("revision {revision}")
18092        }
18093        (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
18094    }
18095}
18096
18097pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
18098    match (&report.mode, &report.revision) {
18099        (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
18100        (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
18101        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18102            format!("No diff found for revision {revision}.")
18103        }
18104        (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
18105    }
18106}
18107
18108fn cmd_impact(
18109    path: &Path,
18110    cached: bool,
18111    revision: Option<&str>,
18112    scope: Option<&str>,
18113    limit: usize,
18114    format: OutputFormat,
18115) -> Result<()> {
18116    let report = impact::compute(
18117        path,
18118        impact::ImpactOptions {
18119            cached,
18120            revision,
18121            scope,
18122            limit,
18123        },
18124    )?;
18125    if format.json_output {
18126        println!(
18127            "{}",
18128            to_json_schema(
18129                &report,
18130                format.pretty,
18131                format.terse,
18132                format.ultra_terse,
18133                format.schema
18134            )?
18135        );
18136        return Ok(());
18137    }
18138
18139    if format.compact {
18140        println!(
18141            "impact mode:{} changed:{} symbols:{} tests:{}/{}",
18142            diff_digest_mode_label(report.mode),
18143            report.changed_files.len(),
18144            report.changed_symbols.len(),
18145            report.affected_tests.len(),
18146            report.affected_tests_total
18147        );
18148        for target in &report.affected_tests {
18149            println!(
18150                "{} reasons:{} command:{}",
18151                target.path,
18152                target.reasons.len(),
18153                target.commands.join(" && ")
18154            );
18155        }
18156        for warning in &report.warnings {
18157            println!("warning {warning}");
18158        }
18159        return Ok(());
18160    }
18161
18162    println!("Impact ({})", diff_digest_mode_label(report.mode));
18163    println!("  changed files:          {}", report.changed_files.len());
18164    println!("  changed symbols:        {}", report.changed_symbols.len());
18165    println!(
18166        "  affected tests:         {}/{}",
18167        report.affected_tests.len(),
18168        report.affected_tests_total
18169    );
18170    for target in &report.affected_tests {
18171        println!();
18172        println!("{}", target.path);
18173        for reason in &target.reasons {
18174            println!("  - {reason}");
18175        }
18176        if !target.symbols.is_empty() {
18177            println!("  symbols: {}", target.symbols.join(", "));
18178        }
18179        for command in &target.commands {
18180            println!("  run: {}", command);
18181        }
18182    }
18183    for warning in &report.warnings {
18184        println!("warning: {warning}");
18185    }
18186    Ok(())
18187}
18188
18189pub(crate) fn render_test_digest_from_input(
18190    path: &Path,
18191    input: &str,
18192    runner: Option<&str>,
18193    format: OutputFormat,
18194) -> Result<()> {
18195    let report = test_digest::compute(path, input, runner)?;
18196    if format.json_output {
18197        println!(
18198            "{}",
18199            to_json_schema(
18200                &report,
18201                format.pretty,
18202                format.terse,
18203                format.ultra_terse,
18204                format.schema
18205            )?
18206        );
18207        return Ok(());
18208    }
18209
18210    if report.failure_groups.is_empty() {
18211        println!("No failures detected (runner: {}).", report.runner);
18212        for warning in &report.warnings {
18213            println!("warning: {warning}");
18214        }
18215        return Ok(());
18216    }
18217
18218    if format.compact {
18219        println!(
18220            "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18221            report.runner,
18222            report.failures,
18223            report.grouped_failures,
18224            report.counts.passed.unwrap_or(0),
18225            report.counts.failed.unwrap_or(report.grouped_failures),
18226            report.counts.skipped.unwrap_or(0),
18227        );
18228        for failure in &report.failure_groups {
18229            let tests = truncate_for_compact(&failure.tests.join(","), 60);
18230            let location = match (&failure.path, failure.line) {
18231                (Some(path), Some(line)) => format!("{path}:{line}"),
18232                (Some(path), None) => path.clone(),
18233                _ => "-".to_string(),
18234            };
18235            println!(
18236                "{} tests:{} count:{} summaries:{} msg:{}",
18237                location,
18238                tests,
18239                failure.occurrences,
18240                test_digest_summary_label(failure.summary_state),
18241                truncate_for_compact(&failure.message, 80)
18242            );
18243        }
18244        for warning in &report.warnings {
18245            println!("warning: {warning}");
18246        }
18247        return Ok(());
18248    }
18249
18250    println!("Test digest ({})", report.runner);
18251    println!("  failures:        {}", report.failures);
18252    println!("  failure groups:  {}", report.grouped_failures);
18253    if let Some(passed) = report.counts.passed {
18254        println!("  passed:          {}", passed);
18255    }
18256    if let Some(failed) = report.counts.failed {
18257        println!("  failed:          {}", failed);
18258    }
18259    if let Some(skipped) = report.counts.skipped {
18260        println!("  skipped:         {}", skipped);
18261    }
18262
18263    for failure in &report.failure_groups {
18264        println!();
18265        match (&failure.path, failure.line, failure.column) {
18266            (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18267            (Some(path), Some(line), None) => println!("{path}:{line}"),
18268            (Some(path), None, _) => println!("{path}"),
18269            (None, _, _) => println!("(no file anchor)"),
18270        }
18271        println!("  tests: {}", failure.tests.join(", "));
18272        println!("  occurrences: {}", failure.occurrences);
18273        println!("  message: {}", failure.message);
18274        println!(
18275            "  cached summaries: {}",
18276            test_digest_summary_label(failure.summary_state)
18277        );
18278        for summary in &failure.current_summaries {
18279            println!(
18280                "    - {}: {}",
18281                summary.symbol,
18282                truncate_for_compact(&summary.summary, 160)
18283            );
18284        }
18285    }
18286    for warning in &report.warnings {
18287        println!("warning: {warning}");
18288    }
18289    Ok(())
18290}
18291
18292#[derive(Clone, Serialize, Deserialize)]
18293struct DispatchTraceSummary {
18294    backlog: usize,
18295    job_packet: usize,
18296    worker_result: usize,
18297    worker_context: usize,
18298    source_handle: usize,
18299    semantic_rows: usize,
18300}
18301
18302#[derive(Clone, Serialize, Deserialize)]
18303struct DispatchTraceReport {
18304    contract_version: String,
18305    root: String,
18306    #[serde(skip_serializing_if = "Option::is_none")]
18307    scope: Option<String>,
18308    targets: Vec<String>,
18309    projection_freshness: GraphDbFreshnessReport,
18310    projection_hashes: Vec<String>,
18311    evidence_packet_ids: Vec<String>,
18312    shared_preparation: ConflictMatrixSharedPreparationSummary,
18313    worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18314    worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18315    summary: DispatchTraceSummary,
18316    nodes: Vec<SubstrateTerseGraphNode>,
18317    edges: Vec<SubstrateTerseGraphEdge>,
18318    conflict_matrix_decisions: Vec<String>,
18319    replay_commands: Vec<String>,
18320    repair_commands: Vec<String>,
18321    truncated: bool,
18322    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18323    warnings: Vec<String>,
18324}
18325
18326fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18327    matches!(
18328        kind,
18329        "session"
18330            | "backlog"
18331            | "job_packet"
18332            | "worker_result"
18333            | "worker_context"
18334            | "source_handle"
18335            | "semantic_concept"
18336            | "semantic_entity"
18337            | "file"
18338            | "symbol"
18339            | "route"
18340    )
18341}
18342
18343fn dispatch_trace_kind_rank(kind: &str) -> usize {
18344    match kind {
18345        "backlog" => 0,
18346        "job_packet" => 1,
18347        "worker_result" => 2,
18348        "worker_context" => 3,
18349        "source_handle" => 4,
18350        "file" => 5,
18351        "symbol" => 6,
18352        "route" => 7,
18353        "semantic_concept" => 8,
18354        "semantic_entity" => 9,
18355        "session" => 10,
18356        _ => 99,
18357    }
18358}
18359
18360fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18361    DispatchTraceSummary {
18362        backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18363        job_packet: nodes
18364            .iter()
18365            .filter(|node| node.kind == "job_packet")
18366            .count(),
18367        worker_result: nodes
18368            .iter()
18369            .filter(|node| node.kind == "worker_result")
18370            .count(),
18371        worker_context: nodes
18372            .iter()
18373            .filter(|node| node.kind == "worker_context")
18374            .count(),
18375        source_handle: nodes
18376            .iter()
18377            .filter(|node| node.kind == "source_handle")
18378            .count(),
18379        semantic_rows: nodes
18380            .iter()
18381            .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18382            .count(),
18383    }
18384}
18385
18386fn dispatch_trace_shared_preparation_summary(
18387    graph_nodes: &[SubstrateGraphNode],
18388    graph_edges: &[SubstrateGraphEdge],
18389    conflict: &ConflictMatrixReport,
18390) -> ConflictMatrixSharedPreparationSummary {
18391    ConflictMatrixSharedPreparationSummary {
18392        evidence_cache_status: conflict
18393            .inputs
18394            .shared_preparation
18395            .evidence_cache_status
18396            .clone(),
18397        graph_nodes: graph_nodes.len(),
18398        graph_edges: graph_edges.len(),
18399        evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18400        source_handles: conflict
18401            .candidates
18402            .iter()
18403            .map(|candidate| candidate.source_handles.len())
18404            .sum(),
18405        worker_context: conflict
18406            .candidates
18407            .iter()
18408            .map(|candidate| candidate.worker_context_handles.len())
18409            .sum(),
18410        worker_results: conflict
18411            .candidates
18412            .iter()
18413            .map(|candidate| candidate.worker_feedback.total)
18414            .sum(),
18415        semantic_rows: conflict
18416            .candidates
18417            .iter()
18418            .map(|candidate| candidate.semantic_related.len())
18419            .sum(),
18420        dispatch_trace_snapshot_nodes: graph_nodes.len(),
18421        dispatch_trace_snapshot_edges: graph_edges.len(),
18422    }
18423}
18424
18425fn dispatch_trace_collect_ids(
18426    targets: &[String],
18427    candidates: &[ConflictMatrixCandidate],
18428    graph_nodes: &[SubstrateGraphNode],
18429    graph_edges: &[SubstrateGraphEdge],
18430    depth: usize,
18431    limit: usize,
18432) -> (BTreeSet<String>, bool) {
18433    let target_refs = targets
18434        .iter()
18435        .map(|target| target.trim_start_matches('#').to_string())
18436        .collect::<BTreeSet<_>>();
18437    let mut ids = BTreeSet::new();
18438    for candidate in candidates {
18439        ids.insert(candidate.target_node_id.clone());
18440        for source in &candidate.source_handles {
18441            ids.insert(source.handle.clone());
18442        }
18443        for handle in &candidate.worker_context_handles {
18444            ids.insert(handle.clone());
18445        }
18446        for semantic in &candidate.semantic_related {
18447            ids.insert(semantic.handle.clone());
18448        }
18449    }
18450    for node in graph_nodes {
18451        if !dispatch_trace_allowed_node_kind(&node.kind) {
18452            continue;
18453        }
18454        if node
18455            .properties
18456            .get("ref_id")
18457            .is_some_and(|ref_id| target_refs.contains(ref_id))
18458        {
18459            ids.insert(node.id.clone());
18460        }
18461    }
18462
18463    let node_by_id = graph_nodes
18464        .iter()
18465        .map(|node| (node.id.as_str(), node))
18466        .collect::<BTreeMap<_, _>>();
18467    let max_nodes = if limit == 0 {
18468        usize::MAX
18469    } else {
18470        limit
18471            .saturating_mul(targets.len().max(1))
18472            .saturating_mul(12)
18473            .max(64)
18474    };
18475    let mut truncated = false;
18476    for _ in 0..depth.max(1) {
18477        let before = ids.len();
18478        let current_ids = ids.clone();
18479        for edge in graph_edges {
18480            if ids.len() >= max_nodes {
18481                truncated = true;
18482                break;
18483            }
18484            let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18485            if !touches {
18486                continue;
18487            }
18488            for endpoint in [&edge.from_id, &edge.to_id] {
18489                let Some(node) = node_by_id.get(endpoint.as_str()) else {
18490                    continue;
18491                };
18492                if dispatch_trace_allowed_node_kind(&node.kind) {
18493                    ids.insert(endpoint.clone());
18494                }
18495            }
18496        }
18497        if ids.len() == before || truncated {
18498            break;
18499        }
18500    }
18501    (ids, truncated)
18502}
18503
18504#[allow(clippy::too_many_arguments)]
18505fn build_dispatch_trace_report_from_conflict_snapshot(
18506    root: &Path,
18507    scope: Option<&str>,
18508    conflict: ConflictMatrixReport,
18509    graph_nodes: Vec<SubstrateGraphNode>,
18510    graph_edges: Vec<SubstrateGraphEdge>,
18511    depth: usize,
18512    limit: usize,
18513    extra_warnings: Vec<String>,
18514) -> Result<DispatchTraceReport> {
18515    let shared_preparation =
18516        dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18517    let (ids, truncated) = dispatch_trace_collect_ids(
18518        &conflict.targets,
18519        &conflict.candidates,
18520        &graph_nodes,
18521        &graph_edges,
18522        depth,
18523        limit,
18524    );
18525    let mut nodes = graph_nodes
18526        .into_iter()
18527        .filter(|node| ids.contains(&node.id))
18528        .collect::<Vec<_>>();
18529    nodes.sort_by(|left, right| {
18530        dispatch_trace_kind_rank(&left.kind)
18531            .cmp(&dispatch_trace_kind_rank(&right.kind))
18532            .then(left.id.cmp(&right.id))
18533    });
18534    let node_ids = nodes
18535        .iter()
18536        .map(|node| node.id.as_str())
18537        .collect::<BTreeSet<_>>();
18538    let mut edges = graph_edges
18539        .into_iter()
18540        .filter(|edge| {
18541            node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18542        })
18543        .collect::<Vec<_>>();
18544    edges.sort_by(|left, right| {
18545        left.from_id
18546            .cmp(&right.from_id)
18547            .then(left.kind.cmp(&right.kind))
18548            .then(left.to_id.cmp(&right.to_id))
18549    });
18550    let mut warnings = conflict.warnings;
18551    warnings.extend(extra_warnings);
18552
18553    Ok(DispatchTraceReport {
18554        contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
18555        root: conflict.root,
18556        scope: conflict.scope,
18557        targets: conflict.targets,
18558        projection_freshness: conflict.orchestration.projection_freshness,
18559        projection_hashes: conflict.orchestration.projection_hashes,
18560        evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
18561        shared_preparation,
18562        worker_prompt_packets: conflict.worker_prompt_packets,
18563        worker_feedback: conflict
18564            .candidates
18565            .iter()
18566            .map(|candidate| candidate.worker_feedback.clone())
18567            .collect(),
18568        summary: dispatch_trace_summary(&nodes),
18569        nodes: nodes.into_iter().map(Into::into).collect(),
18570        edges: edges.into_iter().map(Into::into).collect(),
18571        conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
18572        replay_commands: conflict.next_commands,
18573        repair_commands: graph_db_repair_commands(root, scope),
18574        truncated,
18575        warnings,
18576    })
18577}
18578
18579fn build_dispatch_trace_report(
18580    path: &Path,
18581    scope: Option<&str>,
18582    raw_targets: &[String],
18583    depth: usize,
18584    limit: usize,
18585    impact_limit: usize,
18586) -> Result<DispatchTraceReport> {
18587    let root = lint::resolve_project_root_or_canonical_path(path)?;
18588    let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
18589    if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
18590        write_traversal_graph_store(&root, path, scope)
18591            .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
18592    }
18593    let graph_db = graph_substrate_db_path(&root, scope);
18594    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
18595        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
18596    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
18597    let extra_warnings = store
18598        .read_only_recovery()
18599        .map(graph_db_read_recovery_diagnostic)
18600        .into_iter()
18601        .collect::<Vec<_>>();
18602    let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
18603    let graph_prepared = prepare_conflict_matrix_graph_orchestration(
18604        &root,
18605        scope,
18606        "sqlite",
18607        raw_targets,
18608        &prepared,
18609        depth,
18610        limit,
18611        &store,
18612        freshness.clone(),
18613    )?;
18614    let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
18615        &prepared.preparation_cache.source_watermark,
18616        &prepared.preparation_cache.document_watermark,
18617        &prepared.preparation_cache.staged_diff_watermark,
18618        &[
18619            &format!("targets:{}", raw_targets.join(",")),
18620            &format!("depth:{depth}"),
18621            &format!("limit:{limit}"),
18622        ],
18623    );
18624    if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
18625        &root,
18626        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18627        &dt_cache_key,
18628    ) {
18629        return Ok(cached_report);
18630    }
18631    let conflict = build_conflict_matrix_report_from_prepared_graph(
18632        &root,
18633        path,
18634        scope,
18635        depth,
18636        limit,
18637        impact_limit,
18638        freshness,
18639        extra_warnings.clone(),
18640        &prepared,
18641        &graph_prepared,
18642    )?;
18643    let report = build_dispatch_trace_report_from_conflict_snapshot(
18644        &root,
18645        scope,
18646        conflict,
18647        graph_prepared.graph.nodes,
18648        graph_prepared.graph.edges,
18649        depth,
18650        limit,
18651        extra_warnings,
18652    )?;
18653    cycle_packet_cache::cycle_packet_write_cache(
18654        &root,
18655        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18656        &dt_cache_key,
18657        &report,
18658    );
18659    Ok(report)
18660}
18661
18662fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
18663    let json = serde_json::to_string(report)?.replace("</", "<\\/");
18664    let mut html = String::new();
18665    html.push_str(
18666        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
18667    );
18668    html.push_str(
18669        r#"<style>
18670:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
18671@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
18672*{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}}
18673</style>"#,
18674    );
18675    html.push_str("</head><body><div class=\"page\">");
18676    html.push_str(&format!(
18677        "<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>",
18678        html_escape(&report.targets.join(", ")),
18679        report.evidence_packet_ids.len(),
18680        report.nodes.len(),
18681        report.worker_prompt_packets.len(),
18682        html_escape(&report.contract_version)
18683    ));
18684    html.push_str(
18685        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>"#,
18686    );
18687    html.push_str("<script id=\"trace-data\" type=\"application/json\">");
18688    html.push_str(&json);
18689    html.push_str(
18690        r##"</script><script>
18691const report = JSON.parse(document.getElementById("trace-data").textContent);
18692const svg = document.getElementById("graph-canvas");
18693const nodeList = document.getElementById("nodes");
18694const packets = document.getElementById("packets");
18695const feedback = document.getElementById("feedback");
18696const nodes = report.nodes.map((node, index) => ({...node, index}));
18697const nodeById = new Map(nodes.map(node => [node.id, node]));
18698const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
18699const 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"]]);
18700function color(kind){return colorByKind.get(kind)||"#6b7280";}
18701function text(value){return value == null ? "" : String(value);}
18702function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));}
18703function layout(){
18704  const rect = svg.getBoundingClientRect();
18705  const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
18706  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
18707  const counts = new Map();
18708  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
18709  const offsets = new Map();
18710  for (const node of nodes) {
18711    const group = kinds.indexOf(node.kind);
18712    const index = offsets.get(node.kind) || 0;
18713    offsets.set(node.kind, index + 1);
18714    const total = counts.get(node.kind) || 1;
18715    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
18716    const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
18717    node.x = cx + Math.cos(angle) * ring;
18718    node.y = cy + Math.sin(angle) * ring;
18719  }
18720}
18721function draw(){
18722  svg.innerHTML = "";
18723  for (const edge of edges) {
18724    const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
18725    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
18726    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
18727    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
18728    line.setAttribute("class", "edge");
18729    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
18730    svg.appendChild(line);
18731  }
18732  for (const node of nodes) {
18733    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
18734    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
18735    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
18736    circle.setAttribute("fill", color(node.kind));
18737    circle.setAttribute("class", "node");
18738    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
18739    svg.appendChild(circle);
18740    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
18741    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
18742    label.setAttribute("class", "node-label");
18743    label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
18744    svg.appendChild(label);
18745  }
18746}
18747packets.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>";
18748feedback.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>";
18749nodeList.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("");
18750window.addEventListener("resize", () => { layout(); draw(); });
18751layout(); draw();
18752</script></div></body></html>"##,
18753    );
18754    Ok(html)
18755}
18756
18757struct DispatchTraceOptions<'a> {
18758    path: &'a Path,
18759    scope: Option<&'a str>,
18760    raw_targets: &'a [String],
18761    depth: usize,
18762    limit: usize,
18763    impact_limit: usize,
18764    trace_format: DispatchTraceFormat,
18765}
18766
18767fn cmd_dispatch_trace(
18768    options: DispatchTraceOptions<'_>,
18769    output_format: OutputFormat,
18770) -> Result<()> {
18771    let report = build_dispatch_trace_report(
18772        options.path,
18773        options.scope,
18774        options.raw_targets,
18775        options.depth,
18776        options.limit,
18777        options.impact_limit,
18778    )?;
18779    match options.trace_format {
18780        DispatchTraceFormat::Json => {
18781            if output_format.envelope {
18782                print_json_or_envelope(
18783                    &report,
18784                    &output_format,
18785                    "dispatch-trace",
18786                    "operator-review",
18787                    ToolEnvelopeSummary {
18788                        text: format!(
18789                            "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
18790                            report.targets.len(),
18791                            report.nodes.len(),
18792                            report.worker_prompt_packets.len()
18793                        ),
18794                        metrics: vec![
18795                            envelope_metric("targets", report.targets.len()),
18796                            envelope_metric("nodes", report.nodes.len()),
18797                            envelope_metric("edges", report.edges.len()),
18798                            envelope_metric(
18799                                "worker_prompt_packets",
18800                                report.worker_prompt_packets.len(),
18801                            ),
18802                        ],
18803                    },
18804                    report.truncated,
18805                    report.replay_commands.clone(),
18806                )
18807            } else {
18808                println!(
18809                    "{}",
18810                    to_json_schema(
18811                        &report,
18812                        output_format.pretty,
18813                        output_format.terse,
18814                        output_format.ultra_terse,
18815                        output_format.schema
18816                    )?
18817                );
18818                Ok(())
18819            }
18820        }
18821        DispatchTraceFormat::Html => {
18822            println!("{}", dispatch_trace_html(&report)?);
18823            Ok(())
18824        }
18825    }
18826}
18827
18828#[derive(Clone, Debug)]
18829struct DependencyDagProfile {
18830    id: String,
18831    graph_node_id: String,
18832    label: String,
18833    path: Option<String>,
18834    line: Option<i64>,
18835    detail: Option<String>,
18836    source_files: BTreeSet<String>,
18837    source_symbols: BTreeSet<String>,
18838    config_files: BTreeSet<String>,
18839    expected_tests: BTreeSet<String>,
18840    semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
18841    worker_feedback: ConflictMatrixWorkerFeedback,
18842}
18843
18844#[derive(Clone, Debug, Serialize)]
18845struct DependencyDagNode {
18846    id: String,
18847    graph_node_id: String,
18848    label: String,
18849    #[serde(skip_serializing_if = "Option::is_none")]
18850    path: Option<String>,
18851    #[serde(skip_serializing_if = "Option::is_none")]
18852    line: Option<i64>,
18853    #[serde(skip_serializing_if = "Option::is_none")]
18854    detail: Option<String>,
18855    source_files: Vec<String>,
18856    source_symbols: Vec<String>,
18857    config_files: Vec<String>,
18858    expected_tests: Vec<String>,
18859    semantic_refs: Vec<ConflictMatrixSemanticRef>,
18860    worker_feedback: ConflictMatrixWorkerFeedback,
18861}
18862
18863#[derive(Clone, Debug, Serialize)]
18864struct DependencyDagEdge {
18865    from: String,
18866    to: String,
18867    kind: String,
18868    weight: usize,
18869    reasons: Vec<String>,
18870    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18871    shared_files: Vec<String>,
18872    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18873    shared_symbols: Vec<String>,
18874    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18875    shared_tests: Vec<String>,
18876    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18877    shared_config_files: Vec<String>,
18878    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18879    shared_semantic_refs: Vec<String>,
18880}
18881
18882#[derive(Clone, Debug, Serialize)]
18883struct DependencyDagTopoBatch {
18884    batch: usize,
18885    targets: Vec<String>,
18886}
18887
18888#[derive(Clone, Debug, Serialize)]
18889struct DependencyDagCycleDiagnostics {
18890    has_cycles: bool,
18891    blocked_nodes: Vec<String>,
18892    cycle_edges: Vec<DependencyDagEdge>,
18893}
18894
18895#[derive(Serialize)]
18896struct DependencyDagSummary {
18897    nodes: usize,
18898    edges: usize,
18899    topo_batches: usize,
18900    has_cycles: bool,
18901}
18902
18903#[derive(Serialize)]
18904struct DependencyDagReport {
18905    contract_version: &'static str,
18906    root: String,
18907    #[serde(skip_serializing_if = "Option::is_none")]
18908    scope: Option<String>,
18909    path: String,
18910    targets: Vec<String>,
18911    projection_freshness: GraphDbFreshnessReport,
18912    projection_hashes: Vec<String>,
18913    nodes: Vec<DependencyDagNode>,
18914    edges: Vec<DependencyDagEdge>,
18915    topo_batches: Vec<DependencyDagTopoBatch>,
18916    cycle_diagnostics: DependencyDagCycleDiagnostics,
18917    summary: DependencyDagSummary,
18918    replay_commands: Vec<String>,
18919    repair_commands: Vec<String>,
18920    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18921    warnings: Vec<String>,
18922}
18923
18924fn dependency_dag_backlog_node_for_target(
18925    store: &impl GraphStore,
18926    target: &str,
18927) -> Result<SubstrateGraphNode> {
18928    let resolved = graph_db_resolve_evidence_target(store, target)?
18929        .with_context(|| format!("dependency-dag target not found: {target}"))?;
18930    if resolved.kind == "backlog" {
18931        return Ok(resolved);
18932    }
18933    let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
18934        bail!(
18935            "dependency-dag target {} resolved to {} without a backlog ref_id",
18936            target,
18937            resolved.kind
18938        );
18939    };
18940    store
18941        .nodes_by_kind("backlog")?
18942        .into_iter()
18943        .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
18944        .min_by(|left, right| {
18945            left.properties
18946                .get("line")
18947                .and_then(|value| value.parse::<i64>().ok())
18948                .cmp(
18949                    &right
18950                        .properties
18951                        .get("line")
18952                        .and_then(|value| value.parse::<i64>().ok()),
18953                )
18954                .then(left.id.cmp(&right.id))
18955        })
18956        .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
18957}
18958
18959fn dependency_dag_resolve_backlog_nodes(
18960    root: &Path,
18961    path: &Path,
18962    store: &impl GraphStore,
18963    raw_targets: &[String],
18964) -> Result<Vec<SubstrateGraphNode>> {
18965    let mut nodes = Vec::new();
18966    let mut seen = BTreeSet::new();
18967    if raw_targets.is_empty() {
18968        let hinted_path = if path.is_absolute() {
18969            path.to_path_buf()
18970        } else {
18971            root.join(path)
18972        };
18973        let hinted_markdown = hinted_path
18974            .extension()
18975            .and_then(|ext| ext.to_str())
18976            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
18977        let hinted_rel = hinted_markdown.then(|| {
18978            relativize_pathbuf(&hinted_path, root)
18979                .to_string_lossy()
18980                .replace('\\', "/")
18981        });
18982        for node in store.nodes_by_kind("backlog")? {
18983            if let Some(expected_path) = &hinted_rel
18984                && node.properties.get("path") != Some(expected_path)
18985            {
18986                continue;
18987            }
18988            if seen.insert(node.id.clone()) {
18989                nodes.push(node);
18990            }
18991        }
18992        if nodes.is_empty() && hinted_rel.is_some() {
18993            for node in store.nodes_by_kind("backlog")? {
18994                if seen.insert(node.id.clone()) {
18995                    nodes.push(node);
18996                }
18997            }
18998        }
18999    } else {
19000        for target in raw_targets {
19001            let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
19002            let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
19003            if seen.insert(node.id.clone()) {
19004                nodes.push(node);
19005            }
19006        }
19007    }
19008    if nodes.is_empty() {
19009        bail!("dependency-dag needs at least one resolvable backlog id");
19010    }
19011    nodes.sort_by(|left, right| {
19012        left.properties
19013            .get("line")
19014            .and_then(|value| value.parse::<i64>().ok())
19015            .cmp(
19016                &right
19017                    .properties
19018                    .get("line")
19019                    .and_then(|value| value.parse::<i64>().ok()),
19020            )
19021            .then(left.id.cmp(&right.id))
19022    });
19023    Ok(nodes)
19024}
19025
19026fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
19027    node.properties
19028        .get("ref_id")
19029        .cloned()
19030        .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
19031}
19032
19033fn dependency_dag_node_profile(
19034    root: &Path,
19035    store: &impl GraphStore,
19036    node: &SubstrateGraphNode,
19037    graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
19038    graph_edges: &[SubstrateGraphEdge],
19039    depth: usize,
19040    limit: usize,
19041) -> Result<DependencyDagProfile> {
19042    let id = dependency_dag_node_id(node);
19043    let mut source_files = BTreeSet::new();
19044    let mut source_symbols = BTreeSet::new();
19045    for edge in graph_edges
19046        .iter()
19047        .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
19048    {
19049        let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
19050            continue;
19051        };
19052        match target.kind.as_str() {
19053            "file" | "route" => {
19054                if let Some(path) = target.properties.get("path") {
19055                    source_files.insert(path.clone());
19056                }
19057            }
19058            "symbol" => {
19059                source_symbols.insert(target.label.clone());
19060                if let Some(path) = target.properties.get("path") {
19061                    source_files.insert(path.clone());
19062                }
19063            }
19064            _ => {}
19065        }
19066    }
19067
19068    let max_rows = if limit == 0 { usize::MAX } else { limit };
19069    for (source, _) in
19070        graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
19071    {
19072        let terse: SubstrateTerseGraphNode = (&source).into();
19073        if let Some(handle) = conflict_matrix_source_handle(&terse) {
19074            source_files.insert(handle.file);
19075        }
19076    }
19077
19078    let worker_results = graph_nodes_by_id
19079        .values()
19080        .filter(|candidate| {
19081            candidate.kind == "worker_result"
19082                && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
19083        })
19084        .map(SubstrateTerseGraphNode::from)
19085        .collect::<Vec<_>>();
19086    let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
19087    let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
19088    let config_files = source_files
19089        .iter()
19090        .filter(|file| is_planner_config_path(file))
19091        .cloned()
19092        .collect();
19093
19094    let mut semantic_refs = BTreeMap::new();
19095    for kind in ["semantic_concept", "semantic_entity"] {
19096        for (semantic, _) in
19097            graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
19098        {
19099            let terse: SubstrateTerseGraphNode = (&semantic).into();
19100            let item = conflict_matrix_semantic_ref(root, &terse);
19101            semantic_refs
19102                .entry(format!("{}:{}", item.kind, item.label))
19103                .or_insert(item);
19104        }
19105    }
19106
19107    Ok(DependencyDagProfile {
19108        id,
19109        graph_node_id: node.id.clone(),
19110        label: node.label.clone(),
19111        path: node.properties.get("path").cloned(),
19112        line: node
19113            .properties
19114            .get("line")
19115            .and_then(|value| value.parse::<i64>().ok()),
19116        detail: node.properties.get("detail").cloned(),
19117        source_files,
19118        source_symbols,
19119        config_files,
19120        expected_tests,
19121        semantic_refs,
19122        worker_feedback,
19123    })
19124}
19125
19126fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
19127    let lower = text.to_ascii_lowercase();
19128    let mut refs = Vec::new();
19129    for marker in markers {
19130        let mut offset = 0usize;
19131        while let Some(pos) = lower[offset..].find(marker) {
19132            let start = offset + pos + marker.len();
19133            let segment = text[start..]
19134                .split(['\n', '.'])
19135                .next()
19136                .unwrap_or(&text[start..]);
19137            refs.extend(extract_conflict_target_refs(segment));
19138            offset = start;
19139        }
19140    }
19141    dedupe_preserve_order(refs)
19142}
19143
19144fn dependency_dag_push_edge(
19145    edges: &mut Vec<DependencyDagEdge>,
19146    seen: &mut BTreeSet<(String, String, String)>,
19147    edge: DependencyDagEdge,
19148) {
19149    if edge.from == edge.to {
19150        return;
19151    }
19152    if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
19153        edges.push(edge);
19154    }
19155}
19156
19157fn dependency_dag_explicit_edges(
19158    profiles: &[DependencyDagProfile],
19159    target_ids: &BTreeSet<String>,
19160    edges: &mut Vec<DependencyDagEdge>,
19161    seen: &mut BTreeSet<(String, String, String)>,
19162) {
19163    for profile in profiles {
19164        let detail = profile.detail.as_deref().unwrap_or_default();
19165        for dep in dependency_dag_marker_refs(
19166            detail,
19167            &[
19168                "depends on",
19169                "depends-on",
19170                "deps:",
19171                "after",
19172                "blocked by",
19173                "requires",
19174            ],
19175        ) {
19176            if target_ids.contains(&dep) {
19177                dependency_dag_push_edge(
19178                    edges,
19179                    seen,
19180                    DependencyDagEdge {
19181                        from: dep.clone(),
19182                        to: profile.id.clone(),
19183                        kind: "explicit_depends_on".to_string(),
19184                        weight: 1000,
19185                        reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
19186                        shared_files: Vec::new(),
19187                        shared_symbols: Vec::new(),
19188                        shared_tests: Vec::new(),
19189                        shared_config_files: Vec::new(),
19190                        shared_semantic_refs: Vec::new(),
19191                    },
19192                );
19193            }
19194        }
19195        for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
19196            if target_ids.contains(&downstream) {
19197                dependency_dag_push_edge(
19198                    edges,
19199                    seen,
19200                    DependencyDagEdge {
19201                        from: profile.id.clone(),
19202                        to: downstream.clone(),
19203                        kind: "explicit_before".to_string(),
19204                        weight: 900,
19205                        reasons: vec![format!(
19206                            "{} declares it should run before #{downstream}",
19207                            profile.id
19208                        )],
19209                        shared_files: Vec::new(),
19210                        shared_symbols: Vec::new(),
19211                        shared_tests: Vec::new(),
19212                        shared_config_files: Vec::new(),
19213                        shared_semantic_refs: Vec::new(),
19214                    },
19215                );
19216            }
19217        }
19218    }
19219}
19220
19221fn dependency_dag_worker_follow_up_edges(
19222    profiles: &[DependencyDagProfile],
19223    target_ids: &BTreeSet<String>,
19224    edges: &mut Vec<DependencyDagEdge>,
19225    seen: &mut BTreeSet<(String, String, String)>,
19226) {
19227    for profile in profiles {
19228        for follow_up in &profile.worker_feedback.follow_up_ids {
19229            if target_ids.contains(follow_up) {
19230                dependency_dag_push_edge(
19231                    edges,
19232                    seen,
19233                    DependencyDagEdge {
19234                        from: profile.id.clone(),
19235                        to: follow_up.clone(),
19236                        kind: "worker_result_follow_up".to_string(),
19237                        weight: 700,
19238                        reasons: vec![format!(
19239                            "worker_result for #{} references follow-up #{}",
19240                            profile.id, follow_up
19241                        )],
19242                        shared_files: Vec::new(),
19243                        shared_symbols: Vec::new(),
19244                        shared_tests: Vec::new(),
19245                        shared_config_files: Vec::new(),
19246                        shared_semantic_refs: Vec::new(),
19247                    },
19248                );
19249            }
19250        }
19251    }
19252}
19253
19254fn dependency_dag_overlap_edges(
19255    profiles: &[DependencyDagProfile],
19256    edges: &mut Vec<DependencyDagEdge>,
19257    seen: &mut BTreeSet<(String, String, String)>,
19258) {
19259    for left_idx in 0..profiles.len() {
19260        for right_idx in (left_idx + 1)..profiles.len() {
19261            let left = &profiles[left_idx];
19262            let right = &profiles[right_idx];
19263            let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19264            let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19265            let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19266            let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19267            let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19268            let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19269            let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19270            if shared_files.is_empty()
19271                && shared_symbols.is_empty()
19272                && shared_tests.is_empty()
19273                && shared_config_files.is_empty()
19274                && shared_semantic_refs.is_empty()
19275            {
19276                continue;
19277            }
19278            let kind = if shared_files.is_empty()
19279                && shared_symbols.is_empty()
19280                && shared_tests.is_empty()
19281                && shared_config_files.is_empty()
19282            {
19283                "semantic_relation"
19284            } else {
19285                "shared_resource"
19286            };
19287            let mut reasons = Vec::new();
19288            if !shared_files.is_empty() {
19289                reasons.push(format!("shared files: {}", shared_files.join(", ")));
19290            }
19291            if !shared_symbols.is_empty() {
19292                reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19293            }
19294            if !shared_tests.is_empty() {
19295                reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19296            }
19297            if !shared_config_files.is_empty() {
19298                reasons.push(format!(
19299                    "shared config files: {}",
19300                    shared_config_files.join(", ")
19301                ));
19302            }
19303            if !shared_semantic_refs.is_empty() {
19304                reasons.push(format!(
19305                    "shared semantic refs: {}",
19306                    shared_semantic_refs.join(", ")
19307                ));
19308            }
19309            let weight = shared_files.len() * 100
19310                + shared_config_files.len() * 100
19311                + shared_symbols.len() * 40
19312                + shared_tests.len() * 10
19313                + shared_semantic_refs.len() * 5;
19314            dependency_dag_push_edge(
19315                edges,
19316                seen,
19317                DependencyDagEdge {
19318                    from: left.id.clone(),
19319                    to: right.id.clone(),
19320                    kind: kind.to_string(),
19321                    weight,
19322                    reasons,
19323                    shared_files,
19324                    shared_symbols,
19325                    shared_tests,
19326                    shared_config_files,
19327                    shared_semantic_refs,
19328                },
19329            );
19330        }
19331    }
19332}
19333
19334fn dependency_dag_topo_batches(
19335    targets: &[String],
19336    edges: &[DependencyDagEdge],
19337) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19338    let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19339    let order = targets
19340        .iter()
19341        .enumerate()
19342        .map(|(idx, id)| (id.clone(), idx))
19343        .collect::<BTreeMap<_, _>>();
19344    let mut indegree = targets
19345        .iter()
19346        .map(|id| (id.clone(), 0usize))
19347        .collect::<BTreeMap<_, _>>();
19348    let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19349    let mut seen_pairs = BTreeSet::<(String, String)>::new();
19350    for edge in edges {
19351        if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19352            continue;
19353        }
19354        if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19355            continue;
19356        }
19357        *indegree.entry(edge.to.clone()).or_default() += 1;
19358        outgoing
19359            .entry(edge.from.clone())
19360            .or_default()
19361            .push(edge.to.clone());
19362    }
19363    for values in outgoing.values_mut() {
19364        values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19365        values.dedup();
19366    }
19367
19368    let mut processed = BTreeSet::new();
19369    let mut batches = Vec::new();
19370    loop {
19371        let mut ready = targets
19372            .iter()
19373            .filter(|id| !processed.contains(*id))
19374            .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19375            .cloned()
19376            .collect::<Vec<_>>();
19377        ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19378        if ready.is_empty() {
19379            break;
19380        }
19381        for id in &ready {
19382            processed.insert(id.clone());
19383            for next in outgoing.get(id).into_iter().flatten() {
19384                if let Some(value) = indegree.get_mut(next) {
19385                    *value = value.saturating_sub(1);
19386                }
19387            }
19388        }
19389        batches.push(DependencyDagTopoBatch {
19390            batch: batches.len() + 1,
19391            targets: ready,
19392        });
19393    }
19394
19395    let blocked_nodes = targets
19396        .iter()
19397        .filter(|id| !processed.contains(*id))
19398        .cloned()
19399        .collect::<Vec<_>>();
19400    let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19401    let cycle_edges = edges
19402        .iter()
19403        .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19404        .cloned()
19405        .collect::<Vec<_>>();
19406    (
19407        batches,
19408        DependencyDagCycleDiagnostics {
19409            has_cycles: !blocked_nodes.is_empty(),
19410            blocked_nodes,
19411            cycle_edges,
19412        },
19413    )
19414}
19415
19416fn dependency_dag_replay_commands(
19417    path: &Path,
19418    scope: Option<&str>,
19419    targets: &[String],
19420    depth: usize,
19421    limit: usize,
19422) -> Vec<String> {
19423    let target_args = targets
19424        .iter()
19425        .map(|target| shell_quote(target))
19426        .collect::<Vec<_>>()
19427        .join(" ");
19428    let mut command = format!(
19429        "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19430        shell_quote(path.to_string_lossy().as_ref()),
19431        scope
19432            .map(|scope| format!(" --scope {}", shell_quote(scope)))
19433            .unwrap_or_default(),
19434        depth,
19435        limit
19436    );
19437    if !target_args.is_empty() {
19438        command.push(' ');
19439        command.push_str(&target_args);
19440    }
19441    vec![command]
19442}
19443
19444fn build_dependency_dag_report(
19445    path: &Path,
19446    scope: Option<&str>,
19447    raw_targets: &[String],
19448    depth: usize,
19449    limit: usize,
19450) -> Result<DependencyDagReport> {
19451    let root = lint::resolve_project_root_or_canonical_path(path)?;
19452    write_traversal_graph_store(&root, path, scope)
19453        .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19454    let graph_db = graph_substrate_db_path(&root, scope);
19455    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19456        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19457    let mut warnings = Vec::new();
19458    if let Some(recovery) = store.read_only_recovery() {
19459        warnings.push(graph_db_read_recovery_diagnostic(recovery));
19460    }
19461    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19462    if freshness.fail_closed {
19463        bail!(
19464            "dependency-dag graph projection failed closed: {}; repair: {}",
19465            freshness.diagnostics.join("; "),
19466            graph_db_repair_commands(&root, scope).join("; ")
19467        );
19468    }
19469
19470    let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19471    let graph_nodes = store.all_nodes()?;
19472    let graph_edges = store.all_edges()?;
19473    let graph_nodes_by_id = graph_nodes
19474        .into_iter()
19475        .map(|node| (node.id.clone(), node))
19476        .collect::<BTreeMap<_, _>>();
19477    let profiles = target_nodes
19478        .iter()
19479        .map(|node| {
19480            dependency_dag_node_profile(
19481                &root,
19482                &store,
19483                node,
19484                &graph_nodes_by_id,
19485                &graph_edges,
19486                depth,
19487                limit,
19488            )
19489        })
19490        .collect::<Result<Vec<_>>>()?;
19491    let targets = profiles
19492        .iter()
19493        .map(|profile| profile.id.clone())
19494        .collect::<Vec<_>>();
19495    let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19496
19497    let mut edges = Vec::new();
19498    let mut seen_edges = BTreeSet::new();
19499    dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19500    dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19501    dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19502    edges.sort_by(|left, right| {
19503        left.from
19504            .cmp(&right.from)
19505            .then(left.to.cmp(&right.to))
19506            .then(left.kind.cmp(&right.kind))
19507    });
19508    let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19509
19510    let nodes = profiles
19511        .into_iter()
19512        .map(|profile| DependencyDagNode {
19513            id: profile.id,
19514            graph_node_id: profile.graph_node_id,
19515            label: profile.label,
19516            path: profile.path,
19517            line: profile.line,
19518            detail: profile.detail,
19519            source_files: sorted_set(&profile.source_files),
19520            source_symbols: sorted_set(&profile.source_symbols),
19521            config_files: sorted_set(&profile.config_files),
19522            expected_tests: sorted_set(&profile.expected_tests),
19523            semantic_refs: profile.semantic_refs.into_values().collect(),
19524            worker_feedback: profile.worker_feedback,
19525        })
19526        .collect::<Vec<_>>();
19527    let projection_hashes = freshness
19528        .content_hash
19529        .clone()
19530        .into_iter()
19531        .collect::<Vec<_>>();
19532    let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19533    let repair_commands = graph_db_repair_commands(&root, scope);
19534    let summary = DependencyDagSummary {
19535        nodes: nodes.len(),
19536        edges: edges.len(),
19537        topo_batches: topo_batches.len(),
19538        has_cycles: cycle_diagnostics.has_cycles,
19539    };
19540
19541    Ok(DependencyDagReport {
19542        contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19543        root: root.to_string_lossy().to_string(),
19544        scope: scope.map(str::to_string),
19545        path: path.to_string_lossy().to_string(),
19546        targets,
19547        projection_freshness: freshness,
19548        projection_hashes,
19549        nodes,
19550        edges,
19551        topo_batches,
19552        cycle_diagnostics,
19553        summary,
19554        replay_commands,
19555        repair_commands,
19556        warnings,
19557    })
19558}
19559
19560fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
19561    if compact {
19562        println!(
19563            "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
19564            report.targets.len(),
19565            report.edges.len(),
19566            report.topo_batches.len(),
19567            report.cycle_diagnostics.has_cycles
19568        );
19569    } else {
19570        println!("Dependency DAG");
19571        println!("  targets: {}", report.targets.join(", "));
19572        println!("  edges:   {}", report.edges.len());
19573        println!("  cycles:  {}", report.cycle_diagnostics.has_cycles);
19574    }
19575    for batch in &report.topo_batches {
19576        println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
19577    }
19578    for edge in &report.edges {
19579        println!(
19580            "edge {} -> {} kind:{} weight:{}",
19581            edge.from, edge.to, edge.kind, edge.weight
19582        );
19583        for reason in &edge.reasons {
19584            println!("  reason: {reason}");
19585        }
19586    }
19587    if report.cycle_diagnostics.has_cycles {
19588        println!(
19589            "cycle blocked nodes: {}",
19590            report.cycle_diagnostics.blocked_nodes.join(", ")
19591        );
19592    }
19593    for command in &report.replay_commands {
19594        println!("replay: {command}");
19595    }
19596    for command in &report.repair_commands {
19597        println!("repair: {command}");
19598    }
19599    for warning in &report.warnings {
19600        println!("warning: {warning}");
19601    }
19602}
19603
19604fn cmd_dependency_dag(
19605    path: &Path,
19606    scope: Option<&str>,
19607    raw_targets: &[String],
19608    depth: usize,
19609    limit: usize,
19610    format: OutputFormat,
19611) -> Result<()> {
19612    let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
19613    if format.json_output {
19614        print_json_or_envelope(
19615            &report,
19616            &format,
19617            "dependency-dag",
19618            "topological-planning",
19619            ToolEnvelopeSummary {
19620                text: format!(
19621                    "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
19622                    report.targets.len(),
19623                    report.edges.len(),
19624                    report.topo_batches.len(),
19625                    report.cycle_diagnostics.has_cycles
19626                ),
19627                metrics: vec![
19628                    envelope_metric("targets", report.targets.len()),
19629                    envelope_metric("edges", report.edges.len()),
19630                    envelope_metric("topo_batches", report.topo_batches.len()),
19631                    envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
19632                ],
19633            },
19634            report.cycle_diagnostics.has_cycles,
19635            report.replay_commands.clone(),
19636        )
19637    } else {
19638        print_dependency_dag_human(&report, format.compact);
19639        Ok(())
19640    }
19641}
19642
19643pub(crate) fn render_log_digest_from_input(
19644    path: &Path,
19645    input: &str,
19646    format: OutputFormat,
19647) -> Result<()> {
19648    let report = log_digest::compute(path, input)?;
19649    if format.json_output {
19650        println!(
19651            "{}",
19652            to_json_schema(
19653                &report,
19654                format.pretty,
19655                format.terse,
19656                format.ultra_terse,
19657                format.schema
19658            )?
19659        );
19660        return Ok(());
19661    }
19662
19663    if format.compact {
19664        println!(
19665            "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
19666            report.non_empty_lines,
19667            report.signal_groups,
19668            report.repeated_line_groups,
19669            report.file_ref_groups,
19670            report.symbol_ref_groups,
19671            report.stack_groups
19672        );
19673        for signal in &report.signals {
19674            let location = match (&signal.path, signal.line) {
19675                (Some(path), Some(line)) => format!("{path}:{line}"),
19676                (Some(path), None) => path.clone(),
19677                _ => "-".to_string(),
19678            };
19679            println!(
19680                "{} sev:{} count:{} sums:{} msg:{}",
19681                location,
19682                signal.severity,
19683                signal.occurrences,
19684                log_digest_summary_label(signal.summary_state),
19685                truncate_for_compact(&signal.message, 80)
19686            );
19687        }
19688        for repeated in &report.repeated_lines {
19689            println!(
19690                "repeat count:{} line:{}",
19691                repeated.occurrences,
19692                truncate_for_compact(&repeated.line, 80)
19693            );
19694        }
19695        for symbol in &report.symbol_refs {
19696            println!(
19697                "sym:{} count:{} sums:{}",
19698                symbol.symbol,
19699                symbol.occurrences,
19700                log_digest_summary_label(symbol.summary_state)
19701            );
19702        }
19703        for warning in &report.warnings {
19704            println!("warning: {warning}");
19705        }
19706        return Ok(());
19707    }
19708
19709    println!("Log digest");
19710    println!("  lines:                    {}", report.total_lines);
19711    println!("  non-empty lines:          {}", report.non_empty_lines);
19712    println!("  signal groups:            {}", report.signal_groups);
19713    println!(
19714        "  repeated lines:           {}",
19715        report.repeated_line_groups
19716    );
19717    println!(
19718        "  repeated line instances:  {}",
19719        report.repeated_line_occurrences
19720    );
19721    println!("  file refs:                {}", report.file_ref_groups);
19722    println!("  symbol refs:              {}", report.symbol_ref_groups);
19723    println!("  stack groups:             {}", report.stack_groups);
19724
19725    if !report.signals.is_empty() {
19726        println!();
19727        println!("Signals:");
19728        for signal in &report.signals {
19729            match (&signal.path, signal.line, signal.column) {
19730                (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
19731                (Some(path), Some(line), None) => println!("{path}:{line}"),
19732                (Some(path), None, _) => println!("{path}"),
19733                (None, _, _) => println!("(no file anchor)"),
19734            }
19735            println!("  severity: {}", signal.severity);
19736            println!("  occurrences: {}", signal.occurrences);
19737            println!("  message: {}", signal.message);
19738            println!(
19739                "  cached summaries: {}",
19740                log_digest_summary_label(signal.summary_state)
19741            );
19742            for summary in &signal.current_summaries {
19743                println!(
19744                    "    - {}: {}",
19745                    summary.symbol,
19746                    truncate_for_compact(&summary.summary, 160)
19747                );
19748            }
19749        }
19750    }
19751
19752    if !report.repeated_lines.is_empty() {
19753        println!();
19754        println!("Repeated lines:");
19755        for repeated in &report.repeated_lines {
19756            println!(
19757                "  {}x {}",
19758                repeated.occurrences,
19759                truncate_for_compact(&repeated.line, 180)
19760            );
19761        }
19762    }
19763
19764    if !report.file_refs.is_empty() {
19765        println!();
19766        println!("Anchored files:");
19767        for file_ref in &report.file_refs {
19768            match (file_ref.line, file_ref.column) {
19769                (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
19770                (Some(line), None) => println!("{}:{}", file_ref.path, line),
19771                (None, _) => println!("{}", file_ref.path),
19772            }
19773            println!("  occurrences: {}", file_ref.occurrences);
19774            println!(
19775                "  cached summaries: {}",
19776                log_digest_summary_label(file_ref.summary_state)
19777            );
19778            for summary in &file_ref.current_summaries {
19779                println!(
19780                    "    - {}: {}",
19781                    summary.symbol,
19782                    truncate_for_compact(&summary.summary, 160)
19783                );
19784            }
19785        }
19786    }
19787
19788    if !report.symbol_refs.is_empty() {
19789        println!();
19790        println!("Symbol candidates:");
19791        for symbol in &report.symbol_refs {
19792            println!("{}", symbol.symbol);
19793            println!("  occurrences: {}", symbol.occurrences);
19794            println!(
19795                "  cached summaries: {}",
19796                log_digest_summary_label(symbol.summary_state)
19797            );
19798            for summary in &symbol.current_summaries {
19799                println!(
19800                    "    - {}: {}",
19801                    summary.symbol,
19802                    truncate_for_compact(&summary.summary, 160)
19803                );
19804            }
19805        }
19806    }
19807
19808    if !report.stack_traces.is_empty() {
19809        println!();
19810        println!("Stack groups:");
19811        for stack in &report.stack_traces {
19812            println!("  occurrences: {}", stack.occurrences);
19813            for frame in &stack.frames {
19814                println!("    - {}", frame);
19815            }
19816        }
19817    }
19818
19819    for warning in &report.warnings {
19820        println!("warning: {warning}");
19821    }
19822    Ok(())
19823}
19824
19825pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
19826    match trend {
19827        metric_digest::MetricDigestTrend::Improved => "improved",
19828        metric_digest::MetricDigestTrend::Regressed => "regressed",
19829        metric_digest::MetricDigestTrend::Flat => "flat",
19830        metric_digest::MetricDigestTrend::Unknown => "changed",
19831    }
19832}
19833
19834pub(crate) fn metric_digest_gate_label(
19835    decision: metric_digest::CommunitySearchGateDecision,
19836) -> &'static str {
19837    match decision {
19838        metric_digest::CommunitySearchGateDecision::Pass => "pass",
19839        metric_digest::CommunitySearchGateDecision::Block => "block",
19840    }
19841}
19842
19843fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
19844    let input = fs::read_to_string(fixture_path)
19845        .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
19846    let report = dci_benchmark::compute(&input)?;
19847
19848    if format.json_output {
19849        println!(
19850            "{}",
19851            to_json_schema(
19852                &report,
19853                format.pretty,
19854                format.terse,
19855                format.ultra_terse,
19856                format.schema
19857            )?
19858        );
19859        return Ok(());
19860    }
19861
19862    if format.compact {
19863        println!(
19864            "dci tasks:{} strategies:{} warnings:{}",
19865            report.tasks_loaded,
19866            report.strategies_compared,
19867            report.warnings.len()
19868        );
19869        for summary in &report.strategy_summaries {
19870            println!(
19871                "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
19872                summary.strategy,
19873                summary.rank,
19874                summary.localized,
19875                summary.task_runs,
19876                dci_benchmark::format_number(summary.localization_rate * 100.0),
19877                dci_benchmark::format_number(summary.avg_useful_hits),
19878                dci_benchmark::format_number(summary.zero_output_rate * 100.0),
19879                dci_benchmark::format_number(summary.avg_tool_calls),
19880                dci_benchmark::format_number(summary.avg_latency_ms),
19881                dci_benchmark::format_number(summary.avg_estimated_tokens),
19882                dci_benchmark::format_number(summary.avg_output_tokens)
19883            );
19884        }
19885        if let Some(gate) = &report.memory_retrieval_gate {
19886            println!(
19887                "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
19888                gate.decision,
19889                gate.baseline_strategy,
19890                dci_benchmark::format_number(gate.min_avg_useful_hits),
19891                gate.max_zero_output_failures,
19892                gate.diagnostics.len()
19893            );
19894        }
19895        for warning in &report.warnings {
19896            println!("warning: {warning}");
19897        }
19898        return Ok(());
19899    }
19900
19901    println!("DCI benchmark");
19902    if let Some(description) = &report.description {
19903        println!("  description: {}", description);
19904    }
19905    println!("  tasks loaded:        {}", report.tasks_loaded);
19906    println!("  strategies compared: {}", report.strategies_compared);
19907
19908    println!();
19909    println!("Strategy summary:");
19910    for summary in &report.strategy_summaries {
19911        println!(
19912            "  #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
19913            summary.rank,
19914            summary.strategy,
19915            summary.localized,
19916            summary.task_runs,
19917            summary.localization_rate * 100.0,
19918            dci_benchmark::format_number(summary.avg_useful_hits),
19919            summary.zero_output_rate * 100.0,
19920            dci_benchmark::format_number(summary.avg_tool_calls),
19921            dci_benchmark::format_number(summary.avg_latency_ms),
19922            dci_benchmark::format_number(summary.avg_estimated_tokens),
19923            dci_benchmark::format_number(summary.avg_output_tokens)
19924        );
19925    }
19926
19927    if let Some(gate) = &report.memory_retrieval_gate {
19928        println!();
19929        println!("Memory retrieval gate:");
19930        println!("  decision: {}", gate.decision);
19931        println!(
19932            "  baseline: {}, min avg useful hits {}, max zero-output failures {}",
19933            gate.baseline_strategy,
19934            dci_benchmark::format_number(gate.min_avg_useful_hits),
19935            gate.max_zero_output_failures
19936        );
19937        for row in &gate.rows {
19938            println!(
19939                "  {}: status {}, avg useful hits {}, zero-output failures {}",
19940                row.strategy,
19941                row.status,
19942                dci_benchmark::format_number(row.avg_useful_hits),
19943                row.zero_output_failures
19944            );
19945        }
19946        for diagnostic in &gate.diagnostics {
19947            println!("  diagnostic: {diagnostic}");
19948        }
19949    }
19950
19951    println!();
19952    println!("Task winners:");
19953    for row in &report.task_rows {
19954        let label = row
19955            .label
19956            .as_ref()
19957            .map(|value| format!(" ({value})"))
19958            .unwrap_or_default();
19959        println!("  {}{}", row.task_id, label);
19960        println!("    localized: {}", row.best_localization.join(", "));
19961        println!("    most useful hits: {}", row.most_useful_hits.join(", "));
19962        println!(
19963            "    lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
19964            row.lowest_tool_calls.as_deref().unwrap_or("-"),
19965            row.lowest_latency.as_deref().unwrap_or("-"),
19966            row.lowest_token_budget.as_deref().unwrap_or("-"),
19967            row.lowest_output_tokens.as_deref().unwrap_or("-")
19968        );
19969        if !row.zero_output_failures.is_empty() {
19970            println!("    zero output: {}", row.zero_output_failures.join(", "));
19971        }
19972    }
19973
19974    for warning in &report.warnings {
19975        println!("warning: {warning}");
19976    }
19977    Ok(())
19978}
19979
19980pub(crate) fn format_compact_count(value: u64) -> String {
19981    if value >= 1_000_000 {
19982        format!("{:.1}M", value as f64 / 1_000_000.0)
19983    } else if value >= 1_000 {
19984        format!("{:.1}K", value as f64 / 1_000.0)
19985    } else {
19986        value.to_string()
19987    }
19988}
19989
19990fn cmd_digest_runner(
19991    kind: &str,
19992    path: &Path,
19993    runner: Option<&str>,
19994    shell_command: &str,
19995    format: OutputFormat,
19996) -> Result<()> {
19997    let digest_kind = DigestRunnerKind::parse(kind)?;
19998    let root = transcript_artifact_root(path)?;
19999    let execution = run_digest_runner_command(shell_command)?;
20000    let output = &execution.output;
20001    let captured = String::from_utf8_lossy(&output.stdout).into_owned();
20002    let exit_code = output.status.code().unwrap_or(-1);
20003    if format.json_output && format.envelope {
20004        let artifact_key = format!(
20005            "{}:{}:{}:{}",
20006            digest_kind.as_str(),
20007            shell_command,
20008            execution.executed_command,
20009            captured
20010        );
20011        let artifact = if captured.trim().is_empty() {
20012            None
20013        } else {
20014            let (suffix, expand) = match digest_kind {
20015                DigestRunnerKind::Test => (
20016                    "test.log",
20017                    format!(
20018                        "tsift test-digest --path {} --input {}{} --json",
20019                        shell_quote(root.to_string_lossy().as_ref()),
20020                        shell_quote(
20021                            root.join(".tsift/artifacts")
20022                                .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
20023                                .to_string_lossy()
20024                                .as_ref()
20025                        ),
20026                        runner
20027                            .map(|value| format!(" --runner {}", shell_quote(value)))
20028                            .unwrap_or_default()
20029                    ),
20030                ),
20031                DigestRunnerKind::Log => (
20032                    "log",
20033                    format!(
20034                        "tsift log-digest --path {} --input {} --json",
20035                        shell_quote(root.to_string_lossy().as_ref()),
20036                        shell_quote(
20037                            root.join(".tsift/artifacts")
20038                                .join(format!("{}.log", stable_handle("tart", &artifact_key)))
20039                                .to_string_lossy()
20040                                .as_ref()
20041                        )
20042                    ),
20043                ),
20044            };
20045            Some(persist_transcript_artifact(
20046                &root,
20047                "tart",
20048                suffix,
20049                &artifact_key,
20050                &captured,
20051                expand,
20052            )?)
20053        };
20054        let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
20055
20056        match digest_kind {
20057            DigestRunnerKind::Test => {
20058                let digest_report = test_digest::compute(path, &captured, runner)?;
20059                let report = serde_json::json!({
20060                    "kind": digest_kind.as_str(),
20061                    "command": shell_command,
20062                    "executed_command": execution.executed_command,
20063                    "exit_code": exit_code,
20064                    "success": output.status.success(),
20065                    "filter": filter_report,
20066                    "artifact": artifact,
20067                    "digest": digest_report,
20068                });
20069                let mut follow_up = artifact
20070                    .as_ref()
20071                    .map(|entry| vec![entry.expand.clone()])
20072                    .unwrap_or_default();
20073                follow_up.push(format!(
20074                    "tsift rewrite --run {}",
20075                    shell_quote(shell_command)
20076                ));
20077                let summary_text = if output.status.success() && digest_report.failures == 0 {
20078                    format!("test run passed for {}", runner.unwrap_or("auto"))
20079                } else {
20080                    format!("test run captured {} failure(s)", digest_report.failures)
20081                };
20082                print_json_or_envelope(
20083                    &report,
20084                    &format,
20085                    "digest-runner",
20086                    "test-run",
20087                    ToolEnvelopeSummary {
20088                        text: summary_text,
20089                        metrics: vec![
20090                            envelope_metric("runner", &digest_report.runner),
20091                            envelope_metric("exit_code", exit_code),
20092                            envelope_metric("filter", execution.filter_label()),
20093                            envelope_metric("failures", digest_report.failures),
20094                            envelope_metric("groups", digest_report.grouped_failures),
20095                            envelope_metric(
20096                                "artifact",
20097                                artifact
20098                                    .as_ref()
20099                                    .map(|entry| entry.handle.as_str())
20100                                    .unwrap_or("-"),
20101                            ),
20102                        ],
20103                    },
20104                    false,
20105                    follow_up,
20106                )?;
20107            }
20108            DigestRunnerKind::Log => {
20109                let digest_report = log_digest::compute(path, &captured)?;
20110                let report = serde_json::json!({
20111                    "kind": digest_kind.as_str(),
20112                    "command": shell_command,
20113                    "executed_command": execution.executed_command,
20114                    "exit_code": exit_code,
20115                    "success": output.status.success(),
20116                    "filter": filter_report,
20117                    "artifact": artifact,
20118                    "digest": digest_report,
20119                });
20120                let mut follow_up = artifact
20121                    .as_ref()
20122                    .map(|entry| vec![entry.expand.clone()])
20123                    .unwrap_or_default();
20124                follow_up.push(format!(
20125                    "tsift rewrite --run {}",
20126                    shell_quote(shell_command)
20127                ));
20128                let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
20129                    "command finished without log signals".to_string()
20130                } else {
20131                    format!(
20132                        "command emitted {} log signal group(s)",
20133                        digest_report.signal_groups
20134                    )
20135                };
20136                print_json_or_envelope(
20137                    &report,
20138                    &format,
20139                    "digest-runner",
20140                    "command-run",
20141                    ToolEnvelopeSummary {
20142                        text: summary_text,
20143                        metrics: vec![
20144                            envelope_metric("exit_code", exit_code),
20145                            envelope_metric("filter", execution.filter_label()),
20146                            envelope_metric("signals", digest_report.signal_groups),
20147                            envelope_metric("file_refs", digest_report.file_ref_groups),
20148                            envelope_metric(
20149                                "artifact",
20150                                artifact
20151                                    .as_ref()
20152                                    .map(|entry| entry.handle.as_str())
20153                                    .unwrap_or("-"),
20154                            ),
20155                        ],
20156                    },
20157                    false,
20158                    follow_up,
20159                )?;
20160            }
20161        }
20162
20163        if output.status.success() {
20164            return Ok(());
20165        }
20166        if let Some(code) = output.status.code() {
20167            std::process::exit(code);
20168        }
20169        bail!("digest-wrapped command terminated by signal: {shell_command}");
20170    }
20171
20172    if captured.trim().is_empty() {
20173        let label = match digest_kind {
20174            DigestRunnerKind::Test => "test",
20175            DigestRunnerKind::Log => "log",
20176        };
20177        println!("No {label} output captured.");
20178    } else {
20179        match digest_kind {
20180            DigestRunnerKind::Test => {
20181                render_test_digest_from_input(path, &captured, runner, format)?
20182            }
20183            DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20184        }
20185    }
20186
20187    if output.status.success() {
20188        return Ok(());
20189    }
20190    if let Some(code) = output.status.code() {
20191        std::process::exit(code);
20192    }
20193    bail!("digest-wrapped command terminated by signal: {shell_command}");
20194}
20195
20196struct DigestRunnerExecution {
20197    output: std::process::Output,
20198    executed_command: String,
20199    filter: Option<DigestRunnerFilter>,
20200}
20201
20202impl DigestRunnerExecution {
20203    fn filter_label(&self) -> &'static str {
20204        self.filter
20205            .as_ref()
20206            .map(|filter| filter.tool)
20207            .unwrap_or("none")
20208    }
20209}
20210
20211struct DigestRunnerFilter {
20212    tool: &'static str,
20213    command: String,
20214}
20215
20216impl DigestRunnerFilter {
20217    fn to_json(&self) -> serde_json::Value {
20218        serde_json::json!({
20219            "tool": self.tool,
20220            "command": self.command,
20221        })
20222    }
20223}
20224
20225fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20226    let filter = rtk_rewrite_for_digest_runner(shell_command);
20227    let executed_command = filter
20228        .as_ref()
20229        .map(|filter| filter.command.as_str())
20230        .unwrap_or(shell_command);
20231    let output = Command::new("sh")
20232        .arg("-lc")
20233        .arg(format!("({executed_command}) 2>&1"))
20234        .stdout(Stdio::piped())
20235        .output()
20236        .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20237
20238    Ok(DigestRunnerExecution {
20239        output,
20240        executed_command: executed_command.to_string(),
20241        filter,
20242    })
20243}
20244
20245fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20246    if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20247        return None;
20248    }
20249    let output = Command::new("rtk")
20250        .arg("rewrite")
20251        .arg(shell_command)
20252        .output()
20253        .ok()?;
20254    if !output.status.success() {
20255        return None;
20256    }
20257    let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20258    if rewritten.is_empty() || rewritten == shell_command {
20259        return None;
20260    }
20261    Some(DigestRunnerFilter {
20262        tool: "rtk",
20263        command: rewritten,
20264    })
20265}
20266
20267fn find_command_on_path(command: &str) -> Option<PathBuf> {
20268    let path_var = std::env::var_os("PATH")?;
20269    std::env::split_paths(&path_var)
20270        .map(|dir| dir.join(command))
20271        .find(|candidate| candidate.is_file())
20272}
20273
20274pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20275    if !db_path.exists() {
20276        bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20277    }
20278    summarize::SummaryDb::open_read_only_resilient(db_path)
20279}
20280
20281fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20282    !matches!(report.index, status::IndexStatus::Fresh { .. })
20283}
20284
20285fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20286    !matches!(report.instructions, init::InstructionStatus::Current { .. })
20287}
20288
20289pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20290    if status_instructions_need_fix(report) {
20291        eprintln!("status fix: refreshing tsift instructions");
20292        init::init(root, false, false)?;
20293    }
20294
20295    let eviction = cycle_packet_cache::cycle_packet_cache_evict(
20296        root,
20297        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
20298        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
20299    );
20300    if eviction.evicted_entries > 0 {
20301        eprintln!(
20302            "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
20303            eviction.evicted_entries,
20304            eviction.evicted_bytes,
20305            eviction.remaining_entries
20306        );
20307    }
20308
20309    if !status_index_needs_fix(report) {
20310        return Ok(());
20311    }
20312
20313    let scopes = config::Config::submodule_dirs(root)?;
20314    if scopes.is_empty() {
20315        eprintln!("status fix: refreshing index");
20316        run_index_update(
20317            &root.join(".tsift/index.db"),
20318            root,
20319            "status --fix refreshing index".to_string(),
20320            root,
20321            None,
20322            false,
20323            false,
20324        )?;
20325        return Ok(());
20326    }
20327
20328    let cfg = config::Config::load(root)?;
20329    for scope in scopes {
20330        if !scope.source_root.exists() {
20331            eprintln!(
20332                "status fix: skipping missing submodule `{}` ({})",
20333                scope.id,
20334                scope.source_root.display()
20335            );
20336            continue;
20337        }
20338        eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20339        run_index_update(
20340            &cfg.db_path_for(root, &scope.id),
20341            &scope.source_root,
20342            format!("status --fix refreshing submodule `{}` index", scope.id),
20343            root,
20344            Some(scope.id.as_str()),
20345            false,
20346            false,
20347        )?;
20348    }
20349
20350    Ok(())
20351}
20352
20353pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20354    match &report.index {
20355        status::IndexStatus::Fresh { missing_scopes, .. }
20356        | status::IndexStatus::Stale { missing_scopes, .. }
20357        | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20358    }
20359}
20360
20361pub(crate) fn autoindex_missing_workspace_scopes(
20362    root: &Path,
20363    report: &status::StatusReport,
20364) -> Result<()> {
20365    let missing_scopes = match &report.index {
20366        status::IndexStatus::Fresh { missing_scopes, .. }
20367        | status::IndexStatus::Stale { missing_scopes, .. }
20368        | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20369    };
20370    if missing_scopes.is_empty() {
20371        return Ok(());
20372    }
20373
20374    let missing_scope_ids = missing_scopes
20375        .iter()
20376        .map(|scope| scope.scope.as_str())
20377        .collect::<std::collections::HashSet<_>>();
20378    let cfg = config::Config::load(root)?;
20379    for scope in config::Config::submodule_dirs(root)? {
20380        if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20381            continue;
20382        }
20383        let db_path = cfg.db_path_for(root, &scope.id);
20384        run_index_update(
20385            &db_path,
20386            &scope.source_root,
20387            format!(
20388                "autoindexing missing submodule `{}` during status",
20389                scope.id
20390            ),
20391            root,
20392            Some(scope.id.as_str()),
20393            false,
20394            false,
20395        )?;
20396    }
20397    Ok(())
20398}
20399
20400pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
20401    for warning in &stats.warnings {
20402        let rel_path = relativize_pathbuf(&warning.path, root);
20403        eprintln!(
20404            "warning: summarize stats {}: {}",
20405            rel_path.display(),
20406            warning.message
20407        );
20408    }
20409}
20410
20411fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
20412    Result::<(), anyhow::Error>::Err(err)
20413        .context(context)
20414        .unwrap_err()
20415}
20416
20417fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
20418    let message = err.to_string();
20419    message.contains("another tsift index writer is already active")
20420        || substrate::error_mentions_locked_db(err)
20421}
20422
20423fn add_write_lock_context(
20424    err: anyhow::Error,
20425    action: String,
20426    root: &std::path::Path,
20427    scope: Option<&str>,
20428) -> anyhow::Error {
20429    if !should_attach_lock_diagnostics(&err) {
20430        return contextualize_error(err, action);
20431    }
20432
20433    let Ok(report) = status::check_locks(root, None, scope) else {
20434        return contextualize_error(err, action);
20435    };
20436
20437    contextualize_error(
20438        err,
20439        format!(
20440            "{}\n\nlock diagnostics:\n{}",
20441            action,
20442            status::format_locks_human(&report, false).trim_end()
20443        ),
20444    )
20445}
20446
20447pub(crate) fn run_index_update(
20448    db_path: &std::path::Path,
20449    source_root: &std::path::Path,
20450    action: String,
20451    root: &std::path::Path,
20452    scope: Option<&str>,
20453    rebuild: bool,
20454    prune: bool,
20455) -> Result<index::IndexSummary> {
20456    let result = (|| {
20457        let db = index::IndexDb::open(db_path)?;
20458        if rebuild {
20459            db.rebuild(source_root)
20460        } else if prune {
20461            db.apply_changes_pruned(source_root)
20462        } else {
20463            db.apply_changes(source_root)
20464        }
20465    })();
20466
20467    let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
20468    emit_index_warnings(&summary, source_root, scope);
20469    Ok(summary)
20470}
20471
20472pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
20473    for change in &mut summary.changes {
20474        change.path = relativize_pathbuf(&change.path, root);
20475    }
20476    for warning in &mut summary.warnings {
20477        warning.path = relativize_pathbuf(&warning.path, root);
20478    }
20479}
20480
20481fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
20482    for warning in &summary.warnings {
20483        let rel_path = relativize_pathbuf(&warning.path, root);
20484        let stage = match warning.stage {
20485            index::IndexWarningStage::ReadSource => "read failed",
20486            index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
20487            index::IndexWarningStage::ExtractCallSites => "call extraction failed",
20488            index::IndexWarningStage::ExtractRoutes => "route extraction failed",
20489        };
20490        let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
20491        let lang_suffix = warning
20492            .language
20493            .as_deref()
20494            .map(|lang| format!(" [{}]", lang))
20495            .unwrap_or_default();
20496        eprintln!(
20497            "warning: {}{}{}: {}: {}",
20498            scope_prefix,
20499            rel_path.display(),
20500            lang_suffix,
20501            stage,
20502            warning.message
20503        );
20504    }
20505}
20506
20507pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
20508    let config_path = root.join(".tsift/config.toml");
20509    if !config_path.exists() {
20510        return summarize::SummarizeConfig::default();
20511    }
20512    #[derive(serde::Deserialize, Default)]
20513    struct RawConfig {
20514        #[serde(default)]
20515        summarize: Option<RawSummarize>,
20516    }
20517    #[derive(serde::Deserialize)]
20518    struct RawSummarize {
20519        model: Option<String>,
20520        max_file_tokens: Option<usize>,
20521        api_key_env: Option<String>,
20522    }
20523    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
20524    let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
20525    let defaults = summarize::SummarizeConfig::default();
20526    match raw.summarize {
20527        Some(s) => summarize::SummarizeConfig {
20528            model: s.model.unwrap_or(defaults.model),
20529            max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
20530            api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
20531        },
20532        None => defaults,
20533    }
20534}
20535
20536#[derive(Debug, Clone, PartialEq, Eq)]
20537struct ExtractSymbolContext {
20538    db_path: PathBuf,
20539    source_root: PathBuf,
20540}
20541
20542pub(crate) fn find_symbols_db_for_file(
20543    root: &Path,
20544    file_path: &Path,
20545) -> Result<Option<ExtractSymbolContext>> {
20546    let cfg = config::Config::load(root)?;
20547    let mut submodules = config::Config::submodule_dirs(root)?;
20548    submodules.sort_by(|left, right| {
20549        right
20550            .source_root
20551            .components()
20552            .count()
20553            .cmp(&left.source_root.components().count())
20554    });
20555
20556    for scope in submodules {
20557        if !file_path.starts_with(&scope.source_root) {
20558            continue;
20559        }
20560        let db_path = cfg.db_path_for(root, &scope.id);
20561        if db_path.exists() {
20562            return Ok(Some(ExtractSymbolContext {
20563                db_path,
20564                source_root: scope.source_root,
20565            }));
20566        }
20567    }
20568
20569    let single = root.join(".tsift/index.db");
20570    if single.exists() && file_path.starts_with(root) {
20571        return Ok(Some(ExtractSymbolContext {
20572            db_path: single,
20573            source_root: root.to_path_buf(),
20574        }));
20575    }
20576
20577    Ok(None)
20578}
20579
20580pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
20581    let canonical = path
20582        .canonicalize()
20583        .with_context(|| format!("canonicalizing {}", path.display()))?;
20584
20585    Ok(if canonical.is_dir() {
20586        canonical
20587    } else {
20588        canonical
20589            .parent()
20590            .map(Path::to_path_buf)
20591            .unwrap_or(canonical)
20592    })
20593}
20594
20595fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
20596    if path.exists() {
20597        return path
20598            .canonicalize()
20599            .with_context(|| format!("canonicalizing extract scope {}", path.display()));
20600    }
20601
20602    Ok(summarize::normalize_lexical_path(path))
20603}
20604
20605pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
20606    let scope = if extract_path.is_absolute() {
20607        extract_path.to_path_buf()
20608    } else {
20609        root.join(extract_path)
20610    };
20611    normalize_extract_scope_path(&scope)
20612}
20613
20614pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
20615    normalize_extract_scope_path(changed_path)
20616        .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
20617        .starts_with(extract_scope)
20618}
20619
20620pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
20621    summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
20622}
20623
20624pub(crate) fn summarize_full_extract_deleted_summary_paths(
20625    summary_db: &summarize::SummaryDb,
20626    root: &Path,
20627    extract_scope: &Path,
20628    files_to_extract: &[PathBuf],
20629) -> Result<BTreeSet<String>> {
20630    let live_paths = files_to_extract
20631        .iter()
20632        .map(|file_path| summarize_relative_file_path(root, file_path))
20633        .collect::<BTreeSet<_>>();
20634    let mut deleted = BTreeSet::new();
20635
20636    for cached_path in summary_db.cached_file_paths()? {
20637        if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
20638            continue;
20639        }
20640        if !live_paths.contains(&cached_path) {
20641            deleted.insert(cached_path);
20642        }
20643    }
20644
20645    Ok(deleted)
20646}
20647
20648#[derive(Debug, Clone)]
20649struct SearchIndexTarget {
20650    label: String,
20651    db_path: PathBuf,
20652    source_root: PathBuf,
20653    scope_name: Option<String>,
20654    reindex_cmd: String,
20655}
20656
20657fn cargo_package_index_target(
20658    root: &Path,
20659    package: multiplicity::CargoPackageInfo,
20660) -> SearchIndexTarget {
20661    SearchIndexTarget {
20662        label: format!("cargo package `{}` index", package.scope_id),
20663        db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
20664        source_root: package.package_root.clone(),
20665        scope_name: Some(package.scope_id.clone()),
20666        reindex_cmd: format!(
20667            "tsift index --submodule {} {}",
20668            package.scope_id,
20669            root.display()
20670        ),
20671    }
20672}
20673
20674#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20675enum SearchIndexState {
20676    Missing,
20677    Fresh,
20678    Stale { stale_files: usize },
20679}
20680
20681fn resolve_search_index_targets(
20682    root: &Path,
20683    path_hint: &Path,
20684    scope: Option<&str>,
20685    federated: bool,
20686) -> Result<Vec<SearchIndexTarget>> {
20687    if let Some(scope_name) = scope {
20688        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
20689            let cfg = config::Config::load(root)?;
20690            return Ok(vec![SearchIndexTarget {
20691                label: format!("submodule `{}` index", scope.id),
20692                db_path: cfg.db_path_for(root, &scope.id),
20693                source_root: scope.source_root.clone(),
20694                scope_name: Some(scope.id.clone()),
20695                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20696            }]);
20697        }
20698        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
20699            return Ok(vec![cargo_package_index_target(root, package)]);
20700        }
20701        config::Config::resolve_submodule(root, scope_name)?;
20702    }
20703
20704    if federated {
20705        let cfg = config::Config::load(root)?;
20706        let mut targets = Vec::new();
20707        for scope in config::Config::submodule_dirs(root)? {
20708            if !cfg.federation_for_scope(&scope) {
20709                continue;
20710            }
20711            targets.push(SearchIndexTarget {
20712                label: format!("submodule `{}` index", scope.id),
20713                db_path: cfg.db_path_for(root, &scope.id),
20714                source_root: scope.source_root.clone(),
20715                scope_name: Some(scope.id.clone()),
20716                reindex_cmd: format!("tsift index --workspace {}", root.display()),
20717            });
20718        }
20719        return Ok(targets);
20720    }
20721
20722    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
20723        let cfg = config::Config::load(root)?;
20724        return Ok(vec![SearchIndexTarget {
20725            label: format!("submodule `{}` index", scope.id),
20726            db_path: cfg.db_path_for(root, &scope.id),
20727            source_root: scope.source_root.clone(),
20728            scope_name: Some(scope.id.clone()),
20729            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20730        }]);
20731    }
20732
20733    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
20734        return Ok(vec![cargo_package_index_target(root, package)]);
20735    }
20736
20737    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
20738        let cfg = config::Config::load(root)?;
20739        return Ok(vec![SearchIndexTarget {
20740            label: format!("submodule `{}` index", scope.id),
20741            db_path: cfg.db_path_for(root, &scope.id),
20742            source_root: scope.source_root.clone(),
20743            scope_name: Some(scope.id.clone()),
20744            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20745        }]);
20746    }
20747
20748    let scopes = config::Config::submodule_dirs(root)?;
20749    if !scopes.is_empty() {
20750        let root_db = root.join(".tsift/index.db");
20751        if !root_db.exists() {
20752            let available_scopes = scopes
20753                .iter()
20754                .map(|scope| scope.id.as_str())
20755                .collect::<Vec<_>>()
20756                .join(", ");
20757            let cfg = config::Config::load(root)?;
20758            let indexed_scopes = scopes
20759                .iter()
20760                .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
20761                .map(|scope| scope.id.as_str())
20762                .collect::<Vec<_>>();
20763            let indexed_label = if indexed_scopes.is_empty() {
20764                "none".to_string()
20765            } else {
20766                indexed_scopes.join(", ")
20767            };
20768            bail!(
20769                "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: {}.",
20770                root.display(),
20771                root_db.display(),
20772                available_scopes,
20773                indexed_label,
20774            );
20775        }
20776    }
20777
20778    Ok(vec![SearchIndexTarget {
20779        label: "index".to_string(),
20780        db_path: root.join(".tsift/index.db"),
20781        source_root: root.to_path_buf(),
20782        scope_name: None,
20783        reindex_cmd: format!("tsift index {}", root.display()),
20784    }])
20785}
20786
20787fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
20788    if !target.source_root.exists() || !target.db_path.exists() {
20789        return Ok(SearchIndexState::Missing);
20790    }
20791
20792    let inspection =
20793        index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
20794    let stale_files =
20795        inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
20796    if stale_files == 0 {
20797        Ok(SearchIndexState::Fresh)
20798    } else {
20799        Ok(SearchIndexState::Stale { stale_files })
20800    }
20801}
20802
20803#[derive(Debug, Clone, PartialEq, Eq)]
20804struct RebuildSearchTarget {
20805    label: String,
20806    reason: RebuildSearchReason,
20807    reindex_cmd: String,
20808}
20809
20810#[derive(Debug, Clone, PartialEq, Eq)]
20811enum RebuildSearchReason {
20812    Missing,
20813    Stale { stale_files: usize },
20814}
20815
20816#[derive(Debug, Clone, PartialEq, Eq)]
20817struct DegradedSearchTarget {
20818    label: String,
20819    reason: RebuildSearchReason,
20820    reindex_cmd: String,
20821}
20822
20823#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20824pub(crate) enum DegradedSearchMode {
20825    ReadOnly,
20826    Exact,
20827}
20828
20829#[derive(Debug)]
20830struct SearchPrecheck {
20831    targets: Vec<SearchIndexTarget>,
20832    degraded_targets: Vec<DegradedSearchTarget>,
20833}
20834
20835fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
20836    err.chain().any(|cause| {
20837        cause
20838            .to_string()
20839            .contains("another tsift index writer is already active")
20840    })
20841}
20842
20843fn infer_agent_doc_task_submodule(
20844    root: &Path,
20845    path_hint: &Path,
20846) -> Result<Option<config::WorkspaceScope>> {
20847    let hinted_path = if path_hint.is_absolute() {
20848        path_hint.to_path_buf()
20849    } else {
20850        root.join(path_hint)
20851    };
20852    let Ok(relative) = hinted_path.strip_prefix(root) else {
20853        return Ok(None);
20854    };
20855    let mut components = relative.components();
20856    let Some(std::path::Component::Normal(first)) = components.next() else {
20857        return Ok(None);
20858    };
20859    if first != "tasks" {
20860        return Ok(None);
20861    }
20862    let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
20863        return Ok(None);
20864    };
20865    config::Config::find_submodule(root, file_stem)
20866}
20867
20868fn degraded_search_target(
20869    target: &SearchIndexTarget,
20870    reason: RebuildSearchReason,
20871) -> DegradedSearchTarget {
20872    DegradedSearchTarget {
20873        label: target.label.clone(),
20874        reason,
20875        reindex_cmd: target.reindex_cmd.clone(),
20876    }
20877}
20878
20879fn apply_search_index_update(
20880    root: &Path,
20881    target: &SearchIndexTarget,
20882) -> Result<index::IndexSummary> {
20883    run_index_update(
20884        &target.db_path,
20885        &target.source_root,
20886        format!("autoindexing {}", target.label),
20887        root,
20888        target.scope_name.as_deref(),
20889        false,
20890        false,
20891    )
20892}
20893
20894fn collect_rebuild_search_targets(
20895    targets: &[SearchIndexTarget],
20896) -> Result<Vec<RebuildSearchTarget>> {
20897    let mut rebuild_targets = Vec::new();
20898    for target in targets {
20899        let reason = match inspect_search_index(target)? {
20900            SearchIndexState::Missing => RebuildSearchReason::Missing,
20901            SearchIndexState::Fresh => continue,
20902            SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
20903        };
20904        rebuild_targets.push(RebuildSearchTarget {
20905            label: target.label.clone(),
20906            reason,
20907            reindex_cmd: target.reindex_cmd.clone(),
20908        });
20909    }
20910    Ok(rebuild_targets)
20911}
20912
20913fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
20914    match target.reason {
20915        RebuildSearchReason::Missing => format!("{} is missing", target.label),
20916        RebuildSearchReason::Stale { stale_files } => {
20917            let file_suffix = if stale_files == 1 { "" } else { "s" };
20918            format!(
20919                "{} is stale ({} file{})",
20920                target.label, stale_files, file_suffix
20921            )
20922        }
20923    }
20924}
20925
20926fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
20927    if rebuild_targets.len() == 1 {
20928        let target = &rebuild_targets[0];
20929        return format!(
20930            "{}. Run `{}` to rebuild before retrying.",
20931            rebuild_search_target_detail(target),
20932            target.reindex_cmd
20933        );
20934    }
20935
20936    let summary: Vec<String> = rebuild_targets
20937        .iter()
20938        .take(3)
20939        .map(rebuild_search_target_detail)
20940        .collect();
20941    let overflow = rebuild_targets.len().saturating_sub(summary.len());
20942    let mut details = summary.join(", ");
20943    if overflow > 0 {
20944        details.push_str(&format!(", +{} more", overflow));
20945    }
20946    let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
20947    format!(
20948        "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
20949        rebuild_targets.len(),
20950        details,
20951        reindex_cmd
20952    )
20953}
20954
20955pub(crate) fn precheck_search_indexes(
20956    root: &Path,
20957    path_hint: &Path,
20958    scope: Option<&str>,
20959    federated: bool,
20960    autoindex: bool,
20961) -> Result<SearchPrecheck> {
20962    let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
20963    let mut stale_targets = Vec::new();
20964    let mut degraded_targets = Vec::new();
20965
20966    for target in &targets {
20967        match inspect_search_index(target)? {
20968            SearchIndexState::Missing => {
20969                if autoindex && let Err(err) = apply_search_index_update(root, target) {
20970                    if is_active_writer_lock_error(&err) {
20971                        degraded_targets
20972                            .push(degraded_search_target(target, RebuildSearchReason::Missing));
20973                    } else {
20974                        return Err(err);
20975                    }
20976                }
20977            }
20978            SearchIndexState::Fresh => {}
20979            SearchIndexState::Stale { stale_files } => {
20980                if autoindex {
20981                    if let Err(err) = apply_search_index_update(root, target) {
20982                        if is_active_writer_lock_error(&err) {
20983                            degraded_targets.push(degraded_search_target(
20984                                target,
20985                                RebuildSearchReason::Stale { stale_files },
20986                            ));
20987                        } else {
20988                            return Err(err);
20989                        }
20990                    }
20991                } else {
20992                    stale_targets.push(RebuildSearchTarget {
20993                        label: target.label.clone(),
20994                        reason: RebuildSearchReason::Stale { stale_files },
20995                        reindex_cmd: target.reindex_cmd.clone(),
20996                    });
20997                }
20998            }
20999        }
21000    }
21001
21002    if stale_targets.is_empty() {
21003        return Ok(SearchPrecheck {
21004            targets,
21005            degraded_targets,
21006        });
21007    }
21008
21009    bail!(
21010        "tsift search aborted: {} \
21011         or re-run without `--no-autoindex`.",
21012        rebuild_search_targets_message(&stale_targets),
21013    );
21014}
21015
21016pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
21017    if targets.is_empty() {
21018        return None;
21019    }
21020
21021    if targets
21022        .iter()
21023        .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
21024    {
21025        Some(DegradedSearchMode::Exact)
21026    } else {
21027        Some(DegradedSearchMode::ReadOnly)
21028    }
21029}
21030
21031fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
21032    if targets.len() == 1 {
21033        let target = &targets[0];
21034        return match target.reason {
21035            RebuildSearchReason::Missing => format!("{} is missing", target.label),
21036            RebuildSearchReason::Stale { stale_files } => {
21037                let file_suffix = if stale_files == 1 { "" } else { "s" };
21038                format!(
21039                    "{} is stale ({} file{})",
21040                    target.label, stale_files, file_suffix
21041                )
21042            }
21043        };
21044    }
21045
21046    let missing = targets
21047        .iter()
21048        .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
21049        .count();
21050    let stale = targets.len().saturating_sub(missing);
21051    let mut parts = Vec::new();
21052    if stale > 0 {
21053        let suffix = if stale == 1 { "" } else { "es" };
21054        parts.push(format!("{stale} stale index{suffix}"));
21055    }
21056    if missing > 0 {
21057        let suffix = if missing == 1 { "" } else { "es" };
21058        parts.push(format!("{missing} missing index{suffix}"));
21059    }
21060    parts.join(", ")
21061}
21062
21063pub(crate) fn emit_degraded_search_note(
21064    targets: &[DegradedSearchTarget],
21065    mode: DegradedSearchMode,
21066) {
21067    let summary = degraded_search_targets_summary(targets);
21068    let reindex_cmd = &targets[0].reindex_cmd;
21069    match mode {
21070        DegradedSearchMode::ReadOnly => eprintln!(
21071            "note: active tsift writer detected; skipping autoindex because {}. \
21072             Continuing with read-only search and the current index snapshot; symbol hits may lag. \
21073             Retry `{}` after the active writer finishes for fresh index results.",
21074            summary, reindex_cmd
21075        ),
21076        DegradedSearchMode::Exact => eprintln!(
21077            "note: active tsift writer detected; skipping autoindex because {}. \
21078             Continuing with exact live-file search. Retry `{}` after the active writer finishes \
21079             for indexed symbol hits.",
21080            summary, reindex_cmd
21081        ),
21082    }
21083}
21084
21085fn search_timeout_message(
21086    timeout_secs: u64,
21087    strategy: &str,
21088    targets: &[SearchIndexTarget],
21089) -> Result<String> {
21090    let rebuild_targets = collect_rebuild_search_targets(targets)?;
21091    if rebuild_targets.is_empty() {
21092        return Ok(format!(
21093            "tsift search timed out after {}s (strategy: {}). \
21094             The search root looks fresh, so reindexing is unlikely to help. \
21095             Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
21096             or try a different strategy.",
21097            timeout_secs, strategy,
21098        ));
21099    }
21100
21101    Ok(format!(
21102        "tsift search timed out after {}s (strategy: {}). {}",
21103        timeout_secs,
21104        strategy,
21105        rebuild_search_targets_message(&rebuild_targets),
21106    ))
21107}
21108
21109fn is_exact_preferring_query_char(ch: char) -> bool {
21110    matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
21111}
21112
21113fn query_prefers_exact_search(query: &str) -> bool {
21114    let trimmed = query.trim();
21115    !trimmed.is_empty()
21116        && !trimmed.chars().any(char::is_whitespace)
21117        && trimmed.chars().any(|ch| ch.is_alphanumeric())
21118        && trimmed.chars().any(is_exact_preferring_query_char)
21119        && trimmed
21120            .chars()
21121            .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
21122}
21123
21124pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
21125    strategy.unwrap_or_else(|| {
21126        if query_prefers_exact_search(query) {
21127            "exact".to_string()
21128        } else {
21129            "lexical".to_string()
21130        }
21131    })
21132}
21133
21134
21135pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21136    let mut files = Vec::new();
21137    if path.is_file() {
21138        files.push(path.to_path_buf());
21139        return Ok(files);
21140    }
21141    let walker = ignore::WalkBuilder::new(path)
21142        .hidden(true)
21143        .git_ignore(true)
21144        .build();
21145    for entry in walker {
21146        let entry = entry?;
21147        if entry.file_type().is_some_and(|ft| ft.is_file()) {
21148            let p = entry.path();
21149            if let Some(ext) = p.extension() {
21150                let ext = ext.to_string_lossy();
21151                if matches!(
21152                    ext.as_ref(),
21153                    "rs" | "py"
21154                        | "ts"
21155                        | "tsx"
21156                        | "js"
21157                        | "jsx"
21158                        | "kt"
21159                        | "kts"
21160                        | "zig"
21161                        | "sh"
21162                        | "bash"
21163                        | "zsh"
21164                ) {
21165                    files.push(p.to_path_buf());
21166                }
21167            }
21168        }
21169    }
21170    Ok(files)
21171}
21172
21173#[cfg(test)]
21174 mod tests {
21175     use super::*;
21176     use super::semantic_edit::{
21177         EditOp,
21178         apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21179         markdown_section_spans,
21180     };
21181     use tsift_memory::{MemoryEventKind, MemoryStore};
21182
21183    use std::cell::RefCell;
21184    use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21185    fn parse_cli<I, T>(itr: I) -> Cli
21186    where
21187        I: IntoIterator<Item = T> + Send + 'static,
21188        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21189    {
21190        std::thread::Builder::new()
21191            .name("cli-parse".to_string())
21192            .stack_size(16 * 1024 * 1024)
21193            .spawn(move || Cli::parse_from(itr))
21194            .unwrap()
21195            .join()
21196            .unwrap()
21197    }
21198
21199    fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21200    where
21201        I: IntoIterator<Item = T> + Send + 'static,
21202        T: Into<std::ffi::OsString> + Clone + Send + 'static,
21203    {
21204        std::thread::Builder::new()
21205            .name("cli-try-parse".to_string())
21206            .stack_size(16 * 1024 * 1024)
21207            .spawn(move || Cli::try_parse_from(itr))
21208            .unwrap()
21209            .join()
21210            .unwrap()
21211    }
21212
21213    fn build_relative_search_budget_report(
21214        query: &str,
21215        strategy: &str,
21216        root: &Path,
21217        response: &sift::SearchResponse,
21218        symbol_hits: &[index::SymbolHit],
21219        budget: ResponseBudget,
21220        filters: &SearchFacetFilters,
21221    ) -> SearchBudgetReport {
21222        build_search_budget_report(SearchBudgetReportInput {
21223            query,
21224            strategy,
21225            root,
21226            response,
21227            symbol_hits,
21228            absolute: false,
21229            budget,
21230            filters,
21231        })
21232    }
21233
21234    #[derive(Default)]
21235    struct MemoryConvexGraphClient {
21236        nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
21237        edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
21238    }
21239
21240    impl ConvexGraphClient for MemoryConvexGraphClient {
21241        fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
21242            self.nodes
21243                .borrow_mut()
21244                .insert(row.external_id.clone(), row.clone());
21245            Ok(())
21246        }
21247
21248        fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
21249            self.edges
21250                .borrow_mut()
21251                .insert(row.edge_key.clone(), row.clone());
21252            Ok(())
21253        }
21254
21255        fn delete_node_row(&self, external_id: &str) -> Result<usize> {
21256            Ok(usize::from(
21257                self.nodes.borrow_mut().remove(external_id).is_some(),
21258            ))
21259        }
21260
21261        fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
21262            Ok(usize::from(
21263                self.edges.borrow_mut().remove(edge_key).is_some(),
21264            ))
21265        }
21266
21267        fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
21268            Ok(self.nodes.borrow().get(external_id).cloned())
21269        }
21270
21271        fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
21272            Ok(self.nodes.borrow().values().cloned().collect())
21273        }
21274
21275        fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
21276            Ok(self.edges.borrow().values().cloned().collect())
21277        }
21278
21279        fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
21280            Ok(self
21281                .nodes
21282                .borrow()
21283                .values()
21284                .filter(|row| row.kind == kind)
21285                .cloned()
21286                .collect())
21287        }
21288
21289        fn outgoing_edge_rows(
21290            &self,
21291            from_external_id: &str,
21292            kind: Option<&str>,
21293        ) -> Result<Vec<ConvexEdgeRow>> {
21294            Ok(self
21295                .edges
21296                .borrow()
21297                .values()
21298                .filter(|row| row.from_external_id == from_external_id)
21299                .filter(|row| kind.is_none_or(|kind| row.kind == kind))
21300                .cloned()
21301                .collect())
21302        }
21303    }
21304
21305    fn init_git_repo(path: &Path) {
21306        let status = std::process::Command::new("git")
21307            .args(["init"])
21308            .current_dir(path)
21309            .status()
21310            .unwrap();
21311        assert!(status.success(), "git init failed");
21312
21313        let status = std::process::Command::new("git")
21314            .args(["add", "."])
21315            .current_dir(path)
21316            .status()
21317            .unwrap();
21318        assert!(status.success(), "git add failed");
21319
21320        let status = std::process::Command::new("git")
21321            .args([
21322                "-c",
21323                "user.name=tsift-tests",
21324                "-c",
21325                "user.email=tsift-tests@example.com",
21326                "commit",
21327                "--quiet",
21328                "-m",
21329                "init",
21330            ])
21331            .current_dir(path)
21332            .status()
21333            .unwrap();
21334        assert!(status.success(), "git commit failed");
21335    }
21336
21337    fn write_empty_root_index(root: &Path) {
21338        let index_dir = root.join(".tsift");
21339        fs::create_dir_all(&index_dir).unwrap();
21340        fs::write(index_dir.join("index.db"), "").unwrap();
21341    }
21342
21343    fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
21344        if let Some(parent) = path.parent() {
21345            fs::create_dir_all(parent).unwrap();
21346        }
21347        let body = std::iter::repeat_n(line, lines)
21348            .collect::<Vec<_>>()
21349            .join("\n");
21350        fs::write(path, format!("{body}\n")).unwrap();
21351        path.to_path_buf()
21352    }
21353
21354    // --- build_token_capped_preview ---
21355
21356    #[test]
21357    fn token_capped_preview_returns_all_lines_when_under_cap() {
21358        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 1", "}"];
21359        let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
21360        assert!(!result.was_capped);
21361        assert_eq!(result.preview.len(), 3);
21362        assert_eq!(result.capped_end, 3);
21363    }
21364
21365    #[test]
21366    fn token_capped_preview_truncates_when_over_cap() {
21367        let lines: Vec<&str> = (0..200).map(|_| "    let x = some_very_long_expression_here();").collect();
21368        let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
21369        assert!(result.was_capped);
21370        assert!(result.preview.len() < 200);
21371        assert!(result.capped_end < 200);
21372    }
21373
21374    #[test]
21375    fn token_capped_preview_keeps_at_least_one_line() {
21376        let long_line: String = "x".repeat(8000);
21377        let lines: Vec<&str> = vec![&long_line];
21378        let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
21379        assert!(!result.was_capped);
21380        assert_eq!(result.preview.len(), 1);
21381    }
21382
21383    #[test]
21384    fn token_capped_preview_cap_at_boundary() {
21385        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21386        let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
21387        assert!(!result.was_capped);
21388        assert_eq!(result.preview.len(), 4);
21389    }
21390
21391    #[test]
21392    fn token_capped_preview_cap_just_over_boundary() {
21393        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21394        let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
21395        assert!(result.was_capped);
21396        assert_eq!(result.preview.len(), 3);
21397        assert_eq!(result.capped_end, 3);
21398    }
21399
21400    #[test]
21401    fn token_capped_preview_empty_lines() {
21402        let lines: Vec<&str> = vec![];
21403        let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
21404        assert!(!result.was_capped);
21405        assert!(result.preview.is_empty());
21406    }
21407
21408    #[test]
21409    fn token_capped_preview_per_line_truncation_applied() {
21410        let long_line = "x".repeat(500);
21411        let lines: Vec<&str> = vec![&long_line, "short"];
21412        let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
21413        assert!(!result.was_capped);
21414        assert_eq!(result.preview.len(), 2);
21415        assert!(result.preview[0].text.len() <= 23);
21416        assert!(result.preview[0].text.ends_with("..."));
21417    }
21418
21419    // --- classify_task ---
21420
21421    #[test]
21422    fn route_search_defaults_to_haiku() {
21423        let (tier, model) = classify_task("find all uses of authenticate");
21424        assert_eq!(tier, "haiku");
21425        assert!(
21426            model.contains("haiku"),
21427            "expected haiku model, got {}",
21428            model
21429        );
21430    }
21431
21432    #[test]
21433    fn route_edit_keywords_to_sonnet() {
21434        for kw in &[
21435            "edit the file",
21436            "fix the bug",
21437            "update the config",
21438            "remove dead code",
21439            "create a new module",
21440        ] {
21441            let (tier, _) = classify_task(kw);
21442            assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
21443        }
21444    }
21445
21446    #[test]
21447    fn route_architecture_keywords_to_opus() {
21448        for kw in &[
21449            "design the API",
21450            "architecture review",
21451            "plan the migration",
21452            "analyze the system",
21453            "evaluate trade-offs",
21454        ] {
21455            let (tier, _) = classify_task(kw);
21456            assert_eq!(tier, "opus", "expected opus for {:?}", kw);
21457        }
21458    }
21459
21460    #[test]
21461    fn route_architecture_beats_edit() {
21462        // "design and implement" — architecture signal wins (checked first)
21463        let (tier, _) = classify_task("design and implement the new auth service");
21464        assert_eq!(tier, "opus");
21465    }
21466
21467    #[test]
21468    fn cli_accepts_global_compact_flag() {
21469        let cli = parse_cli(["tsift", "--compact", "status"]);
21470        assert!(cli.compact);
21471        assert!(matches!(cli.command, Some(Commands::Status { .. })));
21472    }
21473
21474    #[test]
21475    fn summarize_diff_scope_matches_relative_directory() {
21476        let root = Path::new("/repo");
21477        let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
21478
21479        assert!(summarize_diff_matches_scope(
21480            Path::new("/repo/src/feature/main.rs"),
21481            &extract_scope
21482        ));
21483        assert!(!summarize_diff_matches_scope(
21484            Path::new("/repo/src/other/main.rs"),
21485            &extract_scope
21486        ));
21487    }
21488
21489    #[test]
21490    fn summarize_diff_scope_matches_relative_file() {
21491        let root = Path::new("/repo");
21492        let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
21493
21494        assert!(summarize_diff_matches_scope(
21495            Path::new("/repo/src/feature/main.rs"),
21496            &extract_scope
21497        ));
21498        assert!(!summarize_diff_matches_scope(
21499            Path::new("/repo/src/feature/lib.rs"),
21500            &extract_scope
21501        ));
21502    }
21503
21504    #[test]
21505    fn summarize_extract_scope_walks_relative_paths_from_root() {
21506        let dir = tempfile::tempdir().unwrap();
21507        let source_dir = dir.path().join("src");
21508        std::fs::create_dir_all(&source_dir).unwrap();
21509        let main_rs = source_dir.join("main.rs");
21510        std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
21511
21512        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
21513        let files = collect_source_files(&extract_scope).unwrap();
21514
21515        assert_eq!(files, vec![main_rs]);
21516    }
21517
21518    #[test]
21519    fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
21520        let dir = tempfile::tempdir().unwrap();
21521        let nested = dir.path().join("src/nested");
21522        std::fs::create_dir_all(&nested).unwrap();
21523        std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
21524        let nested_file = nested.join("main.rs");
21525        std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
21526
21527        let extract_base = resolve_extract_base(&nested).unwrap();
21528        let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
21529        let files = collect_source_files(&extract_scope).unwrap();
21530
21531        assert_eq!(extract_scope, nested);
21532        assert_eq!(files, vec![nested_file]);
21533    }
21534
21535    #[test]
21536    fn summarize_extract_base_uses_parent_of_file_path() {
21537        let dir = tempfile::tempdir().unwrap();
21538        let nested = dir.path().join("src/nested");
21539        std::fs::create_dir_all(&nested).unwrap();
21540        let file_path = nested.join("main.rs");
21541        std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
21542
21543        let extract_base = resolve_extract_base(&file_path).unwrap();
21544
21545        assert_eq!(extract_base, nested);
21546    }
21547
21548    #[test]
21549    fn summarize_extract_scope_normalizes_dotdot_segments() {
21550        let dir = tempfile::tempdir().unwrap();
21551        let source_dir = dir.path().join("src");
21552        std::fs::create_dir_all(&source_dir).unwrap();
21553
21554        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
21555
21556        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21557        assert!(summarize_diff_matches_scope(
21558            &source_dir.join("main.rs"),
21559            &extract_scope
21560        ));
21561    }
21562
21563    #[cfg(unix)]
21564    #[test]
21565    fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
21566        use std::os::unix::fs::symlink;
21567
21568        let dir = tempfile::tempdir().unwrap();
21569        let real_root = dir.path().join("real");
21570        let source_dir = real_root.join("src");
21571        std::fs::create_dir_all(&source_dir).unwrap();
21572        let symlink_scope = dir.path().join("scope-link");
21573        symlink(&source_dir, &symlink_scope).unwrap();
21574
21575        let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
21576
21577        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21578        assert!(summarize_diff_matches_scope(
21579            &source_dir.join("lib.rs"),
21580            &extract_scope
21581        ));
21582    }
21583
21584    #[test]
21585    fn summarize_diff_extract_includes_untracked_files() {
21586        let dir = tempfile::tempdir().unwrap();
21587        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21588        init_git_repo(dir.path());
21589
21590        let source_dir = dir.path().join("src");
21591        std::fs::create_dir_all(&source_dir).unwrap();
21592        let new_file = source_dir.join("new.rs");
21593        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21594
21595        let files = summarize::git_changed_files(dir.path()).unwrap();
21596
21597        assert_eq!(files.existing, vec![new_file]);
21598        assert!(files.deleted.is_empty());
21599    }
21600
21601    #[test]
21602    fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
21603        let dir = tempfile::tempdir().unwrap();
21604        let status = std::process::Command::new("git")
21605            .args(["init"])
21606            .current_dir(dir.path())
21607            .status()
21608            .unwrap();
21609        assert!(status.success(), "git init failed");
21610
21611        let source_dir = dir.path().join("src");
21612        std::fs::create_dir_all(&source_dir).unwrap();
21613        let new_file = source_dir.join("new.rs");
21614        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21615
21616        let files = summarize::git_changed_files(dir.path()).unwrap();
21617
21618        assert_eq!(files.existing, vec![new_file]);
21619        assert!(files.deleted.is_empty());
21620    }
21621
21622    #[test]
21623    fn summarize_diff_extract_tracks_deleted_files() {
21624        let dir = tempfile::tempdir().unwrap();
21625        let source_dir = dir.path().join("src");
21626        std::fs::create_dir_all(&source_dir).unwrap();
21627        let deleted_file = source_dir.join("gone.rs");
21628        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21629        init_git_repo(dir.path());
21630
21631        std::fs::remove_file(&deleted_file).unwrap();
21632
21633        let files = summarize::git_changed_files(dir.path()).unwrap();
21634
21635        assert!(files.existing.is_empty());
21636        assert_eq!(files.deleted, vec![deleted_file]);
21637    }
21638
21639    #[test]
21640    fn summarize_diff_extract_tracks_git_renames() {
21641        let dir = tempfile::tempdir().unwrap();
21642        let source_dir = dir.path().join("src");
21643        std::fs::create_dir_all(&source_dir).unwrap();
21644        let old_file = source_dir.join("old.rs");
21645        let new_file = source_dir.join("new.rs");
21646        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21647        init_git_repo(dir.path());
21648
21649        let status = std::process::Command::new("git")
21650            .args(["mv", "src/old.rs", "src/new.rs"])
21651            .current_dir(dir.path())
21652            .status()
21653            .unwrap();
21654        assert!(status.success(), "git mv failed");
21655
21656        let files = summarize::git_changed_files(dir.path()).unwrap();
21657
21658        assert_eq!(files.existing, vec![new_file]);
21659        assert_eq!(files.deleted, vec![old_file]);
21660    }
21661
21662    #[test]
21663    fn summarize_diff_extract_deletes_removed_summary_rows() {
21664        let dir = tempfile::tempdir().unwrap();
21665        let source_dir = dir.path().join("src");
21666        std::fs::create_dir_all(&source_dir).unwrap();
21667        let deleted_file = source_dir.join("gone.rs");
21668        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21669        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21670        init_git_repo(dir.path());
21671
21672        let summary_db =
21673            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21674        summary_db
21675            .insert(&summarize::Summary {
21676                id: 0,
21677                symbol_name: "stale".to_string(),
21678                file_path: "src/gone.rs".to_string(),
21679                content_hash: "hash1".to_string(),
21680                summary: "stale summary".to_string(),
21681                entities: None,
21682                relationships: None,
21683                concept_labels: None,
21684                extracted_at: "1700000000".to_string(),
21685                model: "test".to_string(),
21686                tokens_input: Some(100),
21687                tokens_output: Some(50),
21688            })
21689            .unwrap();
21690
21691        std::fs::remove_file(&deleted_file).unwrap();
21692
21693        cmd_summarize(
21694            None,
21695            None,
21696            Some(PathBuf::from("src")),
21697            true,
21698            false,
21699            dir.path(),
21700            false,
21701            true,
21702            false,
21703            false,
21704            false,
21705        )
21706        .unwrap();
21707
21708        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21709    }
21710
21711    #[test]
21712    fn summarize_diff_extract_deletes_renamed_summary_rows() {
21713        let dir = tempfile::tempdir().unwrap();
21714        let source_dir = dir.path().join("src");
21715        std::fs::create_dir_all(&source_dir).unwrap();
21716        let old_file = source_dir.join("old.rs");
21717        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21718        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21719        init_git_repo(dir.path());
21720
21721        let summary_db =
21722            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21723        summary_db
21724            .insert(&summarize::Summary {
21725                id: 0,
21726                symbol_name: "stale".to_string(),
21727                file_path: "src/old.rs".to_string(),
21728                content_hash: "hash1".to_string(),
21729                summary: "stale summary".to_string(),
21730                entities: None,
21731                relationships: None,
21732                concept_labels: None,
21733                extracted_at: "1700000000".to_string(),
21734                model: "test".to_string(),
21735                tokens_input: Some(100),
21736                tokens_output: Some(50),
21737            })
21738            .unwrap();
21739
21740        let status = std::process::Command::new("git")
21741            .args(["mv", "src/old.rs", "src/new.rs"])
21742            .current_dir(dir.path())
21743            .status()
21744            .unwrap();
21745        assert!(status.success(), "git mv failed");
21746
21747        cmd_summarize(
21748            None,
21749            None,
21750            Some(PathBuf::from("src")),
21751            true,
21752            false,
21753            dir.path(),
21754            false,
21755            true,
21756            false,
21757            false,
21758            false,
21759        )
21760        .unwrap();
21761
21762        assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
21763    }
21764
21765    #[test]
21766    fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
21767        let dir = tempfile::tempdir().unwrap();
21768        let source_dir = dir.path().join("src");
21769        std::fs::create_dir_all(&source_dir).unwrap();
21770        let deleted_file = source_dir.join("gone.rs");
21771        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21772
21773        let summary_db =
21774            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21775        summary_db
21776            .insert(&summarize::Summary {
21777                id: 0,
21778                symbol_name: "stale".to_string(),
21779                file_path: "src/gone.rs".to_string(),
21780                content_hash: "hash1".to_string(),
21781                summary: "stale summary".to_string(),
21782                entities: None,
21783                relationships: None,
21784                concept_labels: None,
21785                extracted_at: "1700000000".to_string(),
21786                model: "test".to_string(),
21787                tokens_input: Some(100),
21788                tokens_output: Some(50),
21789            })
21790            .unwrap();
21791
21792        std::fs::remove_file(&deleted_file).unwrap();
21793
21794        cmd_summarize(
21795            None,
21796            None,
21797            Some(PathBuf::from("src")),
21798            false,
21799            false,
21800            dir.path(),
21801            false,
21802            true,
21803            false,
21804            false,
21805            false,
21806        )
21807        .unwrap();
21808
21809        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21810    }
21811
21812    #[test]
21813    fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
21814        let dir = tempfile::tempdir().unwrap();
21815        let source_dir = dir.path().join("src");
21816        std::fs::create_dir_all(&source_dir).unwrap();
21817        let file = source_dir.join("lib.rs");
21818        std::fs::write(&file, "fn helper() {}\n").unwrap();
21819
21820        let content = std::fs::read(&file).unwrap();
21821        let summary_db =
21822            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21823        summary_db
21824            .insert(&summarize::Summary {
21825                id: 0,
21826                symbol_name: "lib.rs".to_string(),
21827                file_path: "src/lib.rs".to_string(),
21828                content_hash: summarize::content_hash(&content),
21829                summary: "cached summary".to_string(),
21830                entities: None,
21831                relationships: None,
21832                concept_labels: None,
21833                extracted_at: "1700000000".to_string(),
21834                model: "test".to_string(),
21835                tokens_input: Some(100),
21836                tokens_output: Some(50),
21837            })
21838            .unwrap();
21839        drop(summary_db);
21840
21841        let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
21842        let _lock = hold_writer_lock(&lock_path);
21843
21844        let err = cmd_summarize(
21845            None,
21846            None,
21847            Some(PathBuf::from("src")),
21848            false,
21849            false,
21850            dir.path(),
21851            false,
21852            true,
21853            false,
21854            false,
21855            false,
21856        )
21857        .unwrap_err();
21858        let message = err.to_string();
21859
21860        assert!(message.contains("another tsift summarize extractor is already active"));
21861        assert!(message.contains("tsift summarize --extract"));
21862    }
21863
21864    #[test]
21865    fn summarize_stats_fails_closed_when_cache_missing() {
21866        let dir = tempfile::tempdir().unwrap();
21867        let err = cmd_summarize(
21868            None,
21869            None,
21870            None,
21871            false,
21872            true,
21873            dir.path(),
21874            false,
21875            false,
21876            false,
21877            false,
21878            false,
21879        )
21880        .unwrap_err();
21881
21882        assert!(
21883            err.to_string().contains("no summaries.db found"),
21884            "got: {err}"
21885        );
21886        assert!(!dir.path().join(".tsift/summaries.db").exists());
21887    }
21888
21889    #[test]
21890    fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21891        let dir = tempfile::tempdir().unwrap();
21892        let summary_db =
21893            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21894        summary_db
21895            .insert(&summarize::Summary {
21896                id: 0,
21897                symbol_name: "alpha_helper".to_string(),
21898                file_path: "src/lib.rs".to_string(),
21899                content_hash: "hash1".to_string(),
21900                summary: "cached summary".to_string(),
21901                entities: None,
21902                relationships: None,
21903                concept_labels: None,
21904                extracted_at: "1700000000".to_string(),
21905                model: "claude-haiku-4-5-20251001".to_string(),
21906                tokens_input: Some(100),
21907                tokens_output: Some(40),
21908            })
21909            .unwrap();
21910        drop(summary_db);
21911        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21912
21913        let result = cmd_summarize(
21914            None,
21915            None,
21916            None,
21917            false,
21918            true,
21919            dir.path(),
21920            false,
21921            false,
21922            false,
21923            false,
21924            false,
21925        );
21926
21927        assert!(result.is_ok());
21928    }
21929
21930    #[test]
21931    fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21932        let dir = tempfile::tempdir().unwrap();
21933        let summary_db =
21934            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21935        summary_db
21936            .insert(&summarize::Summary {
21937                id: 0,
21938                symbol_name: "alpha_helper".to_string(),
21939                file_path: "src/lib.rs".to_string(),
21940                content_hash: "hash1".to_string(),
21941                summary: "cached summary".to_string(),
21942                entities: None,
21943                relationships: None,
21944                concept_labels: None,
21945                extracted_at: "1700000000".to_string(),
21946                model: "claude-haiku-4-5-20251001".to_string(),
21947                tokens_input: Some(100),
21948                tokens_output: Some(40),
21949            })
21950            .unwrap();
21951        drop(summary_db);
21952        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21953
21954        let result = cmd_summarize(
21955            Some("alpha_helper".to_string()),
21956            None,
21957            None,
21958            false,
21959            false,
21960            dir.path(),
21961            false,
21962            true,
21963            false,
21964            false,
21965            false,
21966        );
21967
21968        assert!(result.is_ok());
21969    }
21970
21971    #[test]
21972    fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
21973        let dir = tempfile::tempdir().unwrap();
21974        let nested = dir.path().join("src/nested");
21975        std::fs::create_dir_all(&nested).unwrap();
21976
21977        let summary_db =
21978            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21979        summary_db
21980            .insert(&summarize::Summary {
21981                id: 0,
21982                symbol_name: "alpha_helper".to_string(),
21983                file_path: "src/lib.rs".to_string(),
21984                content_hash: "hash1".to_string(),
21985                summary: "cached summary".to_string(),
21986                entities: None,
21987                relationships: None,
21988                concept_labels: None,
21989                extracted_at: "1700000000".to_string(),
21990                model: "claude-haiku-4-5-20251001".to_string(),
21991                tokens_input: Some(100),
21992                tokens_output: Some(40),
21993            })
21994            .unwrap();
21995
21996        let result = cmd_summarize(
21997            Some("alpha_helper".to_string()),
21998            None,
21999            None,
22000            false,
22001            false,
22002            &nested,
22003            false,
22004            true,
22005            false,
22006            false,
22007            false,
22008        );
22009
22010        assert!(result.is_ok());
22011        assert!(!nested.join(".tsift/summaries.db").exists());
22012    }
22013
22014    #[test]
22015    fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
22016        let dir = tempfile::tempdir().unwrap();
22017        std::fs::write(
22018            dir.path().join(".gitmodules"),
22019            r#"[submodule "src/alpha"]
22020	path = src/alpha
22021	url = https://example.com/alpha
22022[submodule "src/beta"]
22023	path = src/beta
22024	url = https://example.com/beta
22025"#,
22026        )
22027        .unwrap();
22028
22029        let alpha_root = dir.path().join("src/alpha");
22030        let beta_root = dir.path().join("src/beta");
22031        std::fs::create_dir_all(alpha_root.join("src")).unwrap();
22032        std::fs::create_dir_all(beta_root.join("src")).unwrap();
22033        std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
22034        std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
22035        std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
22036        let beta_file = beta_root.join("src/lib.rs");
22037        std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
22038        std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
22039        std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
22040
22041        let context = find_symbols_db_for_file(dir.path(), &beta_file)
22042            .unwrap()
22043            .expect("expected matching scoped index");
22044
22045        assert_eq!(
22046            context.db_path,
22047            dir.path().join(".tsift/indexes/beta/index.db")
22048        );
22049        assert_eq!(context.source_root, beta_root);
22050    }
22051
22052    // --- apply_edit_op ---
22053
22054    fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
22055        EditOp {
22056            file: PathBuf::from("dummy.txt"),
22057            old: old.to_string(),
22058            new: new.to_string(),
22059            replace_all,
22060        }
22061    }
22062
22063    #[test]
22064    fn edit_replaces_single_occurrence() {
22065        let content = "hello world";
22066        let op = make_op("world", "rust", false);
22067        let (result, count) = apply_edit_op(content, &op).unwrap();
22068        assert_eq!(result, "hello rust");
22069        assert_eq!(count, 1);
22070    }
22071
22072    #[test]
22073    fn edit_replace_all_replaces_every_occurrence() {
22074        let content = "foo foo foo";
22075        let op = make_op("foo", "bar", true);
22076        let (result, count) = apply_edit_op(content, &op).unwrap();
22077        assert_eq!(result, "bar bar bar");
22078        assert_eq!(count, 3);
22079    }
22080
22081    #[test]
22082    fn edit_fails_when_old_not_found() {
22083        let content = "hello world";
22084        let op = make_op("missing", "x", false);
22085        assert!(apply_edit_op(content, &op).is_err());
22086    }
22087
22088    #[test]
22089    fn edit_fails_when_ambiguous_without_replace_all() {
22090        let content = "foo foo";
22091        let op = make_op("foo", "bar", false);
22092        let err = apply_edit_op(content, &op).unwrap_err();
22093        assert!(err.to_string().contains("2 times"), "got: {}", err);
22094    }
22095
22096    #[test]
22097    fn edit_fails_when_old_equals_new() {
22098        let content = "hello";
22099        let op = make_op("hello", "hello", false);
22100        assert!(apply_edit_op(content, &op).is_err());
22101    }
22102
22103    #[test]
22104    fn edit_batch_rolls_back_when_later_swap_fails() {
22105        let dir = tempfile::tempdir().unwrap();
22106        let alpha = dir.path().join("alpha.txt");
22107        let beta = dir.path().join("beta.txt");
22108        fs::write(&alpha, "alpha old\n").unwrap();
22109        fs::write(&beta, "beta old\n").unwrap();
22110
22111        let batch = EditBatch {
22112            edits: vec![
22113                EditOp {
22114                    file: alpha.clone(),
22115                    old: "old".to_string(),
22116                    new: "new".to_string(),
22117                    replace_all: false,
22118                },
22119                EditOp {
22120                    file: beta.clone(),
22121                    old: "old".to_string(),
22122                    new: "new".to_string(),
22123                    replace_all: false,
22124                },
22125            ],
22126        };
22127
22128        let plan = build_edit_plan(&batch).unwrap();
22129        let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
22130            if commit_index == 1 {
22131                bail!("simulated swap failure");
22132            }
22133            Ok(())
22134        }) {
22135            Ok(_) => panic!("expected simulated swap failure"),
22136            Err(err) => err,
22137        };
22138
22139        assert!(err.to_string().contains("simulated swap failure"));
22140        assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22141        assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22142    }
22143
22144    // --- SQL introspection ---
22145
22146    fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22147        let tmp = tempfile::NamedTempFile::new().unwrap();
22148        let conn = Connection::open(tmp.path()).unwrap();
22149        conn.execute_batch(
22150            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22151             INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22152             INSERT INTO users VALUES (2, 'Bob', NULL);
22153             CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22154                 FOREIGN KEY(user_id) REFERENCES users(id));
22155             INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22156             INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22157             INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22158        ).unwrap();
22159        (tmp, conn)
22160    }
22161
22162    // --- rewrite_command ---
22163
22164    #[test]
22165    fn rewrite_rg_simple_pattern() {
22166        let result = rewrite_command("rg authenticate");
22167        assert_eq!(
22168            result,
22169            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22170        );
22171    }
22172
22173    #[test]
22174    fn rewrite_rg_with_path() {
22175        let result = rewrite_command("rg authenticate src/");
22176        assert_eq!(
22177            result,
22178            Some(
22179                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22180                    .to_string()
22181            )
22182        );
22183    }
22184
22185    #[test]
22186    fn rewrite_rg_with_flags_ignored() {
22187        let result = rewrite_command("rg -i authenticate src/");
22188        assert_eq!(
22189            result,
22190            Some(
22191                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22192                    .to_string()
22193            )
22194        );
22195    }
22196
22197    #[test]
22198    fn rewrite_rg_with_type_flag() {
22199        // -t rs takes a value, should be skipped; pattern is next positional
22200        let result = rewrite_command("rg -t rs authenticate");
22201        assert_eq!(
22202            result,
22203            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
22204        );
22205    }
22206
22207    #[test]
22208    fn rewrite_rg_pipe_passthrough() {
22209        // Pipe chains can't be translated — pass through
22210        let result = rewrite_command("rg authenticate | head -5");
22211        assert_eq!(result, None);
22212    }
22213
22214    #[test]
22215    fn rewrite_rg_files_passthrough() {
22216        let result = rewrite_command("rg --files src/tsift .agent-doc logs");
22217        assert_eq!(result, None);
22218    }
22219
22220    #[test]
22221    fn rewrite_find_passthrough() {
22222        let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
22223        assert_eq!(result, None);
22224    }
22225
22226    #[test]
22227    fn rewrite_grep_recursive() {
22228        let result = rewrite_command("grep -r authenticate src/");
22229        assert_eq!(
22230            result,
22231            Some(
22232                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22233                    .to_string()
22234            )
22235        );
22236    }
22237
22238    #[test]
22239    fn rewrite_grep_non_recursive_passthrough() {
22240        let result = rewrite_command("grep authenticate file.txt");
22241        assert_eq!(result, None);
22242    }
22243
22244    #[test]
22245    fn rewrite_tsift_passthrough() {
22246        let result = rewrite_command("tsift search \"foo\"");
22247        assert_eq!(result, Some("tsift search \"foo\"".to_string()));
22248    }
22249
22250    #[test]
22251    fn rewrite_run_tsift_search_disables_timeout_by_default() {
22252        let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
22253        assert_eq!(
22254            result,
22255            "tsift search hookcaps --exact --path /tmp/x --timeout 0"
22256        );
22257    }
22258
22259    #[test]
22260    fn rewrite_run_preserves_explicit_search_timeout() {
22261        let result = effective_rewrite_run_command(
22262            "tsift search hookcaps --exact --path /tmp/x --timeout 5",
22263        );
22264        assert_eq!(
22265            result,
22266            "tsift search hookcaps --exact --path /tmp/x --timeout 5"
22267        );
22268    }
22269
22270    #[test]
22271    fn rewrite_unrelated_passthrough() {
22272        let result = rewrite_command("echo cargo build");
22273        assert_eq!(result, None);
22274    }
22275
22276    #[test]
22277    fn rewrite_rg_quoted_pattern() {
22278        let result = rewrite_command("rg \"fn main\"");
22279        assert_eq!(
22280            result,
22281            Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
22282        );
22283    }
22284
22285    #[test]
22286    fn rewrite_git_diff_to_diff_digest() {
22287        let result = rewrite_command("git diff");
22288        assert_eq!(result, Some("tsift diff-digest .".to_string()));
22289    }
22290
22291    #[test]
22292    fn rewrite_git_diff_cached_to_diff_digest() {
22293        let result = rewrite_command("git diff --cached");
22294        assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
22295    }
22296
22297    #[test]
22298    fn rewrite_git_diff_with_path_to_diff_digest() {
22299        let result = rewrite_command("git diff -- src/");
22300        assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
22301    }
22302
22303    #[test]
22304    fn rewrite_git_diff_with_revision_passthrough() {
22305        let result = rewrite_command("git diff HEAD~1");
22306        assert_eq!(result, None);
22307    }
22308
22309    #[test]
22310    fn rewrite_git_show_to_revision_diff_digest() {
22311        let result = rewrite_command("git show HEAD~1");
22312        assert_eq!(
22313            result,
22314            Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
22315        );
22316    }
22317
22318    #[test]
22319    fn rewrite_git_log_patch_history_to_revision_diff_digest() {
22320        let result = rewrite_command("git log -p -1 HEAD~2");
22321        assert_eq!(
22322            result,
22323            Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
22324        );
22325    }
22326
22327    #[test]
22328    fn rewrite_cat_long_agent_doc_session_to_session_digest() {
22329        let dir = tempfile::tempdir().unwrap();
22330        let session = dir.path().join("tsift.md");
22331        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22332        for index in 0..90 {
22333            body.push_str(&format!("❯ prompt {index}?\n"));
22334        }
22335        fs::write(&session, body).unwrap();
22336
22337        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22338        assert_eq!(
22339            result,
22340            Some(format!(
22341                "tsift session-digest --path {} --input {} --source markdown",
22342                shell_quote(&resolve_digest_context_path(&session)),
22343                shell_quote(session.to_str().unwrap())
22344            ))
22345        );
22346    }
22347
22348    #[test]
22349    fn rewrite_head_long_claude_jsonl_to_session_digest() {
22350        let dir = tempfile::tempdir().unwrap();
22351        let session = dir.path().join("session.jsonl");
22352        let line =
22353            r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
22354        let body = std::iter::repeat_n(line, 120)
22355            .collect::<Vec<_>>()
22356            .join("\n");
22357        fs::write(&session, format!("{body}\n")).unwrap();
22358
22359        let result = rewrite_command(&format!(
22360            "head -n 120 {}",
22361            shell_quote(session.to_str().unwrap())
22362        ));
22363        assert_eq!(
22364            result,
22365            Some(format!(
22366                "tsift session-digest --path {} --input {} --source claude-jsonl",
22367                shell_quote(&resolve_digest_context_path(&session)),
22368                shell_quote(session.to_str().unwrap())
22369            ))
22370        );
22371    }
22372
22373    #[test]
22374    fn rewrite_head_long_codex_jsonl_to_session_digest() {
22375        let dir = tempfile::tempdir().unwrap();
22376        let session = dir.path().join("codex.jsonl");
22377        let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
22378        let body = std::iter::repeat_n(line, 120)
22379            .collect::<Vec<_>>()
22380            .join("\n");
22381        fs::write(&session, format!("{body}\n")).unwrap();
22382
22383        let result = rewrite_command(&format!(
22384            "head -n 120 {}",
22385            shell_quote(session.to_str().unwrap())
22386        ));
22387        assert_eq!(
22388            result,
22389            Some(format!(
22390                "tsift session-digest --path {} --input {} --source codex-jsonl",
22391                shell_quote(&resolve_digest_context_path(&session)),
22392                shell_quote(session.to_str().unwrap())
22393            ))
22394        );
22395    }
22396
22397    #[test]
22398    fn rewrite_small_transcript_window_passthrough() {
22399        let dir = tempfile::tempdir().unwrap();
22400        let session = dir.path().join("session.jsonl");
22401        let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
22402        let body = std::iter::repeat_n(line, 120)
22403            .collect::<Vec<_>>()
22404            .join("\n");
22405        fs::write(&session, format!("{body}\n")).unwrap();
22406
22407        let result = rewrite_command(&format!(
22408            "tail -n 20 {}",
22409            shell_quote(session.to_str().unwrap())
22410        ));
22411        assert_eq!(result, None);
22412    }
22413
22414    #[test]
22415    fn rewrite_sed_large_agent_doc_range_to_session_digest() {
22416        let dir = tempfile::tempdir().unwrap();
22417        let session = dir.path().join("tsift.md");
22418        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22419        for index in 0..120 {
22420            body.push_str(&format!("### Re: topic {index}\n"));
22421        }
22422        fs::write(&session, body).unwrap();
22423
22424        let result = rewrite_command(&format!(
22425            "sed -n '1,120p' {}",
22426            shell_quote(session.to_str().unwrap())
22427        ));
22428        assert_eq!(
22429            result,
22430            Some(format!(
22431                "tsift session-digest --path {} --input {} --source markdown",
22432                shell_quote(&resolve_digest_context_path(&session)),
22433                shell_quote(session.to_str().unwrap())
22434            ))
22435        );
22436    }
22437
22438    #[test]
22439    fn rewrite_cat_large_agent_doc_log_to_session_digest() {
22440        let dir = tempfile::tempdir().unwrap();
22441        let session = dir.path().join("tsift.log");
22442        let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
22443        let body = std::iter::repeat_n(line, 120)
22444            .collect::<Vec<_>>()
22445            .join("\n");
22446        fs::write(&session, format!("{body}\n")).unwrap();
22447
22448        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22449        assert_eq!(
22450            result,
22451            Some(format!(
22452                "tsift session-digest --path {} --input {} --source agent-doc-log",
22453                shell_quote(&resolve_digest_context_path(&session)),
22454                shell_quote(session.to_str().unwrap())
22455            ))
22456        );
22457    }
22458
22459    #[test]
22460    fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
22461        let dir = tempfile::tempdir().unwrap();
22462        fs::write(
22463            dir.path().join(".gitmodules"),
22464            r#"[submodule "src/tsift"]
22465	path = src/tsift
22466	url = https://example.com/tsift
22467"#,
22468        )
22469        .unwrap();
22470        let submodule = dir.path().join("src/tsift");
22471        fs::create_dir_all(submodule.join("tasks")).unwrap();
22472        fs::write(
22473            submodule.join(".git"),
22474            "gitdir: ../../.git/modules/src/tsift\n",
22475        )
22476        .unwrap();
22477        let session = submodule.join("tasks/plan.md");
22478        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22479        for index in 0..90 {
22480            body.push_str(&format!("❯ prompt {index}?\n"));
22481        }
22482        fs::write(&session, body).unwrap();
22483
22484        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22485
22486        assert_eq!(
22487            result,
22488            Some(format!(
22489                "tsift session-digest --path {} --input {} --source markdown",
22490                shell_quote(submodule.to_str().unwrap()),
22491                shell_quote(session.to_str().unwrap())
22492            ))
22493        );
22494    }
22495
22496    #[test]
22497    fn rewrite_regular_markdown_read_passthrough() {
22498        let dir = tempfile::tempdir().unwrap();
22499        let readme = dir.path().join("README.md");
22500        let body = std::iter::repeat_n("plain markdown", 120)
22501            .collect::<Vec<_>>()
22502            .join("\n");
22503        fs::write(&readme, format!("{body}\n")).unwrap();
22504
22505        let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
22506        assert_eq!(result, None);
22507    }
22508
22509    #[test]
22510    fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
22511        let dir = tempfile::tempdir().unwrap();
22512        write_empty_root_index(dir.path());
22513        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22514
22515        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22516
22517        assert_eq!(
22518            result,
22519            Some(format!(
22520                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
22521                shell_quote(&dir.path().to_string_lossy())
22522            ))
22523        );
22524    }
22525
22526    #[test]
22527    fn rewrite_head_small_source_window_passthrough() {
22528        let dir = tempfile::tempdir().unwrap();
22529        write_empty_root_index(dir.path());
22530        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22531
22532        let result = rewrite_command(&format!(
22533            "head -n 20 {}",
22534            shell_quote(source.to_str().unwrap())
22535        ));
22536
22537        assert_eq!(result, None);
22538    }
22539
22540    #[test]
22541    fn rewrite_sed_large_source_range_to_source_read() {
22542        let dir = tempfile::tempdir().unwrap();
22543        write_empty_root_index(dir.path());
22544        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22545
22546        let result = rewrite_command(&format!(
22547            "sed -n '40,160p' {}",
22548            shell_quote(source.to_str().unwrap())
22549        ));
22550
22551        assert_eq!(
22552            result,
22553            Some(format!(
22554                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
22555                shell_quote(&dir.path().to_string_lossy())
22556            ))
22557        );
22558    }
22559
22560    #[test]
22561    fn rewrite_tail_large_source_window_preserves_tail_anchor() {
22562        let dir = tempfile::tempdir().unwrap();
22563        write_empty_root_index(dir.path());
22564        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22565
22566        let result = rewrite_command(&format!(
22567            "tail -n 120 {}",
22568            shell_quote(source.to_str().unwrap())
22569        ));
22570
22571        assert_eq!(
22572            result,
22573            Some(format!(
22574                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
22575                shell_quote(&dir.path().to_string_lossy())
22576            ))
22577        );
22578    }
22579
22580    #[test]
22581    fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
22582        let dir = tempfile::tempdir().unwrap();
22583        write_empty_root_index(dir.path());
22584        let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
22585
22586        let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
22587
22588        assert_eq!(result, None);
22589    }
22590
22591    #[test]
22592    fn rewrite_large_source_read_passthrough_without_index() {
22593        let dir = tempfile::tempdir().unwrap();
22594        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22595
22596        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22597
22598        assert_eq!(result, None);
22599    }
22600
22601    #[test]
22602    fn rewrite_cargo_test_to_digest_runner() {
22603        let result = rewrite_command("cargo test --lib");
22604        assert_eq!(
22605            result,
22606            Some(
22607                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
22608            )
22609        );
22610    }
22611
22612    #[test]
22613    fn rewrite_pytest_to_digest_runner() {
22614        let result = rewrite_command("pytest -q tests/test_cli.py");
22615        assert_eq!(
22616            result,
22617            Some(
22618                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
22619            )
22620        );
22621    }
22622
22623    #[test]
22624    fn rewrite_python_m_pytest_to_digest_runner() {
22625        let result = rewrite_command("python -m pytest tests/test_cli.py");
22626        assert_eq!(
22627            result,
22628            Some(
22629                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
22630            )
22631        );
22632    }
22633
22634    #[test]
22635    fn rewrite_cargo_build_to_log_digest_runner() {
22636        let result = rewrite_command("cargo build --release");
22637        assert_eq!(
22638            result,
22639            Some(
22640                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
22641            )
22642        );
22643    }
22644
22645    #[test]
22646    fn rewrite_cargo_install_to_log_digest_runner() {
22647        let result = rewrite_command("cargo install --path . --force");
22648        assert_eq!(
22649            result,
22650            Some(
22651                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
22652            )
22653        );
22654    }
22655
22656    #[test]
22657    fn rewrite_metacharacter_command_passthrough() {
22658        let result = rewrite_command("cargo test | head");
22659        assert_eq!(result, None);
22660    }
22661
22662    #[test]
22663    fn rewrite_output_cap_detects_search_even_with_global_flag() {
22664        let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
22665        assert_eq!(cap.max_lines, 50);
22666        assert_eq!(cap.strip_prefix, Some("Strategy:"));
22667    }
22668
22669    #[test]
22670    fn rewrite_output_cap_skips_structured_output() {
22671        assert!(rewrite_output_cap("tsift search foo --json").is_none());
22672        assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
22673        assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
22674    }
22675
22676    #[test]
22677    fn rewrite_output_format_forwards_envelope_to_digest_runner() {
22678        let command = rewrite_command("cargo test --lib").expect("rewrite");
22679        let forwarded = apply_rewrite_output_format(
22680            &command,
22681            OutputFormat {
22682                json_output: true,
22683                compact: false,
22684                pretty: false,
22685                terse: false,
22686                ultra_terse: false,
22687                schema: false,
22688                envelope: true,
22689            },
22690        );
22691        assert_eq!(
22692            forwarded,
22693            "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
22694        );
22695    }
22696
22697    #[test]
22698    fn rewrite_output_format_forwards_json_when_requested() {
22699        let command = rewrite_command("cargo build --release").expect("rewrite");
22700        let forwarded = apply_rewrite_output_format(
22701            &command,
22702            OutputFormat {
22703                json_output: true,
22704                compact: false,
22705                pretty: true,
22706                terse: false,
22707                ultra_terse: false,
22708                schema: false,
22709                envelope: false,
22710            },
22711        );
22712        assert_eq!(
22713            forwarded,
22714            "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
22715        );
22716    }
22717
22718    #[test]
22719    fn output_cap_strips_search_header_and_truncates() {
22720        let capped = apply_output_cap(
22721            b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
22722            OutputCap {
22723                max_lines: 2,
22724                strip_prefix: Some("Strategy:"),
22725            },
22726        );
22727        assert_eq!(
22728            capped,
22729            "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
22730        );
22731    }
22732
22733    #[test]
22734    fn sql_schema_overview_lists_tables() {
22735        let (_tmp, conn) = setup_test_db();
22736        let tables = schema_overview(&conn).unwrap();
22737        let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
22738        assert_eq!(names, &["posts", "users"]);
22739    }
22740
22741    #[test]
22742    fn sql_schema_overview_row_counts() {
22743        let (_tmp, conn) = setup_test_db();
22744        let tables = schema_overview(&conn).unwrap();
22745        let users = tables.iter().find(|t| t.name == "users").unwrap();
22746        let posts = tables.iter().find(|t| t.name == "posts").unwrap();
22747        assert_eq!(users.row_count, 2);
22748        assert_eq!(posts.row_count, 3);
22749    }
22750
22751    #[test]
22752    fn sql_table_columns_metadata() {
22753        let (_tmp, conn) = setup_test_db();
22754        let cols = table_columns(&conn, "users").unwrap();
22755        assert_eq!(cols.len(), 3);
22756        assert_eq!(cols[0].name, "id");
22757        assert!(cols[0].pk);
22758        assert_eq!(cols[1].name, "name");
22759        assert!(cols[1].notnull);
22760        assert_eq!(cols[2].name, "email");
22761        assert!(!cols[2].notnull);
22762    }
22763
22764    #[test]
22765    fn sql_execute_query_returns_rows() {
22766        let (_tmp, conn) = setup_test_db();
22767        let (columns, rows) =
22768            execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
22769        assert_eq!(columns, &["name", "email"]);
22770        assert_eq!(rows.len(), 2);
22771        assert_eq!(rows[0][0], serde_json::json!("Alice"));
22772        assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
22773        assert_eq!(rows[1][1], serde_json::Value::Null);
22774    }
22775
22776    #[test]
22777    fn sql_execute_query_aggregate() {
22778        let (_tmp, conn) = setup_test_db();
22779        let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
22780        assert_eq!(columns, &["cnt"]);
22781        assert_eq!(rows[0][0], serde_json::json!(3));
22782    }
22783
22784    #[test]
22785    fn sql_execute_query_join() {
22786        let (_tmp, conn) = setup_test_db();
22787        let (_cols, rows) = execute_query(
22788            &conn,
22789            "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
22790        )
22791        .unwrap();
22792        assert_eq!(rows.len(), 3);
22793        assert_eq!(rows[0][0], serde_json::json!("Alice"));
22794        assert_eq!(rows[2][0], serde_json::json!("Bob"));
22795    }
22796
22797    #[test]
22798    fn sql_open_db_read_only() {
22799        let (tmp, _conn) = setup_test_db();
22800        drop(_conn);
22801        let ro_conn = open_db(tmp.path()).unwrap();
22802        let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
22803        assert!(result.is_err(), "read-only connection should reject writes");
22804    }
22805
22806    #[test]
22807    fn sql_empty_table_schema() {
22808        let tmp = tempfile::NamedTempFile::new().unwrap();
22809        let conn = Connection::open(tmp.path()).unwrap();
22810        conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
22811            .unwrap();
22812        let tables = schema_overview(&conn).unwrap();
22813        assert_eq!(tables[0].row_count, 0);
22814        assert_eq!(tables[0].columns.len(), 2);
22815    }
22816
22817    // --- graph command ---
22818
22819    fn setup_graph_index() -> tempfile::TempDir {
22820        let dir = tempfile::tempdir().unwrap();
22821        std::fs::write(
22822            dir.path().join("main.rs"),
22823            "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
22824        )
22825        .unwrap();
22826        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22827        db.apply_changes(dir.path()).unwrap();
22828        dir
22829    }
22830
22831    fn setup_traversal_project() -> tempfile::TempDir {
22832        let dir = setup_graph_index();
22833        let task_dir = dir.path().join("tasks/software");
22834        std::fs::create_dir_all(&task_dir).unwrap();
22835        std::fs::write(
22836            task_dir.join("tsift.md"),
22837            r#"---
22838agent_doc_session: tsift-v0.1
22839agent_doc_format: template
22840---
22841
22842## Exchange
22843
22844<!-- agent:exchange patch=append -->
22845❯ do [#kgnv]
22846Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
22847<!-- /agent:exchange -->
22848
22849<!-- agent:queue -->
22850dispatch #spec-test-build-install-commit-push
22851- do [#kgnv]
22852<!-- /agent:queue -->
22853
22854## Backlog
22855
22856<!-- agent:backlog -->
22857- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
22858<!-- /agent:backlog -->
22859"#,
22860        )
22861        .unwrap();
22862        dir
22863    }
22864
22865    fn resolve_ast_span_node<'a>(
22866        graph: &'a TraversalGraphBuild,
22867        label: &str,
22868        symbol_kind: &str,
22869    ) -> &'a TraversalNode {
22870        graph
22871            .nodes
22872            .values()
22873            .find(|node| {
22874                node.kind == "ast_span"
22875                    && node.label == label
22876                    && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
22877            })
22878            .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
22879    }
22880
22881    fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
22882        let dir = tempfile::tempdir().unwrap();
22883        std::fs::write(
22884            dir.path().join("rust.rs"),
22885            r#"mod fixture_nav_rust_mod {
22886    pub fn fixture_nav_rust_helper() {}
22887    pub fn fixture_nav_rust_entry() {
22888        fixture_nav_rust_helper();
22889    }
22890}
22891"#,
22892        )
22893        .unwrap();
22894        std::fs::write(
22895            dir.path().join("python.py"),
22896            r#"def fixture_nav_python_helper():
22897    return 1
22898
22899def fixture_nav_python_entry():
22900    return fixture_nav_python_helper()
22901"#,
22902        )
22903        .unwrap();
22904        std::fs::write(
22905            dir.path().join("typescript.ts"),
22906            r#"export function fixture_nav_typescript_entry(): number {
22907    return fixtureNavTsHelper();
22908}
22909
22910function fixtureNavTsHelper(): number {
22911    return 1;
22912}
22913"#,
22914        )
22915        .unwrap();
22916        std::fs::write(
22917            dir.path().join("javascript.js"),
22918            r#"function fixture_nav_javascript_entry() {
22919    return fixtureNavJsHelper();
22920}
22921
22922function fixtureNavJsHelper() {
22923    return 1;
22924}
22925"#,
22926        )
22927        .unwrap();
22928        std::fs::write(
22929            dir.path().join("kotlin.kt"),
22930            r#"fun fixture_nav_kotlin_entry(): Int {
22931    return fixtureNavKotlinHelper()
22932}
22933
22934fun fixtureNavKotlinHelper(): Int = 1
22935"#,
22936        )
22937        .unwrap();
22938        std::fs::write(
22939            dir.path().join("zig.zig"),
22940            r#"pub fn fixture_nav_zig_entry() i32 {
22941    return fixtureNavZigHelper();
22942}
22943
22944fn fixtureNavZigHelper() i32 {
22945    return 1;
22946}
22947"#,
22948        )
22949        .unwrap();
22950        std::fs::write(
22951            dir.path().join("bash.sh"),
22952            r#"#!/usr/bin/env bash
22953fixture_nav_bash_entry() {
22954    fixture_nav_bash_helper
22955}
22956
22957fixture_nav_bash_helper() {
22958    echo ok
22959}
22960
22961alias fixture_nav_bash_alias='echo alias'
22962"#,
22963        )
22964        .unwrap();
22965        std::fs::write(
22966            dir.path().join("README.md"),
22967            r#"# Fixture Guide
22968
22969## Fixture Section
22970
22971- Fixture step
22972  - Nested fixture step
22973
22974```python
22975def fixture_nav_markdown_embedded():
22976    return 1
22977```
22978"#,
22979        )
22980        .unwrap();
22981
22982        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22983        db.apply_changes(dir.path()).unwrap();
22984        dir
22985    }
22986
22987    fn assert_cli_expand_command_parses(command: &str) {
22988        let args = shell_split(command)
22989            .into_iter()
22990            .map(str::to_string)
22991            .collect::<Vec<_>>();
22992        assert!(
22993            try_parse_cli(args).is_ok(),
22994            "expand command should parse as a tsift CLI command: {command}"
22995        );
22996    }
22997
22998    fn setup_multiplicity_project() -> tempfile::TempDir {
22999        let dir = tempfile::tempdir().unwrap();
23000        std::fs::write(
23001            dir.path().join("Cargo.toml"),
23002            r#"[workspace]
23003members = ["crates/core-lib", "crates/cli-app"]
23004"#,
23005        )
23006        .unwrap();
23007        std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
23008        std::fs::write(
23009            dir.path().join("crates/core-lib/Cargo.toml"),
23010            r#"[package]
23011name = "core-lib"
23012
23013[lib]
23014name = "core_lib"
23015
23016[features]
23017default = []
23018"#,
23019        )
23020        .unwrap();
23021        std::fs::write(
23022            dir.path().join("crates/core-lib/src/lib.rs"),
23023            "pub fn run() {}\n",
23024        )
23025        .unwrap();
23026        std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
23027        std::fs::write(
23028            dir.path().join("crates/cli-app/Cargo.toml"),
23029            r#"[package]
23030name = "cli-app"
23031
23032[[bin]]
23033name = "cli-app"
23034
23035[dependencies]
23036core-lib = { path = "../core-lib" }
23037"#,
23038        )
23039        .unwrap();
23040        std::fs::write(
23041            dir.path().join("crates/cli-app/src/main.rs"),
23042            "use core_lib::run;\nfn main() { run(); }\n",
23043        )
23044        .unwrap();
23045        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23046        db.apply_changes(dir.path()).unwrap();
23047
23048        let task_dir = dir.path().join("tasks/software");
23049        std::fs::create_dir_all(&task_dir).unwrap();
23050        std::fs::write(
23051            task_dir.join("tsift.md"),
23052            r#"---
23053agent_doc_session: tsift-multiplicity
23054agent_doc_format: template
23055---
23056
23057## Backlog
23058
23059<!-- agent:backlog -->
23060- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
23061<!-- /agent:backlog -->
23062"#,
23063        )
23064        .unwrap();
23065        init_git_repo(dir.path());
23066        dir
23067    }
23068
23069    fn setup_dependency_dag_project() -> tempfile::TempDir {
23070        let dir = tempfile::tempdir().unwrap();
23071        std::fs::write(
23072            dir.path().join("main.rs"),
23073            "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
23074        )
23075        .unwrap();
23076        std::fs::write(
23077            dir.path().join("Cargo.toml"),
23078            "[package]\nname = \"dag-fixture\"\n",
23079        )
23080        .unwrap();
23081        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23082        db.apply_changes(dir.path()).unwrap();
23083
23084        let task_dir = dir.path().join("tasks/software");
23085        std::fs::create_dir_all(&task_dir).unwrap();
23086        std::fs::write(
23087            task_dir.join("tsift.md"),
23088            r#"---
23089agent_doc_session: tsift-dag
23090agent_doc_format: template
23091---
23092
23093## Exchange
23094
23095<!-- agent:exchange patch=append -->
23096Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
23097<!-- /agent:exchange -->
23098
23099## Backlog
23100
23101<!-- agent:backlog -->
23102- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
23103- [ ] [#alpha] Update shared_helper in main.rs after #prep.
23104- [ ] [#beta] Refactor shared_helper tests in main.rs.
23105- [ ] [#gamma] Follow-up review for graph navigation.
23106<!-- /agent:backlog -->
23107"#,
23108        )
23109        .unwrap();
23110        dir
23111    }
23112
23113    fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
23114        let dir = setup_graph_index();
23115        let task_dir = dir.path().join("tasks/software");
23116        std::fs::create_dir_all(&task_dir).unwrap();
23117        std::fs::write(
23118            task_dir.join("tsift.md"),
23119            r#"---
23120agent_doc_session: tsift-dag-cycle
23121agent_doc_format: template
23122---
23123
23124## Backlog
23125
23126<!-- agent:backlog -->
23127- [ ] [#left] Left side depends on #right.
23128- [ ] [#right] Right side depends on #left.
23129<!-- /agent:backlog -->
23130"#,
23131        )
23132        .unwrap();
23133        dir
23134    }
23135
23136    fn seed_traversal_semantic_summaries(dir: &Path) {
23137        let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23138        summary_db
23139            .insert(&summarize::Summary {
23140                id: 0,
23141                symbol_name: "helper".to_string(),
23142                file_path: "main.rs".to_string(),
23143                content_hash: "hash-main".to_string(),
23144                summary: "helper builds graph navigation handles for traversal.".to_string(),
23145                entities: Some(vec![
23146                    summarize::Entity {
23147                        name: "helper".to_string(),
23148                        kind: "function".to_string(),
23149                        description: "Builds graph navigation handles.".to_string(),
23150                    },
23151                    summarize::Entity {
23152                        name: "TraversalGraph".to_string(),
23153                        kind: "type".to_string(),
23154                        description: "Carries GraphStore-backed traversal rows.".to_string(),
23155                    },
23156                ]),
23157                relationships: Some(vec![summarize::Relationship {
23158                    from: "helper".to_string(),
23159                    to: "TraversalGraph".to_string(),
23160                    kind: "uses".to_string(),
23161                }]),
23162                concept_labels: Some(vec![
23163                    "graph navigation".to_string(),
23164                    "semantic extraction".to_string(),
23165                ]),
23166                extracted_at: "1700000000".to_string(),
23167                model: "test-model".to_string(),
23168                tokens_input: Some(10),
23169                tokens_output: Some(5),
23170            })
23171            .unwrap();
23172    }
23173
23174    fn seed_tsift_memory_graph_db(dir: &Path) {
23175        let db = dir.join(".tsift").join("memory.db");
23176        let store = MemoryStore::open_or_create(&db).unwrap();
23177        let project = dir.to_string_lossy().to_string();
23178        let observation = MemoryEvent::new(
23179            MemoryEventKind::ImportedObservation,
23180            "claude-mem:observations:1",
23181            [
23182                "Graph memory adapter",
23183                "read-only projection",
23184                "graph-db should retrieve tsift memory observations",
23185                "Project memory is queried from .tsift/memory.db",
23186                "graph memory, tsift memory, semantic query",
23187            ]
23188            .join("\n\n"),
23189        )
23190        .with_session_id("claude-session-a")
23191        .with_observed_at_unix(1_700_000_000)
23192        .with_import("claude-mem", "observations:1")
23193        .with_metadata("project", project.clone())
23194        .with_metadata("observation_type", "fact")
23195        .with_metadata("prompt_number", "7")
23196        .with_metadata("discovery_tokens", "42")
23197        .with_metadata("content_hash", "hash-observation-1");
23198        store.insert_event(&observation).unwrap();
23199
23200        let summary = MemoryEvent::new(
23201            MemoryEventKind::ImportedSessionSummary,
23202            "claude-mem:session_summaries:2",
23203            [
23204                "Query old memory from graph-db",
23205                "Read-only tsift memory SQLite projection",
23206                "Semantic graph rows can point at existing memory",
23207                "Projected source and session nodes",
23208                "Keep capture ownership inside tsift-memory",
23209                "summary note",
23210            ]
23211            .join("\n\n"),
23212        )
23213        .with_session_id("claude-session-a")
23214        .with_observed_at_unix(1_700_000_010)
23215        .with_import("claude-mem", "session_summaries:2")
23216        .with_metadata("project", project)
23217        .with_metadata("prompt_number", "8")
23218        .with_metadata("discovery_tokens", "36");
23219        store.insert_event(&summary).unwrap();
23220
23221        let prompt = MemoryEvent::new(
23222            MemoryEventKind::ImportedUserPrompt,
23223            "claude-mem:user_prompts:3",
23224            "How can graph-db query tsift memory semantic history?",
23225        )
23226        .with_session_id("claude-session-a")
23227        .with_observed_at_unix(1_700_000_020)
23228        .with_import("claude-mem", "user_prompts:3")
23229        .with_metadata("prompt_number", "9");
23230        store.insert_event(&prompt).unwrap();
23231    }
23232
23233    #[test]
23234    fn graph_callers_query() {
23235        let dir = setup_graph_index();
23236        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23237        let callers = db.callers_of("helper").unwrap();
23238        assert_eq!(callers.len(), 1);
23239        assert_eq!(callers[0].caller_name, "main");
23240    }
23241
23242    #[test]
23243    fn graph_callees_query() {
23244        let dir = setup_graph_index();
23245        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23246        let callees = db.callees_of("main").unwrap();
23247        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
23248        assert!(names.contains(&"helper"));
23249        assert!(names.contains(&"new"));
23250    }
23251
23252    #[test]
23253    fn graph_no_callers_returns_empty() {
23254        let dir = setup_graph_index();
23255        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23256        let callers = db.callers_of("nonexistent").unwrap();
23257        assert!(callers.is_empty());
23258    }
23259
23260    #[test]
23261    fn graph_cmd_autoindexes_missing_index_by_default() {
23262        let dir = tempfile::tempdir().unwrap();
23263        std::fs::write(
23264            dir.path().join("main.rs"),
23265            "fn helper() {}\nfn main() { helper(); }\n",
23266        )
23267        .unwrap();
23268        let result = cmd_graph(
23269            "helper",
23270            dir.path(),
23271            true,
23272            false,
23273            None,
23274            20,
23275            false,
23276            true,
23277            false,
23278            false,
23279            false,
23280            false,
23281            false,
23282            TagpathSearchOpts::default(),
23283        );
23284
23285        assert!(result.is_ok());
23286        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
23287        let summary = db.compute_changes(dir.path()).unwrap();
23288        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
23289    }
23290
23291    #[test]
23292    fn traversal_graph_has_stable_typed_handles() {
23293        let dir = setup_traversal_project();
23294        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23295        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23296
23297        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
23298        let symbol = resolve_traversal_node(&graph, "helper").unwrap();
23299        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23300        let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
23301
23302        assert!(file.handle.starts_with("gfil-"));
23303        assert!(symbol.handle.starts_with("gsym-"));
23304        assert!(backlog.handle.starts_with("gbak-"));
23305        assert!(session.handle.starts_with("gses-"));
23306
23307        assert_eq!(
23308            symbol.handle,
23309            resolve_traversal_node(&graph_again, "helper")
23310                .unwrap()
23311                .handle
23312        );
23313        assert_eq!(
23314            backlog.handle,
23315            resolve_traversal_node(&graph_again, "#kgnv")
23316                .unwrap()
23317                .handle
23318        );
23319    }
23320
23321    #[test]
23322    fn traversal_graph_links_backlog_items_to_code_tokens() {
23323        let dir = setup_traversal_project();
23324        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23325        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23326        let helper = resolve_traversal_node(&graph, "helper").unwrap();
23327
23328        assert!(graph.edges.iter().any(|edge| {
23329            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23330        }));
23331    }
23332
23333    #[test]
23334    fn session_hinted_traversal_skips_global_call_edges() {
23335        let dir = setup_traversal_project();
23336        let session = dir.path().join("tasks/software/tsift.md");
23337        let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
23338        let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
23339        let helper = resolve_traversal_node(&bounded, "helper").unwrap();
23340
23341        assert!(bounded.edges.iter().any(|edge| {
23342            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23343        }));
23344        assert!(
23345            !bounded.edges.iter().any(|edge| edge.relation == "calls"),
23346            "session-hinted graph-db projections should not materialize unrelated global call edges"
23347        );
23348
23349        let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
23350        assert!(
23351            full.edges.iter().any(|edge| edge.relation == "calls"),
23352            "root/full projections still carry the complete indexed call graph"
23353        );
23354    }
23355
23356    #[test]
23357    fn agent_doc_task_path_infers_matching_workspace_scope() {
23358        let dir = tempfile::tempdir().unwrap();
23359        std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
23360        std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
23361        std::fs::write(
23362            dir.path().join(".gitmodules"),
23363            "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
23364        )
23365        .unwrap();
23366        let task = dir.path().join("tasks/software/tsift.md");
23367        std::fs::write(&task, "# tsift\n").unwrap();
23368
23369        let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
23370        let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
23371        let cfg = config::Config::load(dir.path()).unwrap();
23372
23373        assert_eq!(targets.len(), 1);
23374        assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
23375        assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
23376        assert!(
23377            targets[0]
23378                .db_path
23379                .ends_with(".tsift/indexes/tsift/index.db")
23380        );
23381        assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
23382    }
23383
23384    #[test]
23385    fn cargo_package_scope_selector_indexes_package_db() {
23386        let dir = setup_multiplicity_project();
23387        let targets =
23388            resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
23389
23390        assert_eq!(targets.len(), 1);
23391        assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
23392        assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
23393        assert!(
23394            targets[0]
23395                .db_path
23396                .ends_with(".tsift/indexes/cargo/core-lib/index.db")
23397        );
23398
23399        cmd_index(
23400            dir.path(),
23401            false,
23402            false,
23403            false,
23404            false,
23405            true,
23406            false,
23407            Some("core_lib"),
23408            false,
23409            true,
23410            false,
23411            false,
23412            false,
23413            false,
23414        )
23415        .unwrap();
23416        assert!(targets[0].db_path.exists());
23417    }
23418
23419    #[test]
23420    fn path_inference_prefers_nested_cargo_package_without_submodule() {
23421        let dir = setup_multiplicity_project();
23422        let source = dir.path().join("crates/cli-app/src/main.rs");
23423        let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
23424
23425        assert_eq!(targets.len(), 1);
23426        assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
23427        assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
23428    }
23429
23430    #[test]
23431    fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
23432        let dir = setup_multiplicity_project();
23433        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23434        let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
23435        let core = resolve_traversal_node(&graph, "core-lib").unwrap();
23436        let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
23437        let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
23438
23439        assert_eq!(workspace.kind, "cargo_workspace");
23440        assert_eq!(core.kind, "cargo_package");
23441        assert_eq!(
23442            core.properties.get("features"),
23443            Some(&"default".to_string())
23444        );
23445        assert!(graph.edges.iter().any(|edge| {
23446            edge.from == workspace.handle
23447                && edge.to == core.handle
23448                && edge.relation == "contains_package"
23449        }));
23450        assert!(graph.edges.iter().any(|edge| {
23451            edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
23452        }));
23453        assert!(graph.edges.iter().any(|edge| {
23454            edge.from == cli.handle
23455                && edge.to == core.handle
23456                && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
23457        }));
23458    }
23459
23460    #[test]
23461    fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
23462        let dir = setup_multiplicity_project();
23463        let session = dir.path().join("tasks/software/tsift.md");
23464        let report =
23465            build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
23466                .unwrap();
23467
23468        assert!(report.per_target_fail_closed.is_empty());
23469        let candidate = report
23470            .candidates
23471            .iter()
23472            .find(|candidate| candidate.target == "corepkg")
23473            .unwrap();
23474        assert!(
23475            candidate
23476                .owned_files
23477                .iter()
23478                .any(|file| file == "crates/core-lib/Cargo.toml"),
23479            "{:?}",
23480            candidate.owned_files
23481        );
23482    }
23483
23484    #[test]
23485    fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
23486        let dir = setup_traversal_project();
23487        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23488        let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
23489        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23490
23491        assert_eq!(job.kind, "job_packet");
23492        assert!(job.handle.starts_with("gjob-"));
23493        assert!(graph.edges.iter().any(|edge| {
23494            edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
23495        }));
23496
23497        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23498        let jobs = store.nodes_by_kind("job_packet").unwrap();
23499        assert!(
23500            jobs.iter()
23501                .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23502            "expected queued job packet in graph store, got {jobs:?}"
23503        );
23504    }
23505
23506    #[test]
23507    fn traversal_graph_includes_routes_and_handler_edges() {
23508        let dir = tempfile::tempdir().unwrap();
23509        std::fs::write(
23510            dir.path().join("api.py"),
23511            r#"@router.get("/items")
23512def list_items():
23513    return []
23514"#,
23515        )
23516        .unwrap();
23517        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23518        db.apply_changes(dir.path()).unwrap();
23519
23520        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23521        let route = resolve_traversal_node(&graph, "/items").unwrap();
23522        let handler = resolve_traversal_node(&graph, "list_items").unwrap();
23523
23524        assert_eq!(route.kind, "route");
23525        assert!(graph.edges.iter().any(|edge| {
23526            edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
23527        }));
23528    }
23529
23530    #[test]
23531    fn traversal_graph_projects_rust_ast_navigation_edges() {
23532        let dir = tempfile::tempdir().unwrap();
23533        std::fs::write(
23534            dir.path().join("main.rs"),
23535            r#"mod api {
23536    pub fn helper() {}
23537    pub fn handler() { helper(); }
23538}
23539
23540fn main() { api::handler(); }
23541"#,
23542        )
23543        .unwrap();
23544        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23545        db.apply_changes(dir.path()).unwrap();
23546
23547        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23548        let api = resolve_ast_span_node(&graph, "api", "mod");
23549        let helper = resolve_ast_span_node(&graph, "helper", "function");
23550        let handler = resolve_ast_span_node(&graph, "handler", "function");
23551
23552        assert_eq!(helper.kind, "ast_span");
23553        assert!(helper.handle.starts_with("span-"));
23554        assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
23555        assert!(graph.edges.iter().any(|edge| {
23556            edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
23557        }));
23558        assert!(graph.edges.iter().any(|edge| {
23559            edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
23560        }));
23561        assert!(graph.edges.iter().any(|edge| {
23562            edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
23563        }));
23564        assert!(graph.edges.iter().any(|edge| {
23565            edge.from == helper.handle
23566                && edge.to == handler.handle
23567                && edge.relation == "next_sibling"
23568        }));
23569        assert!(graph.edges.iter().any(|edge| {
23570            edge.from == handler.handle
23571                && edge.to == helper.handle
23572                && edge.relation == "previous_sibling"
23573        }));
23574        assert!(graph.edges.iter().any(|edge| {
23575            edge.from == helper.handle
23576                && edge.to == api.handle
23577                && edge.relation == "enclosing_module"
23578        }));
23579        assert!(graph.edges.iter().any(|edge| {
23580            edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
23581        }));
23582
23583        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23584        let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
23585        assert!(
23586            ast_nodes.iter().any(|node| node.id == helper.handle
23587                && node.properties.get("symbol_kind") == Some(&"function".to_string())),
23588            "expected helper AST span in graph store, got {ast_nodes:?}"
23589        );
23590        assert!(
23591            store
23592                .outgoing_edges(&helper.handle, Some("parent"))
23593                .unwrap()
23594                .iter()
23595                .any(|edge| edge.to_id == api.handle),
23596            "expected persisted AST parent edge"
23597        );
23598    }
23599
23600    #[test]
23601    fn traversal_graph_projects_markdown_section_block_edges() {
23602        let dir = tempfile::tempdir().unwrap();
23603        std::fs::write(
23604            dir.path().join("README.md"),
23605            "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
23606        )
23607        .unwrap();
23608        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23609        db.apply_changes(dir.path()).unwrap();
23610
23611        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23612        let guide = resolve_ast_span_node(&graph, "Guide", "heading");
23613        let code = resolve_ast_span_node(&graph, "rust", "code_block");
23614        let embedded = resolve_ast_span_node(&graph, "demo", "function");
23615        let list_item = graph
23616            .nodes
23617            .values()
23618            .find(|node| {
23619                node.kind == "ast_span"
23620                    && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
23621                    && node.properties.get("section_handle") == Some(&guide.handle)
23622            })
23623            .expect("missing Markdown list item AST span");
23624
23625        assert_eq!(
23626            code.properties.get("markdown_block_kind"),
23627            Some(&"fenced_code_block".to_string())
23628        );
23629        assert_eq!(
23630            guide.properties.get("heading_level"),
23631            Some(&"1".to_string())
23632        );
23633        assert_eq!(
23634            embedded.properties.get("embedded"),
23635            Some(&"true".to_string())
23636        );
23637        assert_eq!(
23638            embedded.properties.get("language"),
23639            Some(&"rust".to_string())
23640        );
23641        assert_eq!(
23642            embedded.properties.get("markdown_block_handle"),
23643            Some(&code.handle)
23644        );
23645        assert!(graph.edges.iter().any(|edge| {
23646            edge.from == guide.handle
23647                && edge.to == code.handle
23648                && edge.relation == "contains_markdown_block"
23649        }));
23650        assert!(graph.edges.iter().any(|edge| {
23651            edge.from == code.handle
23652                && edge.to == guide.handle
23653                && edge.relation == "enclosing_section"
23654        }));
23655        assert!(graph.edges.iter().any(|edge| {
23656            edge.from == guide.handle
23657                && edge.to == list_item.handle
23658                && edge.relation == "contains_markdown_block"
23659        }));
23660        assert!(graph.edges.iter().any(|edge| {
23661            edge.from == code.handle
23662                && edge.to == embedded.handle
23663                && edge.relation == "contains_embedded_symbol"
23664        }));
23665        assert!(graph.edges.iter().any(|edge| {
23666            edge.from == embedded.handle
23667                && edge.to == code.handle
23668                && edge.relation == "embedded_in_fence"
23669        }));
23670        assert!(graph.edges.iter().any(|edge| {
23671            edge.from == guide.handle
23672                && edge.to == embedded.handle
23673                && edge.relation == "contains_embedded_code"
23674        }));
23675
23676        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23677        assert!(
23678            store
23679                .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
23680                .unwrap()
23681                .iter()
23682                .any(|edge| edge.to_id == code.handle),
23683            "expected persisted Markdown section/block edge"
23684        );
23685        assert!(
23686            store
23687                .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
23688                .unwrap()
23689                .iter()
23690                .any(|edge| edge.to_id == embedded.handle),
23691            "expected persisted Markdown fence/embedded symbol edge"
23692        );
23693    }
23694
23695    #[test]
23696    fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
23697        let dir = setup_multilingual_ast_navigation_project();
23698        let db =
23699            index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
23700        let symbols = db.all_symbols().unwrap();
23701        let expected_symbols = [
23702            ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
23703            (
23704                "python",
23705                "fixture_nav_python_entry",
23706                "function",
23707                "python.py",
23708            ),
23709            (
23710                "typescript",
23711                "fixture_nav_typescript_entry",
23712                "function",
23713                "typescript.ts",
23714            ),
23715            (
23716                "javascript",
23717                "fixture_nav_javascript_entry",
23718                "function",
23719                "javascript.js",
23720            ),
23721            (
23722                "kotlin",
23723                "fixture_nav_kotlin_entry",
23724                "function",
23725                "kotlin.kt",
23726            ),
23727            ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
23728            ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
23729            ("markdown", "Fixture Section", "heading", "README.md"),
23730            ("markdown", "Fixture step", "list_item", "README.md"),
23731            ("markdown", "python", "code_block", "README.md"),
23732        ];
23733
23734        for (language, name, kind, file) in expected_symbols {
23735            let symbol = symbols
23736                .iter()
23737                .find(|symbol| {
23738                    symbol.language == language
23739                        && symbol.name == name
23740                        && symbol.kind == kind
23741                        && symbol.file.ends_with(file)
23742                })
23743                .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
23744            assert!(
23745                symbol.start_byte.is_some() && symbol.end_byte.is_some(),
23746                "{language} {name} should carry AST byte spans"
23747            );
23748        }
23749
23750        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23751        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23752        let expected_ast_nodes = [
23753            ("fixture_nav_rust_entry", "function", "rust"),
23754            ("fixture_nav_python_entry", "function", "python"),
23755            ("fixture_nav_typescript_entry", "function", "typescript"),
23756            ("fixture_nav_javascript_entry", "function", "javascript"),
23757            ("fixture_nav_kotlin_entry", "function", "kotlin"),
23758            ("fixture_nav_zig_entry", "function", "zig"),
23759            ("fixture_nav_bash_entry", "function", "bash"),
23760            ("Fixture Section", "heading", "markdown"),
23761            ("Fixture step", "list_item", "markdown"),
23762            ("python", "code_block", "markdown"),
23763            ("fixture_nav_markdown_embedded", "function", "python"),
23764        ];
23765
23766        for (name, kind, language) in expected_ast_nodes {
23767            let node = resolve_ast_span_node(&graph, name, kind);
23768            let repeated = resolve_ast_span_node(&graph_again, name, kind);
23769            assert!(
23770                node.handle.starts_with("span-"),
23771                "{name} handle: {}",
23772                node.handle
23773            );
23774            assert_eq!(
23775                node.handle, repeated.handle,
23776                "{language} {name} handle drifted"
23777            );
23778            assert_eq!(
23779                node.properties.get("language"),
23780                Some(&language.to_string()),
23781                "{name} should keep its language label"
23782            );
23783        }
23784
23785        let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
23786        let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
23787        let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
23788        assert!(graph.edges.iter().any(|edge| {
23789            edge.from == markdown_section.handle
23790                && edge.to == markdown_code.handle
23791                && edge.relation == "contains_markdown_block"
23792        }));
23793        assert!(graph.edges.iter().any(|edge| {
23794            edge.from == markdown_code.handle
23795                && edge.to == embedded.handle
23796                && edge.relation == "contains_embedded_symbol"
23797        }));
23798        assert!(
23799            graph.nodes.len() <= 80,
23800            "multilingual AST fixture should stay bounded, got {} nodes",
23801            graph.nodes.len()
23802        );
23803        assert!(
23804            graph.edges.len() <= 180,
23805            "multilingual AST fixture should stay bounded, got {} edges",
23806            graph.edges.len()
23807        );
23808
23809        let response = empty_search_response(dir.path(), "lexical");
23810        let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
23811        let report = build_relative_search_budget_report(
23812            "fixture_nav_python_entry",
23813            "lexical",
23814            dir.path(),
23815            &response,
23816            &symbol_hits,
23817            ResponseBudget::new(Some(8), Some(120)),
23818            &SearchFacetFilters::default(),
23819        );
23820        let report_again = build_relative_search_budget_report(
23821            "fixture_nav_python_entry",
23822            "lexical",
23823            dir.path(),
23824            &response,
23825            &symbol_hits,
23826            ResponseBudget::new(Some(8), Some(120)),
23827            &SearchFacetFilters::default(),
23828        );
23829
23830        let top = report
23831            .ranked
23832            .first()
23833            .expect("ranked preview should not be empty");
23834        assert_eq!(top.source, "symbol_span");
23835        assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
23836        assert!(top.handle.starts_with("srnk-"));
23837        assert_eq!(top.handle, report_again.ranked[0].handle);
23838        assert!(
23839            top.reasons.iter().any(|reason| reason == "ast_span"),
23840            "expected AST span ranking reason, got {:?}",
23841            top.reasons
23842        );
23843        assert!(report.ranked.len() <= 8);
23844        assert!(report.symbols.len() <= 8);
23845
23846        let symbol = report
23847            .symbols
23848            .iter()
23849            .find(|symbol| symbol.name == "fixture_nav_python_entry")
23850            .expect("missing search preview symbol");
23851        assert_cli_expand_command_parses(&symbol.expand);
23852        let ast = symbol
23853            .ast
23854            .as_ref()
23855            .expect("search symbol should expose AST");
23856        assert_cli_expand_command_parses(&ast.expand.source_window);
23857        assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
23858        assert_cli_expand_command_parses(&ast.expand.symbol_read);
23859
23860        let markdown_hits = db.symbol_search("python", 20).unwrap();
23861        let markdown_report = build_relative_search_budget_report(
23862            "python",
23863            "lexical",
23864            dir.path(),
23865            &response,
23866            &markdown_hits,
23867            ResponseBudget::new(Some(8), Some(120)),
23868            &SearchFacetFilters::default(),
23869        );
23870        let markdown_symbol = markdown_report
23871            .symbols
23872            .iter()
23873            .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
23874            .expect("missing Markdown code-block symbol");
23875        let markdown_ast = markdown_symbol
23876            .ast
23877            .as_ref()
23878            .expect("Markdown code block should expose AST");
23879        assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
23880        assert_eq!(
23881            markdown_ast
23882                .span
23883                .markdown
23884                .as_ref()
23885                .unwrap()
23886                .embedded_symbols[0]
23887                .name,
23888            "fixture_nav_markdown_embedded"
23889        );
23890    }
23891
23892    #[test]
23893    fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
23894        let edges = vec![
23895            TraversalEdge {
23896                from: "origin".to_string(),
23897                to: "aaa_low".to_string(),
23898                relation: "unknown".to_string(),
23899                label: None,
23900                weight: 1,
23901            },
23902            TraversalEdge {
23903                from: "origin".to_string(),
23904                to: "zzz_high".to_string(),
23905                relation: "mentions".to_string(),
23906                label: None,
23907                weight: 1,
23908            },
23909        ];
23910
23911        let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
23912
23913        assert!(handles.contains("origin"));
23914        assert!(handles.contains("zzz_high"), "{handles:?}");
23915        assert!(!handles.contains("aaa_low"), "{handles:?}");
23916    }
23917
23918    #[test]
23919    fn traversal_materializes_provider_neutral_sqlite_graph() {
23920        let dir = setup_traversal_project();
23921        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23922        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23923
23924        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23925        let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
23926        assert!(
23927            backlog_nodes.iter().any(|node| node.id == backlog.handle
23928                && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23929            "expected materialized backlog node, got {backlog_nodes:?}"
23930        );
23931        assert!(
23932            store
23933                .all_nodes()
23934                .unwrap()
23935                .iter()
23936                .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
23937                    && node.properties.get("projection_version")
23938                        == Some(&GRAPH_PROJECTION_VERSION.to_string())),
23939            "expected projection metadata node"
23940        );
23941        let source_handles = store.nodes_by_kind("source_handle").unwrap();
23942        assert!(
23943            source_handles
23944                .iter()
23945                .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
23946            "expected bounded source_handle rows, got {source_handles:?}"
23947        );
23948        let worker_context = store.nodes_by_kind("worker_context").unwrap();
23949        assert!(
23950            worker_context
23951                .iter()
23952                .any(|node| node.properties.get("target")
23953                    == Some(&"tasks/software/tsift.md".to_string())),
23954            "expected bounded worker_context rows, got {worker_context:?}"
23955        );
23956        let worker_results = store.nodes_by_kind("worker_result").unwrap();
23957        assert!(
23958            worker_results.iter().any(|node| {
23959                node.properties.get("ref_id") == Some(&"kgnv".to_string())
23960                    && node.properties.get("status") == Some(&"completed".to_string())
23961                    && node.properties.get("touched_files") == Some(&"main.rs".to_string())
23962                    && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
23963            }),
23964            "expected worker_result rows, got {worker_results:?}"
23965        );
23966    }
23967
23968    #[test]
23969    fn traversal_projection_materializes_cached_semantic_rows() {
23970        let dir = setup_traversal_project();
23971        seed_traversal_semantic_summaries(dir.path());
23972        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23973        let helper = resolve_traversal_node(&graph, "helper").unwrap();
23974        let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
23975        let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
23976
23977        assert_eq!(concept.kind, "semantic_concept");
23978        assert_eq!(entity.kind, "semantic_entity");
23979        assert!(concept.handle.starts_with("gcon-"));
23980        assert!(entity.handle.starts_with("gent-"));
23981
23982        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23983        assert!(
23984            store
23985                .nodes_by_kind("semantic_concept")
23986                .unwrap()
23987                .iter()
23988                .any(|node| node.label == "semantic extraction"
23989                    && node.properties.contains_key("embedding")),
23990            "expected persisted concept embeddings"
23991        );
23992        assert!(
23993            store
23994                .outgoing_edges(&helper.handle, Some("mentions_concept"))
23995                .unwrap()
23996                .iter()
23997                .any(|edge| edge.to_id == concept.handle),
23998            "expected helper symbol to link to cached summary concept"
23999        );
24000        assert!(
24001            store
24002                .outgoing_edges(
24003                    &semantic_entity_handle("helper", "function"),
24004                    Some("semantic_relation")
24005                )
24006                .unwrap()
24007                .iter()
24008                .any(|edge| edge.to_id == entity.handle
24009                    && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
24010            "expected LLM relationship rows projected into GraphStore"
24011        );
24012    }
24013
24014    #[test]
24015    fn traversal_projection_materializes_tsift_memory_rows() {
24016        let dir = setup_traversal_project();
24017        seed_tsift_memory_graph_db(dir.path());
24018        let memory_db = dir.path().join(".tsift").join("memory.db");
24019        let store = MemoryStore::open_or_create(&memory_db).unwrap();
24020        for summary in ["first closeout", "second closeout"] {
24021            let event = MemoryEvent::new(
24022                MemoryEventKind::ResponseSummary,
24023                "tasks/software/tsift.md",
24024                summary,
24025            )
24026            .with_session_id("tasks/software/tsift.md")
24027            .with_observed_at_unix(1_700_000_100);
24028            store.insert_event(&event).unwrap();
24029        }
24030        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24031        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24032
24033        let native_sources = store
24034            .nodes_by_kind("source_handle")
24035            .unwrap()
24036            .into_iter()
24037            .filter(|node| {
24038                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24039                    && node.properties.get("source_ref")
24040                        == Some(&"tasks/software/tsift.md".to_string())
24041            })
24042            .collect::<Vec<_>>();
24043        assert_eq!(
24044            native_sources.len(),
24045            2,
24046            "same-source native memory events must get distinct source handles"
24047        );
24048
24049        let source = store
24050            .nodes_by_kind("source_handle")
24051            .unwrap()
24052            .into_iter()
24053            .find(|node| {
24054                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24055            })
24056            .expect("expected tsift-memory source handle");
24057        let session = store
24058            .nodes_by_kind("memory_session")
24059            .unwrap()
24060            .into_iter()
24061            .find(|node| {
24062                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24063                    && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
24064            })
24065            .expect("expected tsift-memory session node");
24066        let event = store
24067            .nodes_by_kind("memory_event")
24068            .unwrap()
24069            .into_iter()
24070            .find(|node| {
24071                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24072                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24073                    && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
24074            })
24075            .expect("expected tsift-memory event node");
24076        let concept = store
24077            .nodes_by_kind("semantic_concept")
24078            .unwrap()
24079            .into_iter()
24080            .find(|node| {
24081                node.properties.get("provider") == Some(&"tsift-memory".to_string())
24082                    && node.label.contains("Graph memory adapter")
24083                    && node.properties.contains_key("embedding")
24084            })
24085            .expect("expected tsift-memory semantic concept");
24086
24087        assert!(
24088            store
24089                .outgoing_edges(&session.id, Some("records_memory_source"))
24090                .unwrap()
24091                .iter()
24092                .any(|edge| edge.to_id == source.id),
24093            "expected session to link to source handle"
24094        );
24095        assert!(
24096            store
24097                .outgoing_edges(&session.id, Some("records_memory_event"))
24098                .unwrap()
24099                .iter()
24100                .any(|edge| edge.to_id == event.id),
24101            "expected session to link to memory event"
24102        );
24103        assert!(
24104            store
24105                .outgoing_edges(&event.id, Some("projects_source"))
24106                .unwrap()
24107                .iter()
24108                .any(|edge| edge.to_id == source.id),
24109            "expected memory event to project source handle"
24110        );
24111        assert!(
24112            store
24113                .outgoing_edges(&source.id, Some("mentions_concept"))
24114                .unwrap()
24115                .iter()
24116                .any(|edge| edge.to_id == concept.id),
24117            "expected source handle to seed semantic concept"
24118        );
24119
24120        let related = semantic_related_report_from_store(
24121            dir.path(),
24122            None,
24123            "tsift memory graph adapter",
24124            5,
24125            SemanticRelatedKind::Concept,
24126            &store,
24127        )
24128        .unwrap();
24129        assert!(
24130            related
24131                .items
24132                .iter()
24133                .any(|item| item.handle == concept.id && item.score > 0.0),
24134            "expected semantic query to retrieve tsift-memory concept, got {:?}",
24135            related.items
24136        );
24137
24138        let graph_related = graph_db_report_from_store(
24139            dir.path(),
24140            None,
24141            "sqlite",
24142            GraphDbQuery::Related {
24143                query: "tsift memory graph adapter".to_string(),
24144                kind: SemanticRelatedKind::Concept,
24145                depth: 1,
24146                seed_limit: 5,
24147                limit: 20,
24148            },
24149            &store,
24150            sqlite_graph_freshness(&store, "root").unwrap(),
24151            Vec::new(),
24152        )
24153        .unwrap();
24154        assert_eq!(
24155            graph_related
24156                .readiness
24157                .as_ref()
24158                .map(|readiness| readiness.status.as_str()),
24159            Some("ready"),
24160            "tsift-memory semantic rows should satisfy graph-db related readiness"
24161        );
24162        assert!(
24163            graph_related.nodes.iter().any(|node| {
24164                node.kind == "semantic_concept"
24165                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24166            }),
24167            "expected related graph output to include tsift-memory semantic rows"
24168        );
24169    }
24170
24171    #[test]
24172    fn semantic_related_query_uses_persisted_graph_embeddings() {
24173        let dir = setup_traversal_project();
24174        seed_traversal_semantic_summaries(dir.path());
24175        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24176        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24177
24178        let report = semantic_related_report_from_store(
24179            dir.path(),
24180            None,
24181            "graph navigation",
24182            5,
24183            SemanticRelatedKind::Concept,
24184            &store,
24185        )
24186        .unwrap();
24187
24188        assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
24189        assert!(
24190            report
24191                .items
24192                .iter()
24193                .any(|item| item.label == "graph navigation"
24194                    && item.kind == "semantic_concept"
24195                    && item.score > 0.9),
24196            "expected nearest concept match from graph embeddings, got {:?}",
24197            report.items
24198        );
24199    }
24200
24201    #[test]
24202    fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
24203        let dir = setup_traversal_project();
24204        seed_traversal_semantic_summaries(dir.path());
24205        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24206        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24207
24208        let report = graph_db_report_from_store(
24209            dir.path(),
24210            None,
24211            "sqlite",
24212            GraphDbQuery::Related {
24213                query: "graph navigation".to_string(),
24214                kind: SemanticRelatedKind::All,
24215                depth: 1,
24216                seed_limit: 2,
24217                limit: 20,
24218            },
24219            &store,
24220            sqlite_graph_freshness(&store, "root").unwrap(),
24221            Vec::new(),
24222        )
24223        .unwrap();
24224
24225        let knowledge = report.knowledge_retrieval.as_ref().unwrap();
24226        assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
24227        assert_eq!(knowledge.seed_kind, "all");
24228        assert_eq!(knowledge.depth, 1);
24229        assert_eq!(
24230            report
24231                .readiness
24232                .as_ref()
24233                .map(|readiness| readiness.status.as_str()),
24234            Some("ready")
24235        );
24236        assert!(
24237            knowledge
24238                .diagnostics
24239                .iter()
24240                .any(|diagnostic| diagnostic.contains("incident"))
24241        );
24242        assert!(
24243            report
24244                .semantic_related
24245                .iter()
24246                .any(|item| item.label == "graph navigation"
24247                    && item.kind == "semantic_concept"
24248                    && item.score > 0.9),
24249            "expected natural-language query to seed the graph navigation concept, got {:?}",
24250            report.semantic_related
24251        );
24252        assert!(
24253            report
24254                .nodes
24255                .iter()
24256                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
24257        );
24258        assert!(
24259            report
24260                .nodes
24261                .iter()
24262                .any(|node| node.kind == "symbol" && node.label == "helper"),
24263            "incident expansion from semantic seed should recover source symbols, got {:?}",
24264            report
24265                .nodes
24266                .iter()
24267                .map(|node| (&node.kind, &node.label))
24268                .collect::<Vec<_>>()
24269        );
24270        assert!(
24271            report
24272                .edges
24273                .iter()
24274                .any(|edge| edge.kind == "mentions_concept")
24275        );
24276        assert!(
24277            report.output_budget.as_ref().is_some_and(|budget| budget
24278                .diagnostics
24279                .iter()
24280                .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
24281            "expected related output budget diagnostics, got {:?}",
24282            report.output_budget
24283        );
24284    }
24285
24286    #[test]
24287    fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
24288        let dir = setup_graph_index();
24289        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24290        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24291
24292        let report = graph_db_report_from_store(
24293            dir.path(),
24294            None,
24295            "sqlite",
24296            GraphDbQuery::Related {
24297                query: "graph navigation".to_string(),
24298                kind: SemanticRelatedKind::All,
24299                depth: 1,
24300                seed_limit: 2,
24301                limit: 20,
24302            },
24303            &store,
24304            sqlite_graph_freshness(&store, "root").unwrap(),
24305            Vec::new(),
24306        )
24307        .unwrap();
24308
24309        let readiness = report.readiness.as_ref().unwrap();
24310        assert_eq!(readiness.status, "blocked");
24311        assert_eq!(readiness.reason, "summary_cache_empty");
24312        assert!(readiness.fail_closed);
24313        assert_eq!(
24314            readiness.next_commands,
24315            vec![
24316                "tsift summarize --extract .".to_string(),
24317                graph_db_refresh_command(dir.path(), None)
24318            ]
24319        );
24320        assert!(
24321            report
24322                .knowledge_retrieval
24323                .as_ref()
24324                .unwrap()
24325                .diagnostics
24326                .iter()
24327                .any(|diagnostic| diagnostic.contains("summary cache empty")
24328                    && diagnostic.contains("graph-db materialized code/session rows")),
24329            "expected related diagnostics to carry readiness gate, got {:?}",
24330            report.knowledge_retrieval.as_ref().unwrap().diagnostics
24331        );
24332    }
24333
24334    #[test]
24335    fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
24336        let mut nodes = vec![
24337            SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
24338            SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
24339        ];
24340        let mut edges = vec![SubstrateGraphEdge::new(
24341            "zzz_high",
24342            "seed",
24343            "mentions_concept",
24344        )];
24345        for idx in 0..24 {
24346            let id = format!("aaa_low_{idx:02}");
24347            nodes.push(SubstrateGraphNode::new(
24348                id.clone(),
24349                "note",
24350                format!("low {idx}"),
24351            ));
24352            edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
24353        }
24354        let mut store = SqliteGraphStore::in_memory().unwrap();
24355        store
24356            .replace_projection(&GraphProjection { nodes, edges })
24357            .unwrap();
24358
24359        let subgraph =
24360            graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
24361
24362        assert_eq!(subgraph.nodes.len(), 3);
24363        assert_eq!(subgraph.nodes[0].id, "seed");
24364        assert_eq!(
24365            subgraph.nodes[1].id, "zzz_high",
24366            "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
24367            subgraph.nodes
24368        );
24369        assert!(subgraph.truncated);
24370        assert!(
24371            subgraph
24372                .diagnostics
24373                .iter()
24374                .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
24375            "{:?}",
24376            subgraph.diagnostics
24377        );
24378        assert!(
24379            subgraph
24380                .diagnostics
24381                .iter()
24382                .any(|diagnostic| diagnostic.contains("skipped")),
24383            "{:?}",
24384            subgraph.diagnostics
24385        );
24386    }
24387
24388    #[test]
24389    fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
24390        let dir = setup_traversal_project();
24391        seed_traversal_semantic_summaries(dir.path());
24392        init_git_repo(dir.path());
24393        let session = dir.path().join("tasks/software/tsift.md");
24394        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24395        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24396        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24397        let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24398            root: dir.path(),
24399            scope: None,
24400            backend: "sqlite",
24401            target: "kgnv",
24402            depth: 4,
24403            limit: 8,
24404            cursor: None,
24405            store: &store,
24406            freshness,
24407            warnings: Vec::new(),
24408        })
24409        .unwrap();
24410        assert!(
24411            evidence
24412                .semantic_related
24413                .iter()
24414                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
24415            "expected semantic evidence rows, got {:?}",
24416            evidence
24417                .semantic_related
24418                .iter()
24419                .map(|node| (&node.kind, &node.label))
24420                .collect::<Vec<_>>()
24421        );
24422        assert!(
24423            evidence
24424                .output_budget
24425                .as_ref()
24426                .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
24427                    diagnostic.contains("semantic_match")
24428                        && diagnostic.contains("source_handle_coverage")
24429                })),
24430            "expected evidence output budget diagnostics, got {:?}",
24431            evidence.output_budget
24432        );
24433
24434        let cached_diff = diff_digest::compute(
24435            dir.path(),
24436            diff_digest::DiffDigestOptions {
24437                cached: true,
24438                revision: None,
24439                max_parsed_files: None,
24440            },
24441        )
24442        .unwrap();
24443        let impact_report = impact::compute(
24444            dir.path(),
24445            impact::ImpactOptions {
24446                cached: true,
24447                revision: None,
24448                scope: None,
24449                limit: 10,
24450            },
24451        )
24452        .unwrap();
24453        let graph_nodes = store.all_nodes().unwrap();
24454        let graph_index = conflict_matrix_graph_index(&graph_nodes);
24455        let semantic_candidate = conflict_matrix_candidate_from_evidence(
24456            dir.path(),
24457            &evidence,
24458            &graph_index,
24459            &cached_diff,
24460            &impact_report,
24461        );
24462        assert!(semantic_candidate.semantic_dispatch_score > 0);
24463        assert!(
24464            semantic_candidate
24465                .semantic_dispatch_reasons
24466                .iter()
24467                .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
24468            "expected semantic ranking explanations, got {:?}",
24469            semantic_candidate.semantic_dispatch_reasons
24470        );
24471        assert!(
24472            semantic_candidate
24473                .semantic_related
24474                .iter()
24475                .any(|item| item.label == "graph navigation")
24476        );
24477
24478        let mut plain_candidate = semantic_candidate.clone();
24479        plain_candidate.target = "plain".to_string();
24480        plain_candidate.semantic_related.clear();
24481        plain_candidate.semantic_dispatch_score = 0;
24482        plain_candidate.semantic_dispatch_reasons.clear();
24483        let mut ranked = [plain_candidate, semantic_candidate];
24484        ranked.sort_by(|left, right| {
24485            left.risk
24486                .cmp(&right.risk)
24487                .then_with(|| left.risk_score.cmp(&right.risk_score))
24488                .then_with(|| {
24489                    right
24490                        .semantic_dispatch_score
24491                        .cmp(&left.semantic_dispatch_score)
24492                })
24493                .then_with(|| left.target.cmp(&right.target))
24494        });
24495        assert_eq!(ranked[0].target, "kgnv");
24496    }
24497
24498    #[test]
24499    fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
24500        let dir = setup_dependency_dag_project();
24501        let session = dir.path().join("tasks/software/tsift.md");
24502        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24503
24504        assert_eq!(report.contract_version, "dependency-dag-v1");
24505        assert_eq!(
24506            report.targets,
24507            vec![
24508                "prep".to_string(),
24509                "alpha".to_string(),
24510                "beta".to_string(),
24511                "gamma".to_string()
24512            ]
24513        );
24514        assert!(report.edges.iter().any(|edge| {
24515            edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
24516        }));
24517        assert!(report.edges.iter().any(|edge| {
24518            edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
24519        }));
24520        assert!(report.edges.iter().any(|edge| {
24521            edge.from == "alpha"
24522                && edge.to == "beta"
24523                && edge.kind == "shared_resource"
24524                && edge.shared_files.contains(&"main.rs".to_string())
24525                && edge.shared_symbols.contains(&"shared_helper".to_string())
24526        }));
24527        assert!(
24528            !report.cycle_diagnostics.has_cycles,
24529            "{:?}",
24530            report.cycle_diagnostics
24531        );
24532        assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
24533        assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
24534        assert!(
24535            report.replay_commands[0].contains("dependency-dag"),
24536            "{:?}",
24537            report.replay_commands
24538        );
24539
24540        cmd_dependency_dag(
24541            &session,
24542            None,
24543            &["alpha".to_string(), "beta".to_string()],
24544            4,
24545            12,
24546            OutputFormat {
24547                json_output: true,
24548                compact: false,
24549                pretty: false,
24550                terse: false,
24551                ultra_terse: false,
24552                schema: false,
24553                envelope: false,
24554            },
24555        )
24556        .unwrap();
24557    }
24558
24559    #[test]
24560    fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
24561        let dir = setup_dependency_dag_cycle_project();
24562        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24563
24564        assert!(report.cycle_diagnostics.has_cycles);
24565        assert_eq!(
24566            report.cycle_diagnostics.blocked_nodes,
24567            vec!["left".to_string(), "right".to_string()]
24568        );
24569        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24570            edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
24571        }));
24572        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24573            edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
24574        }));
24575    }
24576
24577    #[test]
24578    fn traversal_projection_queries_match_sqlite_and_convex_stores() {
24579        let dir = setup_traversal_project();
24580        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24581        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
24582
24583        let mut sqlite = SqliteGraphStore::in_memory().unwrap();
24584        sqlite.replace_projection(&projection).unwrap();
24585        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24586        projection.upsert_into(&convex).unwrap();
24587
24588        let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
24589        let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
24590        assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
24591        assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
24592
24593        let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
24594        let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
24595        assert!(convex_graph.edges.iter().any(|edge| {
24596            edge.from == sqlite_backlog.handle
24597                && edge.to == convex_helper.handle
24598                && edge.relation == "mentions"
24599        }));
24600    }
24601
24602    #[test]
24603    fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
24604        let dir = setup_traversal_project();
24605        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24606        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24607        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24608        assert_eq!(freshness.status, "current");
24609
24610        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24611        let report = graph_db_report_from_store(
24612            dir.path(),
24613            None,
24614            "sqlite",
24615            GraphDbQuery::Neighborhood {
24616                id: backlog.handle.clone(),
24617                depth: 1,
24618                edge_kind: Some("mentions".to_string()),
24619                cursor: None,
24620                limit: None,
24621                property_filters: Vec::new(),
24622            },
24623            &store,
24624            freshness,
24625            Vec::new(),
24626        )
24627        .unwrap();
24628        assert!(
24629            report
24630                .edges
24631                .iter()
24632                .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
24633            "expected backlog mention edge, got {:?}",
24634            report.edges
24635        );
24636        assert!(
24637            report.ranked_neighbors.iter().any(|neighbor| {
24638                neighbor.depth == Some(1)
24639                    && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
24640                    && neighbor.node_id != backlog.handle
24641                    && neighbor.handle_coverage_pct >= 95.0
24642                    && neighbor.duplicate_name_precision >= 0.99
24643            }),
24644            "expected ranked neighborhood neighbors with quality scores, got {:?}",
24645            report.ranked_neighbors
24646        );
24647        assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
24648        let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
24649        assert!(!ranking_gate.ranked_output_default);
24650        assert_eq!(ranking_gate.default_order, "stable_node_id");
24651        assert!(
24652            ranking_gate
24653                .diagnostics
24654                .iter()
24655                .any(|diagnostic| diagnostic.contains("score-capped")),
24656            "{ranking_gate:?}"
24657        );
24658        assert!(
24659            ranking_gate
24660                .required_metrics
24661                .iter()
24662                .any(|metric| metric == "handle_coverage_pct")
24663        );
24664        assert!(
24665            ranking_gate
24666                .required_metrics
24667                .iter()
24668                .any(|metric| metric == "duplicate_name_precision")
24669        );
24670        assert!(
24671            report
24672                .page
24673                .as_ref()
24674                .unwrap()
24675                .diagnostics
24676                .iter()
24677                .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
24678            "expected SQLite neighborhood query plan diagnostics, got {:?}",
24679            report.page.as_ref().unwrap().diagnostics
24680        );
24681        let edges_report = graph_db_report_from_store(
24682            dir.path(),
24683            None,
24684            "sqlite",
24685            GraphDbQuery::Edges {
24686                edge_kind: Some("mentions".to_string()),
24687                cursor: None,
24688                limit: Some(2),
24689                property_filters: Vec::new(),
24690            },
24691            &store,
24692            sqlite_graph_freshness(&store, "root").unwrap(),
24693            Vec::new(),
24694        )
24695        .unwrap();
24696        let edge_id = edges_report
24697            .edges
24698            .first()
24699            .map(|edge| edge.id.clone())
24700            .expect("expected at least one paged mentions edge");
24701        assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
24702        assert_eq!(
24703            edges_report.page.as_ref().unwrap().returned_edges,
24704            edges_report.edges.len()
24705        );
24706
24707        let edge_report = graph_db_report_from_store(
24708            dir.path(),
24709            None,
24710            "sqlite",
24711            GraphDbQuery::Edge {
24712                id: edge_id.clone(),
24713            },
24714            &store,
24715            sqlite_graph_freshness(&store, "root").unwrap(),
24716            Vec::new(),
24717        )
24718        .unwrap();
24719        assert_eq!(
24720            edge_report.edge.as_ref().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
24721            Some(edge_id.clone())
24722        );
24723
24724        let incident_report = graph_db_report_from_store(
24725            dir.path(),
24726            None,
24727            "sqlite",
24728            GraphDbQuery::Incident {
24729                id: backlog.handle.clone(),
24730                edge_kind: Some("mentions".to_string()),
24731                cursor: None,
24732                limit: Some(1),
24733                property_filters: Vec::new(),
24734            },
24735            &store,
24736            sqlite_graph_freshness(&store, "root").unwrap(),
24737            Vec::new(),
24738        )
24739        .unwrap();
24740        assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
24741        assert!(
24742            incident_report
24743                .edges
24744                .iter()
24745                .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
24746            "{:?}",
24747            incident_report.edges
24748        );
24749
24750        let schema_report = graph_db_report_from_store(
24751            dir.path(),
24752            None,
24753            "sqlite",
24754            GraphDbQuery::Schema,
24755            &store,
24756            sqlite_graph_freshness(&store, "root").unwrap(),
24757            Vec::new(),
24758        )
24759        .unwrap();
24760        assert!(
24761            schema_report
24762                .schema
24763                .unwrap()
24764                .operations
24765                .iter()
24766                .any(|operation| operation.command.starts_with("neighborhood"))
24767        );
24768    }
24769
24770    #[test]
24771    fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
24772        let mut nodes = vec![SubstrateGraphNode::new(
24773            "origin",
24774            "backlog",
24775            "#budgeted-neighborhood",
24776        )];
24777        let mut edges = Vec::new();
24778        for idx in 0..32 {
24779            let id = format!("src-{idx:02}");
24780            nodes.push(
24781                SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
24782                    .with_property("source_ref", format!("fixture:{idx}"))
24783                    .with_property("detail", "x".repeat(600)),
24784            );
24785            edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
24786        }
24787        let store = SqliteGraphStore::in_memory().unwrap();
24788        GraphProjection { nodes, edges }
24789            .upsert_into(&store)
24790            .unwrap();
24791
24792        let report = graph_db_report_from_store(
24793            Path::new("."),
24794            None,
24795            "fixture",
24796            GraphDbQuery::Neighborhood {
24797                id: "origin".to_string(),
24798                depth: 1,
24799                edge_kind: None,
24800                cursor: None,
24801                limit: None,
24802                property_filters: Vec::new(),
24803            },
24804            &store,
24805            current_graph_db_freshness(),
24806            Vec::new(),
24807        )
24808        .unwrap();
24809        let budget = report.output_budget.as_ref().unwrap();
24810        assert!(budget.selected_nodes < budget.candidate_nodes);
24811        assert!(
24812            budget.dropped_by_budget.iter().any(|drop| {
24813                drop.item == "node"
24814                    && drop.kind == "source_handle"
24815                    && drop.reason == "per_kind_quota"
24816            }),
24817            "expected source_handle budget drops, got {:?}",
24818            budget.dropped_by_budget
24819        );
24820        assert!(report.page.as_ref().unwrap().truncated);
24821        assert!(
24822            report
24823                .page
24824                .as_ref()
24825                .unwrap()
24826                .diagnostics
24827                .iter()
24828                .any(|diagnostic| diagnostic.contains("budget ranking signals")),
24829            "{:?}",
24830            report.page
24831        );
24832    }
24833
24834    #[test]
24835    fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
24836        let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
24837        let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
24838        for idx in 0..8 {
24839            let id = format!("far-{idx:02}");
24840            nodes.push(SubstrateGraphNode::new(
24841                id.clone(),
24842                "note",
24843                format!("aaa deeper row {idx}"),
24844            ));
24845            depth_by_id.insert(id, 6);
24846        }
24847
24848        let origin_ids = vec!["target".to_string()];
24849        let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
24850            &origin_ids,
24851            &BTreeMap::new(),
24852            nodes,
24853            Vec::new(),
24854            Some(3),
24855            Some(&depth_by_id),
24856            None,
24857        );
24858
24859        assert!(
24860            budgeted.nodes.iter().any(|node| node.id == "near"),
24861            "expected the shallow evidence row to outrank deeper rows, got {:?}",
24862            budgeted
24863                .nodes
24864                .iter()
24865                .map(|node| (&node.id, &node.label))
24866                .collect::<Vec<_>>()
24867        );
24868        assert!(
24869            budgeted.report.dropped_by_budget.iter().any(|drop| {
24870                drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
24871            }),
24872            "expected node quota drops, got {:?}",
24873            budgeted.report.dropped_by_budget
24874        );
24875        assert!(
24876            budgeted
24877                .report
24878                .diagnostics
24879                .iter()
24880                .any(|diagnostic| diagnostic.contains("depth")),
24881            "{:?}",
24882            budgeted.report.diagnostics
24883        );
24884    }
24885
24886    #[test]
24887    fn evidence_pagination_returns_next_cursor_when_truncated() {
24888        let mut nodes = vec![SubstrateGraphNode::new(
24889            "target".to_string(),
24890            "backlog_item",
24891            "target item".to_string(),
24892        )];
24893        let mut depth_by_id = BTreeMap::new();
24894        depth_by_id.insert("target".to_string(), 0);
24895        for idx in 0..20 {
24896            let id = format!("ev-{idx}");
24897            nodes.push(SubstrateGraphNode::new(
24898                id.clone(),
24899                "source_handle",
24900                format!("evidence row {idx}"),
24901            ).with_property("detail", "x".repeat(400)));
24902            depth_by_id.insert(id, 1);
24903        }
24904        let origin_ids = vec!["target".to_string()];
24905        let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
24906            &origin_ids,
24907            &BTreeMap::new(),
24908            nodes.clone(),
24909            Vec::new(),
24910            Some(3),
24911            Some(&depth_by_id),
24912            None,
24913        );
24914        assert!(
24915            first_page.truncated,
24916            "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
24917            first_page.nodes.len(),
24918            first_page.report.candidate_nodes
24919        );
24920        assert!(
24921            first_page.next_cursor.is_some(),
24922            "expected next_cursor when truncated"
24923        );
24924        let cursor = first_page.next_cursor.unwrap();
24925        assert!(
24926            !cursor.is_empty(),
24927            "cursor should be a non-empty node id"
24928        );
24929        let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
24930        let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
24931            &origin_ids,
24932            &BTreeMap::new(),
24933            nodes.clone(),
24934            Vec::new(),
24935            Some(3),
24936            Some(&depth_by_id),
24937            Some(&cursor),
24938        );
24939        let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
24940        let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
24941        assert!(
24942            overlap.is_empty(),
24943            "pages should not overlap, but found shared ids: {overlap:?}"
24944        );
24945        assert!(
24946            second_page.report.diagnostics.iter().any(|d| d.contains("cursor skipped")),
24947            "expected cursor skip diagnostic, got {:?}",
24948            second_page.report.diagnostics
24949        );
24950    }
24951
24952    #[test]
24953    fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
24954        let mut nodes = vec![SubstrateGraphNode::new(
24955            "target".to_string(),
24956            "backlog_item",
24957            "target item".to_string(),
24958        )];
24959        let mut depth_by_id = BTreeMap::new();
24960        depth_by_id.insert("target".to_string(), 0);
24961        for idx in 0..3 {
24962            let id = format!("ev-{idx}");
24963            nodes.push(SubstrateGraphNode::new(
24964                id.clone(),
24965                "source_handle",
24966                format!("evidence row {idx}"),
24967            ));
24968            depth_by_id.insert(id, 1);
24969        }
24970        let origin_ids = vec!["target".to_string()];
24971        let result = graph_db_apply_output_budget_with_depths_and_cursor(
24972            &origin_ids,
24973            &BTreeMap::new(),
24974            nodes,
24975            Vec::new(),
24976            None,
24977            Some(&depth_by_id),
24978            None,
24979        );
24980        assert!(
24981            !result.truncated,
24982            "expected no truncation with small candidate set and default budget"
24983        );
24984        assert!(
24985            result.next_cursor.is_none(),
24986            "expected no next_cursor when not truncated"
24987        );
24988    }
24989
24990    #[test]
24991    fn evidence_pagination_invalid_cursor_returns_first_page() {
24992        let mut nodes = vec![SubstrateGraphNode::new(
24993            "target".to_string(),
24994            "backlog_item",
24995            "target item".to_string(),
24996        )];
24997        let mut depth_by_id = BTreeMap::new();
24998        depth_by_id.insert("target".to_string(), 0);
24999        for idx in 0..5 {
25000            let id = format!("ev-{idx}");
25001            nodes.push(SubstrateGraphNode::new(
25002                id.clone(),
25003                "source_handle",
25004                format!("evidence row {idx}"),
25005            ));
25006            depth_by_id.insert(id, 1);
25007        }
25008        let origin_ids = vec!["target".to_string()];
25009        let result = graph_db_apply_output_budget_with_depths_and_cursor(
25010            &origin_ids,
25011            &BTreeMap::new(),
25012            nodes.clone(),
25013            Vec::new(),
25014            None,
25015            Some(&depth_by_id),
25016            Some("nonexistent-id"),
25017        );
25018        assert!(
25019            result.report.diagnostics.iter().any(|d| d.contains("cursor skipped 0")),
25020            "invalid cursor should skip 0 candidates, got {:?}",
25021            result.report.diagnostics
25022        );
25023    }
25024
25025    #[test]
25026    fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
25027        let dir = setup_traversal_project();
25028        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25029        let graph_db = dir.path().join(".tsift/graph.db");
25030        let _lock = hold_rollback_journal_lock(&graph_db);
25031
25032        let report =
25033            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25034                .unwrap();
25035
25036        assert_eq!(report.status, "current");
25037        assert_eq!(
25038            report.recovery,
25039            Some(index::ReadOnlyRecovery::SnapshotFallback)
25040        );
25041        assert!(
25042            report
25043                .warnings
25044                .iter()
25045                .any(|warning| warning.contains("rollback-journal lock")),
25046            "expected rollback-journal recovery warning, got {:?}",
25047            report.warnings
25048        );
25049    }
25050
25051    #[test]
25052    fn graph_db_status_copies_wal_sidecars_when_locked() {
25053        let dir = setup_traversal_project();
25054        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25055        let graph_db = dir.path().join(".tsift/graph.db");
25056        let _lock = hold_wal_database_lock(&graph_db);
25057
25058        let report =
25059            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25060                .unwrap();
25061
25062        assert_eq!(report.status, "current");
25063        assert_eq!(
25064            report.recovery,
25065            Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
25066        );
25067        assert!(
25068            report
25069                .warnings
25070                .iter()
25071                .any(|warning| warning.contains("WAL-aware snapshot fallback")),
25072            "expected WAL recovery warning, got {:?}",
25073            report.warnings
25074        );
25075    }
25076
25077    #[test]
25078    fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
25079        let dir = setup_traversal_project();
25080        let session = dir.path().join("tasks/software/tsift.md");
25081        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25082        let graph_db = dir.path().join(".tsift/graph.db");
25083        let _lock = hold_rollback_journal_lock(&graph_db);
25084
25085        let result = cmd_graph_db(
25086            &session,
25087            None,
25088            GraphDbBackend::Sqlite,
25089            None,
25090            GraphDbQuery::Evidence {
25091                target: "kgnv".to_string(),
25092                depth: 3,
25093                limit: 8,
25094                cursor: None,
25095            },
25096            OutputFormat {
25097                json_output: false,
25098                compact: true,
25099                pretty: false,
25100                terse: false,
25101                ultra_terse: false,
25102                schema: false,
25103                envelope: false,
25104            },
25105        );
25106
25107        assert!(result.is_ok());
25108    }
25109
25110    fn current_graph_db_freshness() -> GraphDbFreshnessReport {
25111        GraphDbFreshnessReport {
25112            status: "current".to_string(),
25113            fail_closed: false,
25114            projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
25115            content_hash: Some("fixture".to_string()),
25116            source_watermark: None,
25117            diagnostics: Vec::new(),
25118        }
25119    }
25120
25121    #[test]
25122    fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
25123        let dir = setup_traversal_project();
25124        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25125        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25126        let stale = GraphDbFreshnessReport {
25127            status: "stale".to_string(),
25128            fail_closed: true,
25129            projection_version: Some("old-v0".to_string()),
25130            content_hash: None,
25131            source_watermark: None,
25132            diagnostics: vec!["projection content hash is missing".to_string()],
25133        };
25134
25135        let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25136            root: dir.path(),
25137            scope: None,
25138            backend: "sqlite",
25139            target: "kgnv",
25140            depth: 3,
25141            limit: 8,
25142            cursor: None,
25143            store: &store,
25144            freshness: stale,
25145            warnings: Vec::new(),
25146        }) {
25147            Ok(_) => panic!("stale graph freshness should fail closed"),
25148            Err(err) => err,
25149        };
25150        let message = err.to_string();
25151        assert!(message.contains("failed closed"), "{message}");
25152        assert!(message.contains("graph-db --path"), "{message}");
25153        assert!(message.contains("refresh --json"), "{message}");
25154    }
25155
25156    fn paged_graph_ids(
25157        store: &impl GraphStore,
25158        cursor: Option<&str>,
25159    ) -> (Vec<String>, GraphDbPageReport) {
25160        let report = graph_db_report_from_store(
25161            Path::new("."),
25162            None,
25163            "fixture",
25164            GraphDbQuery::Kind {
25165                kind: "backlog".to_string(),
25166                cursor: cursor.map(str::to_string),
25167                limit: Some(2),
25168                property_filters: vec!["phase=open".to_string()],
25169            },
25170            store,
25171            current_graph_db_freshness(),
25172            Vec::new(),
25173        )
25174        .unwrap();
25175        (
25176            report.nodes.iter().map(|node| node.id.clone()).collect(),
25177            report.page.unwrap(),
25178        )
25179    }
25180
25181    #[test]
25182    fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
25183        let nodes = (0..5)
25184            .map(|idx| {
25185                let phase = if idx == 1 { "closed" } else { "open" };
25186                SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
25187                    .with_property("phase", phase)
25188            })
25189            .collect::<Vec<_>>();
25190        let projection = GraphProjection {
25191            nodes,
25192            edges: Vec::new(),
25193        };
25194        let sqlite = SqliteGraphStore::in_memory().unwrap();
25195        projection.upsert_into(&sqlite).unwrap();
25196        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25197        projection.upsert_into(&convex).unwrap();
25198
25199        let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
25200        let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
25201        assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
25202        assert_eq!(sqlite_first_ids, convex_first_ids);
25203        assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
25204        assert!(sqlite_first_page.truncated);
25205        assert_eq!(
25206            sqlite_first_page.returned_nodes,
25207            convex_first_page.returned_nodes
25208        );
25209        assert_eq!(
25210            sqlite_first_page.property_filters,
25211            convex_first_page.property_filters
25212        );
25213        assert!(
25214            sqlite_first_page
25215                .diagnostics
25216                .iter()
25217                .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
25218            "expected SQLite kind query plan diagnostics, got {:?}",
25219            sqlite_first_page.diagnostics
25220        );
25221
25222        let cursor = sqlite_first_page.next_cursor.as_deref();
25223        let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
25224        let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
25225        assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
25226        assert_eq!(sqlite_next_ids, convex_next_ids);
25227        assert_eq!(sqlite_next_page.next_cursor, None);
25228        assert!(!sqlite_next_page.truncated);
25229        assert_eq!(
25230            sqlite_next_page.returned_nodes,
25231            convex_next_page.returned_nodes
25232        );
25233        assert_eq!(
25234            sqlite_next_page.property_filters,
25235            convex_next_page.property_filters
25236        );
25237    }
25238
25239    #[test]
25240    fn traversal_shortest_path_crosses_artifacts_and_symbols() {
25241        let dir = setup_traversal_project();
25242        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25243        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25244        let main = resolve_traversal_node(&graph, "main").unwrap();
25245
25246        let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
25247        assert_eq!(path.first(), Some(&backlog.handle));
25248        assert_eq!(path.last(), Some(&main.handle));
25249        assert!(
25250            path.len() >= 3,
25251            "expected backlog -> symbol -> main, got {path:?}"
25252        );
25253    }
25254
25255    #[test]
25256    fn traversal_report_recommends_next_bugfix_nodes() {
25257        let dir = setup_traversal_project();
25258        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25259        let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
25260
25261        assert_eq!(report.mode, "neighborhood");
25262        assert!(
25263            report
25264                .recommendations
25265                .iter()
25266                .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
25267            "expected helper recommendation, got {:?}",
25268            report.recommendations
25269        );
25270        assert!(
25271            !report.exploration.source_windows.is_empty(),
25272            "expected exploration source windows"
25273        );
25274        assert!(
25275            report
25276                .exploration
25277                .no_reread_guidance
25278                .contains("avoid whole-file reads")
25279        );
25280    }
25281
25282    #[test]
25283    fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
25284        let dir = setup_traversal_project();
25285        std::thread::sleep(std::time::Duration::from_millis(50));
25286        std::fs::write(
25287            dir.path().join("main.rs"),
25288            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25289        )
25290        .unwrap();
25291
25292        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25293
25294        assert!(
25295            graph
25296                .warnings
25297                .iter()
25298                .any(|warning| warning.contains("index refreshed")
25299                    && warning.contains("graph traversal packet")),
25300            "expected refresh diagnostic, got {:?}",
25301            graph.warnings
25302        );
25303        assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
25304
25305        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
25306        let summary = db.compute_changes(dir.path()).unwrap();
25307        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
25308    }
25309
25310    #[test]
25311    fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
25312        let dir = setup_traversal_project();
25313        let db_path = dir.path().join(".tsift/index.db");
25314        let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
25315        std::thread::sleep(std::time::Duration::from_millis(50));
25316        std::fs::write(
25317            dir.path().join("main.rs"),
25318            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25319        )
25320        .unwrap();
25321
25322        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25323        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
25324
25325        assert!(
25326            graph
25327                .warnings
25328                .iter()
25329                .any(|warning| warning.contains("falling back to raw source file nodes")),
25330            "expected raw-source fallback diagnostic, got {:?}",
25331            graph.warnings
25332        );
25333        assert!(
25334            file.detail
25335                .as_deref()
25336                .is_some_and(|detail| detail.contains("raw source fallback")),
25337            "expected raw-source detail, got {:?}",
25338            file.detail
25339        );
25340        assert!(
25341            file.expand.contains("source-read"),
25342            "expected source-read fallback command, got {}",
25343            file.expand
25344        );
25345        assert!(
25346            resolve_traversal_node(&graph, "helper").is_none(),
25347            "stale symbol evidence should be skipped when refresh is blocked"
25348        );
25349    }
25350
25351    #[test]
25352    fn traversal_cmd_supports_json_and_html_outputs() {
25353        let dir = setup_traversal_project();
25354        cmd_traverse(
25355            Some("#kgnv"),
25356            Some("main"),
25357            dir.path(),
25358            None,
25359            1,
25360            50,
25361            TraverseFormat::Json,
25362            false,
25363            false,
25364            false,
25365            None,
25366        )
25367        .unwrap();
25368        cmd_traverse(
25369            None,
25370            None,
25371            dir.path(),
25372            None,
25373            1,
25374            50,
25375            TraverseFormat::Html,
25376            false,
25377            false,
25378            false,
25379            None,
25380        )
25381        .unwrap();
25382    }
25383
25384    #[test]
25385    fn traversal_html_renders_inline_graph_visualization() {
25386        let dir = setup_traversal_project();
25387        seed_traversal_semantic_summaries(dir.path());
25388        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25389        let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
25390        let html = traversal_report_html(&report).unwrap();
25391
25392        assert!(html.contains("id=\"graph-canvas\""));
25393        assert!(html.contains("semantic_concept"));
25394        assert!(html.contains("graph navigation"));
25395        assert!(html.contains("JSON.parse"));
25396    }
25397
25398    #[test]
25399    fn compact_helpers_trim_scores_and_snippets() {
25400        assert_eq!(format_score(0.12345, true), "0.12");
25401        assert_eq!(format_score(0.12345, false), "0.1235");
25402        let snippet = compact_snippet("    first line with useful context\nsecond");
25403        assert_eq!(snippet.as_deref(), Some("first line with useful context"));
25404    }
25405
25406    #[test]
25407    fn compact_members_caps_list() {
25408        let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
25409            .iter()
25410            .map(|n| graph::CommunityMember::new(*n))
25411            .collect();
25412        assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
25413    }
25414
25415    #[test]
25416    fn abbreviate_kind_maps_common_kinds() {
25417        assert_eq!(abbreviate_kind("function"), "fn");
25418        assert_eq!(abbreviate_kind("method"), "meth");
25419        assert_eq!(abbreviate_kind("class"), "cls");
25420        assert_eq!(abbreviate_kind("interface"), "iface");
25421        assert_eq!(abbreviate_kind("type_alias"), "type");
25422        assert_eq!(abbreviate_kind("data_class"), "data_cls");
25423        assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
25424        assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
25425        assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
25426        assert_eq!(abbreviate_kind("object"), "obj");
25427        assert_eq!(abbreviate_kind("heading"), "h");
25428        assert_eq!(abbreviate_kind("code_block"), "code");
25429        // short kinds pass through
25430        assert_eq!(abbreviate_kind("struct"), "struct");
25431        assert_eq!(abbreviate_kind("trait"), "trait");
25432        assert_eq!(abbreviate_kind("enum"), "enum");
25433        assert_eq!(abbreviate_kind("const"), "const");
25434        assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
25435    }
25436
25437    #[test]
25438    fn abbreviate_match_type_maps_search_types() {
25439        assert_eq!(abbreviate_match_type("exact_name"), "exact");
25440        assert_eq!(abbreviate_match_type("partial_tags"), "partial");
25441        assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
25442        assert_eq!(abbreviate_match_type("other_type"), "other_type");
25443    }
25444
25445    #[test]
25446    fn explain_compact_groups_edges_by_file() {
25447        let edges = vec![
25448            index::StoredEdge {
25449                caller_file: "src/main.rs".to_string(),
25450                caller_name: "main".to_string(),
25451                caller_line: 1,
25452                callee_name: "helper".to_string(),
25453                call_site_line: 2,
25454                tagpath_handle: None,
25455            },
25456            index::StoredEdge {
25457                caller_file: "src/main.rs".to_string(),
25458                caller_name: "main".to_string(),
25459                caller_line: 1,
25460                callee_name: "render".to_string(),
25461                call_site_line: 3,
25462                tagpath_handle: None,
25463            },
25464        ];
25465        let lines = format_edge_groups(&edges, false);
25466        assert_eq!(lines, vec!["  src/main.rs (2): helper, render"]);
25467    }
25468
25469    #[test]
25470    fn search_hit_groups_preserve_file_counts_and_samples() {
25471        let dir = tempfile::tempdir().unwrap();
25472        let root = dir.path();
25473        let main_rs = root.join("src/main.rs");
25474        fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
25475        fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
25476        let freshness = exact_search_file_timestamp(&main_rs);
25477        let hits = vec![
25478            sift::SearchHit {
25479                artifact_id: "a".to_string(),
25480                artifact_kind: sift::ContextArtifactKind::File,
25481                path: main_rs.display().to_string(),
25482                rank: 1,
25483                score: 10.0,
25484                confidence: sift::ScoreConfidence::High,
25485                location: Some("line 3".to_string()),
25486                snippet: "claudescore-3 anchor".to_string(),
25487                provenance: sift::ArtifactProvenance {
25488                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25489                    source: "ripgrep -F".to_string(),
25490                    synthetic: false,
25491                },
25492                freshness: freshness.clone(),
25493                budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
25494            },
25495            sift::SearchHit {
25496                artifact_id: "b".to_string(),
25497                artifact_kind: sift::ContextArtifactKind::File,
25498                path: main_rs.display().to_string(),
25499                rank: 2,
25500                score: 9.0,
25501                confidence: sift::ScoreConfidence::High,
25502                location: Some("line 7".to_string()),
25503                snippet: "claudescore-3 follow-up".to_string(),
25504                provenance: sift::ArtifactProvenance {
25505                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25506                    source: "ripgrep -F".to_string(),
25507                    synthetic: false,
25508                },
25509                freshness: freshness.clone(),
25510                budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
25511            },
25512            sift::SearchHit {
25513                artifact_id: "c".to_string(),
25514                artifact_kind: sift::ContextArtifactKind::File,
25515                path: main_rs.display().to_string(),
25516                rank: 3,
25517                score: 8.0,
25518                confidence: sift::ScoreConfidence::High,
25519                location: Some("line 9".to_string()),
25520                snippet: "claudescore-3 tail".to_string(),
25521                provenance: sift::ArtifactProvenance {
25522                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25523                    source: "ripgrep -F".to_string(),
25524                    synthetic: false,
25525                },
25526                freshness,
25527                budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
25528            },
25529        ];
25530
25531        let groups = group_search_hits(&hits, root, false);
25532        assert_eq!(groups.len(), 1);
25533        assert_eq!(groups[0].path, "src/main.rs");
25534        assert_eq!(groups[0].hits, 3);
25535        assert_eq!(
25536            groups[0].samples,
25537            vec![
25538                "line 3: claudescore-3 anchor".to_string(),
25539                "line 7: claudescore-3 follow-up".to_string()
25540            ]
25541        );
25542        assert!(should_collapse_search_hits(&hits, root, false));
25543    }
25544
25545    #[test]
25546    fn dense_edge_groups_trigger_collapse() {
25547        let edges = vec![
25548            index::StoredEdge {
25549                caller_file: "src/main.rs".to_string(),
25550                caller_name: "main".to_string(),
25551                caller_line: 1,
25552                callee_name: "helper".to_string(),
25553                call_site_line: 2,
25554                tagpath_handle: None,
25555            },
25556            index::StoredEdge {
25557                caller_file: "src/main.rs".to_string(),
25558                caller_name: "beta".to_string(),
25559                caller_line: 5,
25560                callee_name: "helper".to_string(),
25561                call_site_line: 6,
25562                tagpath_handle: None,
25563            },
25564            index::StoredEdge {
25565                caller_file: "src/main.rs".to_string(),
25566                caller_name: "gamma".to_string(),
25567                caller_line: 9,
25568                callee_name: "helper".to_string(),
25569                call_site_line: 10,
25570                tagpath_handle: None,
25571            },
25572        ];
25573        assert!(should_collapse_edge_groups(&edges));
25574    }
25575
25576    // --- workspace indexing ---
25577
25578    fn setup_workspace() -> tempfile::TempDir {
25579        let dir = tempfile::tempdir().unwrap();
25580        let root = dir.path();
25581        std::fs::write(
25582            root.join(".gitmodules"),
25583            r#"[submodule "src/alpha"]
25584	path = src/alpha
25585	url = https://example.com/alpha
25586[submodule "src/beta"]
25587	path = src/beta
25588	url = https://example.com/beta
25589"#,
25590        )
25591        .unwrap();
25592        let alpha = root.join("src/alpha");
25593        let beta = root.join("src/beta");
25594        std::fs::create_dir_all(&alpha).unwrap();
25595        std::fs::create_dir_all(&beta).unwrap();
25596        std::fs::write(
25597            alpha.join("lib.rs"),
25598            "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
25599        )
25600        .unwrap();
25601        std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
25602        dir
25603    }
25604
25605    fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
25606        let dir = tempfile::tempdir().unwrap();
25607        let root = dir.path();
25608        std::fs::write(
25609            root.join(".gitmodules"),
25610            r#"[submodule "pkg/app/foo"]
25611	path = pkg/app/foo
25612	url = https://example.com/pkg-app-foo
25613[submodule "vendor/foo"]
25614	path = vendor/foo
25615	url = https://example.com/vendor-foo
25616"#,
25617        )
25618        .unwrap();
25619        let pkg_foo = root.join("pkg/app/foo");
25620        let vendor_foo = root.join("vendor/foo");
25621        std::fs::create_dir_all(&pkg_foo).unwrap();
25622        std::fs::create_dir_all(&vendor_foo).unwrap();
25623        std::fs::write(
25624            pkg_foo.join("lib.rs"),
25625            "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
25626        )
25627        .unwrap();
25628        std::fs::write(
25629            vendor_foo.join("lib.rs"),
25630            "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
25631        )
25632        .unwrap();
25633        dir
25634    }
25635
25636    #[test]
25637    fn workspace_index_creates_per_submodule_dbs() {
25638        let dir = setup_workspace();
25639        cmd_index(
25640            dir.path(),
25641            false,
25642            false,
25643            false,
25644            false,
25645            false,
25646            true,
25647            None,
25648            false,
25649            false,
25650            false,
25651            false,
25652            false,
25653            false,
25654        )
25655        .unwrap();
25656        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25657        assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
25658    }
25659
25660    #[test]
25661    fn workspace_index_single_submodule() {
25662        let dir = setup_workspace();
25663        cmd_index(
25664            dir.path(),
25665            false,
25666            false,
25667            false,
25668            false,
25669            false,
25670            false,
25671            Some("alpha"),
25672            false,
25673            false,
25674            false,
25675            false,
25676            false,
25677            false,
25678        )
25679        .unwrap();
25680        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25681        assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
25682    }
25683
25684    #[test]
25685    fn workspace_index_single_submodule_errors_on_unknown_scope() {
25686        let dir = setup_workspace();
25687
25688        let err = cmd_index(
25689            dir.path(),
25690            false,
25691            false,
25692            false,
25693            false,
25694            false,
25695            false,
25696            Some("missing"),
25697            false,
25698            false,
25699            false,
25700            false,
25701            false,
25702            false,
25703        )
25704        .unwrap_err();
25705
25706        let msg = err.to_string();
25707        assert!(msg.contains("unknown scope `missing`"));
25708        assert!(msg.contains("Available scopes: alpha, beta"));
25709        assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
25710    }
25711
25712    #[test]
25713    fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
25714        let dir = setup_workspace_with_duplicate_leaf_names();
25715        cmd_index(
25716            dir.path(),
25717            false,
25718            false,
25719            false,
25720            false,
25721            false,
25722            true,
25723            None,
25724            false,
25725            false,
25726            false,
25727            false,
25728            false,
25729            false,
25730        )
25731        .unwrap();
25732
25733        assert!(
25734            dir.path()
25735                .join(".tsift/indexes/pkg/app/foo/index.db")
25736                .exists()
25737        );
25738        assert!(
25739            dir.path()
25740                .join(".tsift/indexes/vendor/foo/index.db")
25741                .exists()
25742        );
25743    }
25744
25745    #[test]
25746    fn federated_search_across_submodules() {
25747        let dir = setup_workspace();
25748        cmd_index(
25749            dir.path(),
25750            false,
25751            false,
25752            false,
25753            false,
25754            false,
25755            true,
25756            None,
25757            false,
25758            false,
25759            false,
25760            false,
25761            false,
25762            false,
25763        )
25764        .unwrap();
25765        let (hits, _diag) = federated_symbol_search(
25766            dir.path(),
25767            "alpha_helper",
25768            10,
25769            &TagpathSearchOpts {
25770                no_tagpath: true,
25771                strict: false,
25772            },
25773        )
25774        .unwrap();
25775        assert!(
25776            !hits.is_empty(),
25777            "should find alpha_helper via federated search"
25778        );
25779    }
25780
25781    #[test]
25782    fn federated_search_respects_isolation() {
25783        let dir = setup_workspace();
25784        let tsift_dir = dir.path().join(".tsift");
25785        std::fs::create_dir_all(&tsift_dir).unwrap();
25786        std::fs::write(
25787            tsift_dir.join("config.toml"),
25788            r#"
25789[overrides.alpha]
25790tier = "isolated"
25791"#,
25792        )
25793        .unwrap();
25794        cmd_index(
25795            dir.path(),
25796            false,
25797            false,
25798            false,
25799            false,
25800            false,
25801            true,
25802            None,
25803            false,
25804            false,
25805            false,
25806            false,
25807            false,
25808            false,
25809        )
25810        .unwrap();
25811        let (hits, _diag) = federated_symbol_search(
25812            dir.path(),
25813            "alpha_helper",
25814            10,
25815            &TagpathSearchOpts {
25816                no_tagpath: true,
25817                strict: false,
25818            },
25819        )
25820        .unwrap();
25821        assert!(
25822            hits.is_empty(),
25823            "isolated submodule should not appear in federated search"
25824        );
25825    }
25826
25827    #[test]
25828    fn federated_lexical_search_respects_isolation() {
25829        let dir = setup_workspace();
25830        let tsift_dir = dir.path().join(".tsift");
25831        std::fs::create_dir_all(&tsift_dir).unwrap();
25832        std::fs::write(
25833            tsift_dir.join("config.toml"),
25834            r#"
25835[overrides.alpha]
25836tier = "isolated"
25837"#,
25838        )
25839        .unwrap();
25840        cmd_index(
25841            dir.path(),
25842            false,
25843            false,
25844            false,
25845            false,
25846            false,
25847            true,
25848            None,
25849            false,
25850            false,
25851            false,
25852            false,
25853            false,
25854            false,
25855        )
25856        .unwrap();
25857
25858        let response = federated_sift_search(
25859            dir.path(),
25860            &dir.path().join(".tsift/search-cache"),
25861            "fn",
25862            10,
25863            0,
25864            "lexical",
25865        )
25866        .unwrap();
25867
25868        assert!(
25869            !response.hits.is_empty(),
25870            "shared scopes should still contribute lexical hits"
25871        );
25872        assert!(
25873            response
25874                .hits
25875                .iter()
25876                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25877            "isolated scope should not leak lexical hits: {:?}",
25878            response.hits
25879        );
25880    }
25881
25882    #[test]
25883    fn federated_lexical_search_respects_private_tier() {
25884        let dir = setup_workspace();
25885        let tsift_dir = dir.path().join(".tsift");
25886        std::fs::create_dir_all(&tsift_dir).unwrap();
25887        std::fs::write(
25888            tsift_dir.join("config.toml"),
25889            r#"
25890[overrides.alpha]
25891tier = "private"
25892"#,
25893        )
25894        .unwrap();
25895        cmd_index(
25896            dir.path(),
25897            false,
25898            false,
25899            false,
25900            false,
25901            false,
25902            true,
25903            None,
25904            false,
25905            false,
25906            false,
25907            false,
25908            false,
25909            false,
25910        )
25911        .unwrap();
25912
25913        let response = federated_sift_search(
25914            dir.path(),
25915            &dir.path().join(".tsift/search-cache"),
25916            "fn",
25917            10,
25918            0,
25919            "lexical",
25920        )
25921        .unwrap();
25922
25923        assert!(
25924            !response.hits.is_empty(),
25925            "shared scopes should still contribute lexical hits"
25926        );
25927        assert!(
25928            response
25929                .hits
25930                .iter()
25931                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25932            "private scope should not leak lexical hits: {:?}",
25933            response.hits
25934        );
25935    }
25936
25937    #[test]
25938    fn scoped_search_finds_submodule_symbols() {
25939        let dir = setup_workspace();
25940        cmd_index(
25941            dir.path(),
25942            false,
25943            false,
25944            false,
25945            false,
25946            false,
25947            true,
25948            None,
25949            false,
25950            false,
25951            false,
25952            false,
25953            false,
25954            false,
25955        )
25956        .unwrap();
25957        let cfg = config::Config::load(dir.path()).unwrap();
25958        let db_path = cfg.db_path_for(dir.path(), "alpha");
25959        let db = index::IndexDb::open(&db_path).unwrap();
25960        let hits = db.symbol_search("alpha_main", 10).unwrap();
25961        assert!(!hits.is_empty());
25962        assert_eq!(hits[0].name, "alpha_main");
25963    }
25964
25965    #[test]
25966    fn scoped_search_cmd_errors_on_unknown_scope() {
25967        let dir = setup_workspace();
25968
25969        let err = cmd_search(
25970            "alpha_main".to_string(),
25971            Some(dir.path().to_path_buf()),
25972            5,
25973            Some("lexical".to_string()),
25974            Some("missing".to_string()),
25975            false,
25976            false,
25977            false,
25978            0,
25979            false,
25980            false,
25981            false,
25982            false,
25983            false,
25984            false,
25985            false,
25986        )
25987        .unwrap_err();
25988
25989        let msg = err.to_string();
25990        assert!(msg.contains("unknown scope `missing`"));
25991        assert!(msg.contains("Available scopes: alpha, beta"));
25992    }
25993
25994    #[test]
25995    fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
25996        let dir = setup_workspace_with_duplicate_leaf_names();
25997        cmd_index(
25998            dir.path(),
25999            false,
26000            false,
26001            false,
26002            false,
26003            false,
26004            true,
26005            None,
26006            false,
26007            false,
26008            false,
26009            false,
26010            false,
26011            false,
26012        )
26013        .unwrap();
26014
26015        let err = cmd_search(
26016            "vendor_only".to_string(),
26017            Some(dir.path().to_path_buf()),
26018            5,
26019            Some("lexical".to_string()),
26020            Some("foo".to_string()),
26021            false,
26022            false,
26023            false,
26024            0,
26025            false,
26026            false,
26027            false,
26028            false,
26029            false,
26030            false,
26031            false,
26032        )
26033        .unwrap_err();
26034
26035        let msg = err.to_string();
26036        assert!(msg.contains("ambiguous scope `foo`"));
26037        assert!(msg.contains("pkg/app/foo"));
26038        assert!(msg.contains("vendor/foo"));
26039    }
26040
26041    #[test]
26042    fn scoped_graph_query() {
26043        let dir = setup_workspace();
26044        cmd_index(
26045            dir.path(),
26046            false,
26047            false,
26048            false,
26049            false,
26050            false,
26051            true,
26052            None,
26053            false,
26054            false,
26055            false,
26056            false,
26057            false,
26058            false,
26059        )
26060        .unwrap();
26061        let cfg = config::Config::load(dir.path()).unwrap();
26062        let db_path = cfg.db_path_for(dir.path(), "alpha");
26063        let db = index::IndexDb::open(&db_path).unwrap();
26064        let callees = db.callees_of("alpha_main").unwrap();
26065        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
26066        assert!(names.contains(&"alpha_helper"));
26067    }
26068
26069    fn assert_workspace_query_requires_scope(err: anyhow::Error) {
26070        let msg = err.to_string();
26071        assert!(msg.contains("require `--scope <scope>`"), "{msg}");
26072        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26073        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26074        assert!(
26075            !msg.contains("no index found at"),
26076            "workspace query should fail with scope guidance, got: {msg}"
26077        );
26078    }
26079
26080    fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
26081        let msg = err.to_string();
26082        assert!(
26083            msg.contains("requires `--scope <scope>` or `--federated`"),
26084            "{msg}"
26085        );
26086        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26087        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26088        assert!(
26089            !msg.contains("autoindexing index"),
26090            "workspace search should fail before creating a shared root index: {msg}"
26091        );
26092    }
26093
26094    #[test]
26095    fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
26096        let dir = setup_workspace();
26097        cmd_index(
26098            dir.path(),
26099            false,
26100            false,
26101            false,
26102            false,
26103            false,
26104            true,
26105            None,
26106            false,
26107            false,
26108            false,
26109            false,
26110            false,
26111            false,
26112        )
26113        .unwrap();
26114
26115        let err = cmd_graph(
26116            "alpha_main",
26117            dir.path(),
26118            false,
26119            false,
26120            None,
26121            20,
26122            false,
26123            false,
26124            false,
26125            false,
26126            false,
26127            false,
26128            false,
26129            TagpathSearchOpts::default(),
26130        )
26131        .unwrap_err();
26132
26133        assert_workspace_query_requires_scope(err);
26134    }
26135
26136    #[test]
26137    fn graph_cmd_infers_scope_from_nested_workspace_path() {
26138        let dir = setup_workspace();
26139        cmd_index(
26140            dir.path(),
26141            false,
26142            false,
26143            false,
26144            false,
26145            false,
26146            true,
26147            None,
26148            false,
26149            false,
26150            false,
26151            false,
26152            false,
26153            false,
26154        )
26155        .unwrap();
26156        let nested = dir.path().join("src/alpha/nested");
26157        std::fs::create_dir_all(&nested).unwrap();
26158
26159        let result = cmd_graph(
26160            "alpha_main",
26161            &nested,
26162            false,
26163            false,
26164            None,
26165            20,
26166            false,
26167            false,
26168            false,
26169            false,
26170            false,
26171            false,
26172            false,
26173            TagpathSearchOpts::default(),
26174        );
26175
26176        assert!(result.is_ok());
26177    }
26178
26179    #[test]
26180    fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
26181        let dir = setup_workspace();
26182        cmd_index(
26183            dir.path(),
26184            false,
26185            false,
26186            false,
26187            false,
26188            false,
26189            true,
26190            None,
26191            false,
26192            false,
26193            false,
26194            false,
26195            false,
26196            false,
26197        )
26198        .unwrap();
26199
26200        let err = cmd_communities(
26201            dir.path(),
26202            None,
26203            1,
26204            10,
26205            false,
26206            false,
26207            false,
26208            false,
26209            false,
26210            false,
26211            TagpathSearchOpts::default(),
26212        )
26213        .unwrap_err();
26214
26215        assert_workspace_query_requires_scope(err);
26216    }
26217
26218    #[test]
26219    fn communities_cmd_infers_scope_from_nested_workspace_path() {
26220        let dir = setup_workspace();
26221        cmd_index(
26222            dir.path(),
26223            false,
26224            false,
26225            false,
26226            false,
26227            false,
26228            true,
26229            None,
26230            false,
26231            false,
26232            false,
26233            false,
26234            false,
26235            false,
26236        )
26237        .unwrap();
26238        let nested = dir.path().join("src/alpha/nested");
26239        std::fs::create_dir_all(&nested).unwrap();
26240
26241        let result = cmd_communities(
26242            &nested,
26243            None,
26244            1,
26245            10,
26246            false,
26247            false,
26248            false,
26249            false,
26250            false,
26251            false,
26252            TagpathSearchOpts::default(),
26253        );
26254
26255        assert!(result.is_ok());
26256    }
26257
26258    #[test]
26259    fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
26260        let dir = setup_workspace();
26261        cmd_index(
26262            dir.path(),
26263            false,
26264            false,
26265            false,
26266            false,
26267            false,
26268            true,
26269            None,
26270            false,
26271            false,
26272            false,
26273            false,
26274            false,
26275            false,
26276        )
26277        .unwrap();
26278
26279        let err = cmd_path(
26280            "alpha_main",
26281            "alpha_helper",
26282            dir.path(),
26283            None,
26284            false,
26285            false,
26286            false,
26287            false,
26288            false,
26289            TagpathSearchOpts::default(),
26290        )
26291        .unwrap_err();
26292
26293        assert_workspace_query_requires_scope(err);
26294    }
26295
26296    #[test]
26297    fn path_cmd_infers_scope_from_nested_workspace_path() {
26298        let dir = setup_workspace();
26299        cmd_index(
26300            dir.path(),
26301            false,
26302            false,
26303            false,
26304            false,
26305            false,
26306            true,
26307            None,
26308            false,
26309            false,
26310            false,
26311            false,
26312            false,
26313            false,
26314        )
26315        .unwrap();
26316        let nested = dir.path().join("src/alpha/nested");
26317        std::fs::create_dir_all(&nested).unwrap();
26318
26319        let result = cmd_path(
26320            "alpha_main",
26321            "alpha_helper",
26322            &nested,
26323            None,
26324            false,
26325            false,
26326            false,
26327            false,
26328            false,
26329            TagpathSearchOpts::default(),
26330        );
26331
26332        assert!(result.is_ok());
26333    }
26334
26335    #[test]
26336    fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26337        let dir = setup_graph_index();
26338        let db_path = dir.path().join(".tsift/index.db");
26339        let _lock = hold_rollback_journal_lock(&db_path);
26340
26341        let result = cmd_path(
26342            "main",
26343            "helper",
26344            dir.path(),
26345            None,
26346            false,
26347            false,
26348            false,
26349            false,
26350            false,
26351            TagpathSearchOpts::default(),
26352        );
26353
26354        assert!(result.is_ok());
26355    }
26356
26357    #[test]
26358    fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
26359        let dir = setup_workspace();
26360        cmd_index(
26361            dir.path(),
26362            false,
26363            false,
26364            false,
26365            false,
26366            false,
26367            true,
26368            None,
26369            false,
26370            false,
26371            false,
26372            false,
26373            false,
26374            false,
26375        )
26376        .unwrap();
26377
26378        let err = cmd_explain(
26379            "alpha_main",
26380            dir.path(),
26381            None,
26382            15,
26383            false,
26384            false,
26385            false,
26386            false,
26387            false,
26388            false,
26389            false,
26390            false,
26391        )
26392        .unwrap_err();
26393
26394        assert_workspace_query_requires_scope(err);
26395    }
26396
26397    #[test]
26398    fn explain_cmd_infers_scope_from_nested_workspace_path() {
26399        let dir = setup_workspace();
26400        cmd_index(
26401            dir.path(),
26402            false,
26403            false,
26404            false,
26405            false,
26406            false,
26407            true,
26408            None,
26409            false,
26410            false,
26411            false,
26412            false,
26413            false,
26414            false,
26415        )
26416        .unwrap();
26417        let nested = dir.path().join("src/alpha/nested");
26418        std::fs::create_dir_all(&nested).unwrap();
26419
26420        let result = cmd_explain(
26421            "alpha_main",
26422            &nested,
26423            None,
26424            15,
26425            false,
26426            false,
26427            false,
26428            false,
26429            false,
26430            false,
26431            false,
26432            false,
26433        );
26434
26435        assert!(result.is_ok());
26436    }
26437
26438    #[test]
26439    fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26440        let dir = setup_graph_index();
26441        let db_path = dir.path().join(".tsift/index.db");
26442        let _lock = hold_rollback_journal_lock(&db_path);
26443
26444        let result = cmd_explain(
26445            "main",
26446            dir.path(),
26447            None,
26448            15,
26449            false,
26450            false,
26451            false,
26452            false,
26453            false,
26454            false,
26455            false,
26456            false,
26457        );
26458
26459        assert!(result.is_ok());
26460    }
26461
26462    // --- community detection ---
26463
26464    #[test]
26465    fn community_detection_groups_related() {
26466        let dir = setup_graph_index();
26467        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26468        let edges = db.all_edges().unwrap();
26469        let result = graph::detect_communities(&edges);
26470        assert!(result.node_count > 0);
26471        assert!(!result.communities.is_empty());
26472    }
26473
26474    #[test]
26475    fn community_cmd_autoindexes_missing_index_by_default() {
26476        let dir = tempfile::tempdir().unwrap();
26477        let result = cmd_communities(
26478            dir.path(),
26479            None,
26480            2,
26481            10,
26482            false,
26483            false,
26484            false,
26485            false,
26486            false,
26487            false,
26488            TagpathSearchOpts::default(),
26489        );
26490
26491        assert!(result.is_ok());
26492        assert!(dir.path().join(".tsift/index.db").exists());
26493    }
26494
26495    // --- path ---
26496
26497    #[test]
26498    fn path_finds_connected_symbols() {
26499        let dir = setup_graph_index();
26500        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26501        let edges = db.all_edges().unwrap();
26502        let result = graph::shortest_path(&edges, "main", "helper");
26503        assert!(result.is_some());
26504        let path = result.unwrap();
26505        assert_eq!(path.hops, 1);
26506    }
26507
26508    #[test]
26509    fn path_returns_none_for_unknown() {
26510        let dir = setup_graph_index();
26511        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26512        let edges = db.all_edges().unwrap();
26513        assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
26514    }
26515
26516    #[test]
26517    fn path_cmd_autoindexes_missing_index_by_default() {
26518        let dir = tempfile::tempdir().unwrap();
26519        let result = cmd_path(
26520            "a",
26521            "b",
26522            dir.path(),
26523            None,
26524            false,
26525            false,
26526            false,
26527            false,
26528            false,
26529            TagpathSearchOpts::default(),
26530        );
26531
26532        assert!(result.is_ok());
26533        assert!(dir.path().join(".tsift/index.db").exists());
26534    }
26535
26536    // --- explain ---
26537
26538    #[test]
26539    fn explain_shows_symbol_info() {
26540        let dir = setup_graph_index();
26541        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26542        let symbols = db.symbol_info("main").unwrap();
26543        assert!(!symbols.is_empty());
26544        assert_eq!(symbols[0].name, "main");
26545        assert_eq!(symbols[0].kind, "function");
26546    }
26547
26548    #[test]
26549    fn explain_cmd_autoindexes_missing_index_by_default() {
26550        let dir = tempfile::tempdir().unwrap();
26551        let result = cmd_explain(
26552            "main",
26553            dir.path(),
26554            None,
26555            15,
26556            false,
26557            false,
26558            false,
26559            false,
26560            false,
26561            false,
26562            false,
26563            false,
26564        );
26565
26566        assert!(result.is_ok());
26567        assert!(dir.path().join(".tsift/index.db").exists());
26568    }
26569
26570    fn hold_write_lock(db_path: &std::path::Path) -> Connection {
26571        let conn = Connection::open(db_path).unwrap();
26572        conn.execute_batch("BEGIN IMMEDIATE").unwrap();
26573        conn
26574    }
26575
26576    fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
26577        use fs4::fs_std::FileExt;
26578        use std::io::Write;
26579
26580        let mut file = std::fs::OpenOptions::new()
26581            .read(true)
26582            .write(true)
26583            .create(true)
26584            .truncate(false)
26585            .open(lock_path)
26586            .unwrap();
26587        assert!(file.try_lock_exclusive().unwrap());
26588        writeln!(file, "{}", std::process::id()).unwrap();
26589        file
26590    }
26591
26592    fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
26593        let conn = Connection::open(db_path).unwrap();
26594        conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26595            .unwrap();
26596        std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
26597        conn
26598    }
26599
26600    fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
26601        let conn = Connection::open(db_path).unwrap();
26602        conn.execute_batch(
26603            "PRAGMA journal_mode=WAL;
26604             PRAGMA wal_autocheckpoint=0;
26605             CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
26606             INSERT INTO wal_lock_probe DEFAULT VALUES;
26607             PRAGMA locking_mode=EXCLUSIVE;
26608             BEGIN EXCLUSIVE;",
26609        )
26610        .unwrap();
26611        assert!(substrate::wal_sidecar_path(db_path).exists());
26612        conn
26613    }
26614
26615    #[test]
26616    fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
26617        let dir = setup_graph_index();
26618        let db_path = dir.path().join(".tsift/index.db");
26619        let _lock = hold_wal_database_lock(&db_path);
26620
26621        let err = cmd_index(
26622            dir.path(),
26623            false,
26624            false,
26625            false,
26626            false,
26627            false,
26628            false,
26629            None,
26630            false,
26631            false,
26632            false,
26633            false,
26634            false,
26635            false,
26636        )
26637        .unwrap_err();
26638
26639        let msg = err.to_string();
26640        assert!(msg.contains("indexing"));
26641        assert!(msg.contains("lock diagnostics:"));
26642        assert!(msg.contains("lock: absent"));
26643        assert!(msg.contains("wal: present") || msg.contains("shm: present"));
26644        assert!(msg.contains("wedged writer holding live WAL sidecars"));
26645        assert!(msg.contains("snapshot fallback"));
26646    }
26647
26648    #[test]
26649    fn search_cmd_succeeds_while_writer_lock_is_held() {
26650        let dir = setup_graph_index();
26651        let db_path = dir.path().join(".tsift/index.db");
26652        let _lock = hold_write_lock(&db_path);
26653
26654        let result = cmd_search(
26655            "main".to_string(),
26656            Some(dir.path().to_path_buf()),
26657            5,
26658            Some("lexical".to_string()),
26659            None,
26660            false,
26661            false,
26662            false,
26663            0,
26664            true,
26665            false,
26666            false,
26667            false,
26668            false,
26669            false,
26670            false,
26671        );
26672
26673        assert!(result.is_ok());
26674    }
26675
26676    #[test]
26677    fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
26678        let dir = setup_graph_index();
26679        let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
26680
26681        let result = cmd_search(
26682            "main".to_string(),
26683            Some(dir.path().to_path_buf()),
26684            5,
26685            Some("lexical".to_string()),
26686            None,
26687            false,
26688            false,
26689            false,
26690            0,
26691            true,
26692            false,
26693            false,
26694            false,
26695            false,
26696            false,
26697            false,
26698        );
26699
26700        assert!(result.is_ok());
26701    }
26702
26703    #[test]
26704    fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
26705        let dir = setup_graph_index();
26706        let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
26707
26708        let result = cmd_search(
26709            "main".to_string(),
26710            Some(dir.path().to_path_buf()),
26711            5,
26712            Some("lexical".to_string()),
26713            None,
26714            false,
26715            false,
26716            false,
26717            0,
26718            true,
26719            false,
26720            false,
26721            false,
26722            false,
26723            false,
26724            false,
26725        );
26726
26727        assert!(result.is_ok());
26728    }
26729
26730    #[test]
26731    fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
26732        let dir = setup_graph_index();
26733        std::thread::sleep(std::time::Duration::from_millis(50));
26734        std::fs::write(
26735            dir.path().join("main.rs"),
26736            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26737        )
26738        .unwrap();
26739
26740        let err = cmd_search(
26741            "helper".to_string(),
26742            Some(dir.path().to_path_buf()),
26743            5,
26744            Some("lexical".to_string()),
26745            None,
26746            false,
26747            false,
26748            false,
26749            0,
26750            false,
26751            false,
26752            false,
26753            false,
26754            false,
26755            false,
26756            false,
26757        )
26758        .unwrap_err();
26759
26760        assert!(err.to_string().contains("search aborted"));
26761        assert!(err.to_string().contains("index is stale"));
26762        assert!(err.to_string().contains("--no-autoindex"));
26763    }
26764
26765    #[test]
26766    fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
26767        let dir = setup_graph_index();
26768        std::thread::sleep(std::time::Duration::from_millis(50));
26769        std::fs::write(
26770            dir.path().join("main.rs"),
26771            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26772        )
26773        .unwrap();
26774        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26775
26776        let err = cmd_search(
26777            "helper".to_string(),
26778            Some(dir.path().to_path_buf()),
26779            5,
26780            Some("lexical".to_string()),
26781            None,
26782            false,
26783            false,
26784            false,
26785            0,
26786            false,
26787            false,
26788            false,
26789            false,
26790            false,
26791            false,
26792            false,
26793        )
26794        .unwrap_err();
26795
26796        assert!(err.to_string().contains("search aborted"));
26797        assert!(err.to_string().contains("index is stale"));
26798        assert!(!err.to_string().contains("database is locked"));
26799    }
26800
26801    #[test]
26802    fn search_cmd_autoindexes_stale_index_by_default() {
26803        let dir = setup_graph_index();
26804        std::thread::sleep(std::time::Duration::from_millis(50));
26805        std::fs::write(
26806            dir.path().join("main.rs"),
26807            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26808        )
26809        .unwrap();
26810
26811        let result = cmd_search(
26812            "helper".to_string(),
26813            Some(dir.path().to_path_buf()),
26814            5,
26815            Some("lexical".to_string()),
26816            None,
26817            false,
26818            false,
26819            true,
26820            0,
26821            false,
26822            false,
26823            false,
26824            false,
26825            false,
26826            false,
26827            false,
26828        );
26829
26830        assert!(result.is_ok());
26831
26832        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26833        let summary = db.compute_changes(dir.path()).unwrap();
26834        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26835    }
26836
26837    #[test]
26838    fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
26839        let dir = setup_graph_index();
26840        std::thread::sleep(std::time::Duration::from_millis(50));
26841        std::fs::write(
26842            dir.path().join("main.rs"),
26843            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26844        )
26845        .unwrap();
26846        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
26847
26848        let result = cmd_search(
26849            "helper".to_string(),
26850            Some(dir.path().to_path_buf()),
26851            5,
26852            Some("lexical".to_string()),
26853            None,
26854            false,
26855            false,
26856            true,
26857            0,
26858            false,
26859            false,
26860            false,
26861            false,
26862            false,
26863            false,
26864            false,
26865        );
26866
26867        assert!(result.is_ok());
26868
26869        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26870        let summary = db.compute_changes(dir.path()).unwrap();
26871        assert_eq!(summary.modified, 1);
26872    }
26873
26874    #[test]
26875    fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
26876        let dir = setup_graph_index();
26877        std::thread::sleep(std::time::Duration::from_millis(50));
26878        std::fs::write(
26879            dir.path().join("main.rs"),
26880            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26881        )
26882        .unwrap();
26883        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26884
26885        let err = cmd_search(
26886            "helper".to_string(),
26887            Some(dir.path().to_path_buf()),
26888            5,
26889            Some("lexical".to_string()),
26890            None,
26891            false,
26892            false,
26893            true,
26894            0,
26895            false,
26896            false,
26897            false,
26898            false,
26899            false,
26900            false,
26901            false,
26902        )
26903        .unwrap_err();
26904
26905        let msg = err.to_string();
26906        assert!(msg.contains("autoindexing index"));
26907        assert!(msg.contains("lock diagnostics:"));
26908        assert!(msg.contains("journal: present"));
26909        assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
26910    }
26911
26912    #[test]
26913    fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
26914        let dir = setup_graph_index();
26915        let nested = dir.path().join("src/nested");
26916        std::fs::create_dir_all(&nested).unwrap();
26917
26918        let result = cmd_search(
26919            "helper".to_string(),
26920            Some(nested.clone()),
26921            5,
26922            Some("lexical".to_string()),
26923            None,
26924            false,
26925            false,
26926            true,
26927            0,
26928            false,
26929            false,
26930            false,
26931            false,
26932            false,
26933            false,
26934            false,
26935        );
26936
26937        assert!(result.is_ok());
26938        assert!(!nested.join(".tsift/index.db").exists());
26939    }
26940
26941    #[test]
26942    fn exact_search_returns_literal_matches() {
26943        let dir = tempfile::tempdir().unwrap();
26944        std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
26945
26946        let response = run_exact_search_with_timeout(dir.path(), "claudescore-3", 5, 0).unwrap();
26947
26948        assert_eq!(response.strategy, "exact");
26949        assert_eq!(response.hits.len(), 1);
26950        assert!(response.hits[0].path.ends_with("notes.txt"));
26951        assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
26952        assert!(response.hits[0].snippet.contains("claudescore-3"));
26953    }
26954
26955    #[test]
26956    fn exact_search_skips_stale_index_precheck() {
26957        let dir = setup_graph_index();
26958        std::thread::sleep(std::time::Duration::from_millis(50));
26959        std::fs::write(
26960            dir.path().join("main.rs"),
26961            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
26962        )
26963        .unwrap();
26964
26965        let result = cmd_search(
26966            "println!(\"updated\")".to_string(),
26967            Some(dir.path().to_path_buf()),
26968            5,
26969            Some("exact".to_string()),
26970            None,
26971            false,
26972            false,
26973            false,
26974            0,
26975            false,
26976            false,
26977            false,
26978            false,
26979            false,
26980            false,
26981            false,
26982        );
26983
26984        assert!(result.is_ok());
26985    }
26986
26987    #[test]
26988    fn workspace_exact_search_does_not_require_shared_root_index() {
26989        let dir = setup_workspace();
26990        cmd_index(
26991            dir.path(),
26992            false,
26993            false,
26994            false,
26995            false,
26996            false,
26997            true,
26998            None,
26999            false,
27000            false,
27001            false,
27002            false,
27003            false,
27004            false,
27005        )
27006        .unwrap();
27007
27008        let result = cmd_search(
27009            "alpha_helper".to_string(),
27010            Some(dir.path().to_path_buf()),
27011            5,
27012            Some("exact".to_string()),
27013            None,
27014            false,
27015            false,
27016            false,
27017            0,
27018            false,
27019            false,
27020            false,
27021            false,
27022            false,
27023            false,
27024            false,
27025        );
27026
27027        assert!(result.is_ok());
27028        assert!(!dir.path().join(".tsift/index.db").exists());
27029    }
27030
27031    #[test]
27032    fn identifier_like_query_prefers_exact_search() {
27033        assert!(query_prefers_exact_search("claudescore-3"));
27034        assert!(query_prefers_exact_search("alpha_helper"));
27035        assert!(query_prefers_exact_search("src/main.rs"));
27036        assert!(query_prefers_exact_search("crate::module"));
27037        assert!(!query_prefers_exact_search("authenticate"));
27038        assert!(!query_prefers_exact_search("fn main"));
27039        assert!(!query_prefers_exact_search("."));
27040    }
27041
27042    #[test]
27043    fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
27044        assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
27045        assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
27046        assert_eq!(
27047            resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
27048            "hybrid"
27049        );
27050    }
27051
27052    #[test]
27053    fn workspace_identifier_like_search_auto_uses_exact_backend() {
27054        let dir = setup_workspace();
27055        cmd_index(
27056            dir.path(),
27057            false,
27058            false,
27059            false,
27060            false,
27061            false,
27062            true,
27063            None,
27064            false,
27065            false,
27066            false,
27067            false,
27068            false,
27069            false,
27070        )
27071        .unwrap();
27072
27073        let result = cmd_search(
27074            "alpha_helper".to_string(),
27075            Some(dir.path().to_path_buf()),
27076            5,
27077            None,
27078            None,
27079            false,
27080            false,
27081            false,
27082            0,
27083            false,
27084            false,
27085            false,
27086            false,
27087            false,
27088            false,
27089            false,
27090        );
27091
27092        assert!(result.is_ok());
27093        assert!(!dir.path().join(".tsift/index.db").exists());
27094    }
27095
27096    #[test]
27097    fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
27098        let dir = setup_graph_index();
27099        let nested = dir.path().join("src/nested");
27100        std::fs::create_dir_all(&nested).unwrap();
27101        std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
27102
27103        let result = cmd_index(
27104            &nested, false, false, false, false, false, false, None, false, false, false, false,
27105            false, false,
27106        );
27107
27108        assert!(result.is_ok());
27109        assert!(dir.path().join(".tsift/index.db").exists());
27110        assert!(!nested.join(".tsift/index.db").exists());
27111    }
27112
27113    #[test]
27114    fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
27115        let dir = setup_workspace();
27116        let nested = dir.path().join("docs/nested");
27117        std::fs::create_dir_all(&nested).unwrap();
27118
27119        let result = cmd_index(
27120            &nested, false, false, false, false, false, true, None, false, false, false, false,
27121            false, false,
27122        );
27123
27124        let cfg = config::Config::load(dir.path()).unwrap();
27125
27126        assert!(result.is_ok());
27127        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
27128        assert!(cfg.db_path_for(dir.path(), "beta").exists());
27129    }
27130
27131    #[test]
27132    fn status_cmd_autoindexes_missing_workspace_scopes() {
27133        let dir = setup_workspace();
27134        let cfg = config::Config::load(dir.path()).unwrap();
27135        let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
27136        let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
27137        let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
27138        alpha_db.apply_changes(&alpha.source_root).unwrap();
27139
27140        let beta_db_path = cfg.db_path_for(dir.path(), "beta");
27141        assert!(!beta_db_path.exists());
27142
27143        cmd_status(
27144            dir.path(),
27145            StatusCommandOptions {
27146                fix: false,
27147                no_fix: false,
27148                json_output: true,
27149                compact: false,
27150                pretty: false,
27151                terse: false,
27152                schema: false,
27153            },
27154        )
27155        .unwrap();
27156
27157        assert!(beta_db_path.exists());
27158        let report = status::check_status(dir.path()).unwrap();
27159        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27160    }
27161
27162    #[test]
27163    fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
27164        let dir = setup_workspace();
27165        let cfg = config::Config::load(dir.path()).unwrap();
27166
27167        cmd_status(
27168            dir.path(),
27169            StatusCommandOptions {
27170                fix: false,
27171                no_fix: false,
27172                json_output: true,
27173                compact: false,
27174                pretty: false,
27175                terse: false,
27176                schema: false,
27177            },
27178        )
27179        .unwrap();
27180
27181        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
27182        assert!(cfg.db_path_for(dir.path(), "beta").exists());
27183        let report = status::check_status(dir.path()).unwrap();
27184        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27185    }
27186
27187    #[test]
27188    fn status_cmd_fix_refreshes_stale_index() {
27189        let dir = setup_graph_index();
27190        std::thread::sleep(std::time::Duration::from_millis(50));
27191        std::fs::write(
27192            dir.path().join("main.rs"),
27193            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27194        )
27195        .unwrap();
27196
27197        let report = status::check_status(dir.path()).unwrap();
27198        assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
27199
27200        cmd_status(
27201            dir.path(),
27202            StatusCommandOptions {
27203                fix: false,
27204                no_fix: false,
27205                json_output: true,
27206                compact: false,
27207                pretty: false,
27208                terse: false,
27209                schema: false,
27210            },
27211        )
27212        .unwrap();
27213
27214        let report = status::check_status(dir.path()).unwrap();
27215        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27216    }
27217
27218    #[test]
27219    fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
27220        let dir = setup_graph_index();
27221        let db_path = dir.path().join(".tsift/index.db");
27222        let _lock = hold_wal_database_lock(&db_path);
27223
27224        cmd_status(
27225            dir.path(),
27226            StatusCommandOptions {
27227                fix: false,
27228                no_fix: false,
27229                json_output: true,
27230                compact: false,
27231                pretty: false,
27232                terse: false,
27233                schema: false,
27234            },
27235        )
27236        .unwrap();
27237
27238        let report = status::check_status(dir.path()).unwrap();
27239        assert!(matches!(
27240            report.index,
27241            status::IndexStatus::Fresh {
27242                recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
27243                ..
27244            }
27245        ));
27246        let locks = status::check_locks(dir.path(), None, None).unwrap();
27247        assert!(matches!(
27248            locks.writer_lock,
27249            status::WriterLockStatus::Absent { .. }
27250        ));
27251        assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
27252        assert!(
27253            locks
27254                .recommended_action
27255                .contains("wedged writer holding live WAL sidecars")
27256        );
27257    }
27258
27259    #[test]
27260    fn locks_report_uses_ancestor_project_root_for_nested_paths() {
27261        let dir = setup_graph_index();
27262        let nested = dir.path().join("src/nested");
27263        std::fs::create_dir_all(&nested).unwrap();
27264
27265        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27266        let report = status::check_locks(&root, Some(&nested), None).unwrap();
27267
27268        assert_eq!(report.source_root, dir.path());
27269        assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
27270    }
27271
27272    #[test]
27273    fn workspace_locks_report_infers_scope_from_nested_path() {
27274        let dir = setup_workspace();
27275        cmd_index(
27276            dir.path(),
27277            false,
27278            false,
27279            false,
27280            false,
27281            false,
27282            true,
27283            None,
27284            false,
27285            false,
27286            false,
27287            false,
27288            false,
27289            false,
27290        )
27291        .unwrap();
27292        let nested = dir.path().join("src/alpha/nested");
27293        std::fs::create_dir_all(&nested).unwrap();
27294
27295        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27296        let report = status::check_locks(&root, Some(&nested), None).unwrap();
27297        let cfg = config::Config::load(dir.path()).unwrap();
27298
27299        assert_eq!(report.label, "submodule `alpha` index");
27300        assert_eq!(report.source_root, dir.path().join("src/alpha"));
27301        assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
27302        assert_eq!(
27303            report.reindex_command,
27304            format!("tsift index --submodule alpha {}", dir.path().display())
27305        );
27306    }
27307
27308    #[test]
27309    fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
27310        let dir = setup_workspace();
27311        cmd_index(
27312            dir.path(),
27313            false,
27314            false,
27315            false,
27316            false,
27317            false,
27318            true,
27319            None,
27320            false,
27321            false,
27322            false,
27323            false,
27324            false,
27325            false,
27326        )
27327        .unwrap();
27328
27329        let alpha = dir.path().join("src/alpha/lib.rs");
27330        std::thread::sleep(std::time::Duration::from_millis(50));
27331        std::fs::write(
27332            &alpha,
27333            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27334        )
27335        .unwrap();
27336
27337        let result = cmd_search(
27338            "alpha_helper".to_string(),
27339            Some(dir.path().to_path_buf()),
27340            5,
27341            Some("lexical".to_string()),
27342            Some("alpha".to_string()),
27343            false,
27344            false,
27345            true,
27346            0,
27347            false,
27348            false,
27349            false,
27350            false,
27351            false,
27352            false,
27353            false,
27354        );
27355
27356        assert!(result.is_ok());
27357
27358        let cfg = config::Config::load(dir.path()).unwrap();
27359        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27360        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27361        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27362    }
27363
27364    #[test]
27365    fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27366        let dir = setup_workspace();
27367        cmd_index(
27368            dir.path(),
27369            false,
27370            false,
27371            false,
27372            false,
27373            false,
27374            true,
27375            None,
27376            false,
27377            false,
27378            false,
27379            false,
27380            false,
27381            false,
27382        )
27383        .unwrap();
27384
27385        let alpha = dir.path().join("src/alpha/lib.rs");
27386        std::thread::sleep(std::time::Duration::from_millis(50));
27387        std::fs::write(
27388            &alpha,
27389            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27390        )
27391        .unwrap();
27392
27393        let cfg = config::Config::load(dir.path()).unwrap();
27394        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27395
27396        let err = cmd_search(
27397            "alpha_helper".to_string(),
27398            Some(dir.path().to_path_buf()),
27399            5,
27400            Some("lexical".to_string()),
27401            Some("alpha".to_string()),
27402            false,
27403            false,
27404            false,
27405            0,
27406            false,
27407            false,
27408            false,
27409            false,
27410            false,
27411            false,
27412            false,
27413        )
27414        .unwrap_err();
27415
27416        assert!(err.to_string().contains("search aborted"));
27417        assert!(err.to_string().contains("submodule `alpha` index"));
27418        assert!(!err.to_string().contains("database is locked"));
27419    }
27420
27421    #[test]
27422    fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
27423        let dir = setup_workspace();
27424        cmd_index(
27425            dir.path(),
27426            false,
27427            false,
27428            false,
27429            false,
27430            false,
27431            true,
27432            None,
27433            false,
27434            false,
27435            false,
27436            false,
27437            false,
27438            false,
27439        )
27440        .unwrap();
27441
27442        let alpha = dir.path().join("src/alpha/lib.rs");
27443        std::thread::sleep(std::time::Duration::from_millis(50));
27444        std::fs::write(
27445            &alpha,
27446            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27447        )
27448        .unwrap();
27449
27450        let result = cmd_search(
27451            "alpha_helper".to_string(),
27452            Some(dir.path().to_path_buf()),
27453            5,
27454            Some("lexical".to_string()),
27455            None,
27456            true,
27457            false,
27458            true,
27459            0,
27460            false,
27461            false,
27462            false,
27463            false,
27464            false,
27465            false,
27466            false,
27467        );
27468
27469        assert!(result.is_ok());
27470
27471        let cfg = config::Config::load(dir.path()).unwrap();
27472        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27473        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27474        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27475    }
27476
27477    #[test]
27478    fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27479        let dir = setup_workspace();
27480        cmd_index(
27481            dir.path(),
27482            false,
27483            false,
27484            false,
27485            false,
27486            false,
27487            true,
27488            None,
27489            false,
27490            false,
27491            false,
27492            false,
27493            false,
27494            false,
27495        )
27496        .unwrap();
27497
27498        let alpha = dir.path().join("src/alpha/lib.rs");
27499        std::thread::sleep(std::time::Duration::from_millis(50));
27500        std::fs::write(
27501            &alpha,
27502            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27503        )
27504        .unwrap();
27505
27506        let cfg = config::Config::load(dir.path()).unwrap();
27507        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27508
27509        let err = cmd_search(
27510            "alpha_helper".to_string(),
27511            Some(dir.path().to_path_buf()),
27512            5,
27513            Some("lexical".to_string()),
27514            None,
27515            true,
27516            false,
27517            false,
27518            30,
27519            false,
27520            false,
27521            false,
27522            false,
27523            false,
27524            false,
27525            false,
27526        )
27527        .unwrap_err();
27528
27529        assert!(err.to_string().contains("stale"));
27530        assert!(err.to_string().contains("submodule `alpha` index"));
27531        assert!(!err.to_string().contains("database is locked"));
27532    }
27533
27534    #[test]
27535    fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
27536        let dir = setup_workspace();
27537        cmd_index(
27538            dir.path(),
27539            false,
27540            false,
27541            false,
27542            false,
27543            false,
27544            true,
27545            None,
27546            false,
27547            false,
27548            false,
27549            false,
27550            false,
27551            false,
27552        )
27553        .unwrap();
27554
27555        let err = cmd_search(
27556            "alpha_helper".to_string(),
27557            Some(dir.path().to_path_buf()),
27558            5,
27559            Some("lexical".to_string()),
27560            None,
27561            false,
27562            false,
27563            true,
27564            0,
27565            false,
27566            false,
27567            false,
27568            false,
27569            false,
27570            false,
27571            false,
27572        )
27573        .unwrap_err();
27574
27575        assert_workspace_search_requires_explicit_target(err);
27576        assert!(!dir.path().join(".tsift/index.db").exists());
27577    }
27578
27579    #[test]
27580    fn workspace_search_cmd_infers_scope_from_nested_path() {
27581        let dir = setup_workspace();
27582        cmd_index(
27583            dir.path(),
27584            false,
27585            false,
27586            false,
27587            false,
27588            false,
27589            true,
27590            None,
27591            false,
27592            false,
27593            false,
27594            false,
27595            false,
27596            false,
27597        )
27598        .unwrap();
27599        let nested = dir.path().join("src/alpha/nested");
27600        std::fs::create_dir_all(&nested).unwrap();
27601
27602        let result = cmd_search(
27603            "alpha_helper".to_string(),
27604            Some(nested),
27605            5,
27606            Some("lexical".to_string()),
27607            None,
27608            false,
27609            false,
27610            false,
27611            0,
27612            false,
27613            false,
27614            false,
27615            false,
27616            false,
27617            false,
27618            false,
27619        );
27620
27621        assert!(result.is_ok());
27622    }
27623
27624    #[test]
27625    fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
27626        let dir = setup_workspace_with_duplicate_leaf_names();
27627        cmd_index(
27628            dir.path(),
27629            false,
27630            false,
27631            false,
27632            false,
27633            false,
27634            true,
27635            None,
27636            false,
27637            false,
27638            false,
27639            false,
27640            false,
27641            false,
27642        )
27643        .unwrap();
27644        let nested = dir.path().join("vendor/foo/nested");
27645        std::fs::create_dir_all(&nested).unwrap();
27646
27647        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27648        let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
27649        let cfg = config::Config::load(dir.path()).unwrap();
27650
27651        assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
27652    }
27653
27654    #[test]
27655    fn graph_cmd_succeeds_while_writer_lock_is_held() {
27656        let dir = setup_graph_index();
27657        let db_path = dir.path().join(".tsift/index.db");
27658        let _lock = hold_write_lock(&db_path);
27659
27660        let result = cmd_graph(
27661            "main",
27662            dir.path(),
27663            false,
27664            false,
27665            None,
27666            20,
27667            false,
27668            true,
27669            false,
27670            false,
27671            false,
27672            false,
27673            false,
27674            TagpathSearchOpts::default(),
27675        );
27676
27677        assert!(result.is_ok());
27678    }
27679
27680    #[test]
27681    fn graph_cmd_autoindexes_stale_index_by_default() {
27682        let dir = setup_graph_index();
27683        std::thread::sleep(std::time::Duration::from_millis(50));
27684        std::fs::write(
27685            dir.path().join("main.rs"),
27686            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27687        )
27688        .unwrap();
27689
27690        let result = cmd_graph(
27691            "helper",
27692            dir.path(),
27693            true,
27694            false,
27695            None,
27696            20,
27697            false,
27698            true,
27699            false,
27700            false,
27701            false,
27702            false,
27703            false,
27704            TagpathSearchOpts::default(),
27705        );
27706
27707        assert!(result.is_ok());
27708        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27709        let summary = db.compute_changes(dir.path()).unwrap();
27710        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27711    }
27712
27713    #[test]
27714    fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27715        let dir = setup_graph_index();
27716        let db_path = dir.path().join(".tsift/index.db");
27717        let _lock = hold_rollback_journal_lock(&db_path);
27718
27719        let result = cmd_graph(
27720            "main",
27721            dir.path(),
27722            false,
27723            false,
27724            None,
27725            20,
27726            false,
27727            true,
27728            false,
27729            false,
27730            false,
27731            false,
27732            false,
27733            TagpathSearchOpts::default(),
27734        );
27735
27736        assert!(result.is_ok());
27737    }
27738
27739    #[test]
27740    fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
27741        let dir = setup_graph_index();
27742        let nested = dir.path().join("src/nested");
27743        std::fs::create_dir_all(&nested).unwrap();
27744
27745        let result = cmd_graph(
27746            "helper",
27747            &nested,
27748            true,
27749            false,
27750            None,
27751            20,
27752            false,
27753            false,
27754            false,
27755            false,
27756            false,
27757            false,
27758            false,
27759            TagpathSearchOpts::default(),
27760        );
27761
27762        assert!(result.is_ok());
27763    }
27764
27765    #[test]
27766    fn communities_cmd_succeeds_while_writer_lock_is_held() {
27767        let dir = setup_graph_index();
27768        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27769
27770        let result = cmd_communities(
27771            dir.path(),
27772            None,
27773            1,
27774            10,
27775            false,
27776            false,
27777            false,
27778            false,
27779            false,
27780            false,
27781            TagpathSearchOpts::default(),
27782        );
27783
27784        assert!(result.is_ok());
27785    }
27786
27787    #[test]
27788    fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27789        let dir = setup_graph_index();
27790        let db_path = dir.path().join(".tsift/index.db");
27791        let _lock = hold_rollback_journal_lock(&db_path);
27792
27793        let result = cmd_communities(
27794            dir.path(),
27795            None,
27796            1,
27797            10,
27798            false,
27799            false,
27800            false,
27801            false,
27802            false,
27803            false,
27804            TagpathSearchOpts::default(),
27805        );
27806
27807        assert!(result.is_ok());
27808    }
27809
27810    #[test]
27811    fn lint_finds_entities_from_project_root_index_db() {
27812        let dir = tempfile::tempdir().unwrap();
27813        std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
27814        std::fs::write(
27815            dir.path().join("README.md"),
27816            "alpha_helper should be backticked.\n",
27817        )
27818        .unwrap();
27819        cmd_index(
27820            dir.path(),
27821            false,
27822            false,
27823            false,
27824            false,
27825            false,
27826            false,
27827            None,
27828            false,
27829            false,
27830            false,
27831            false,
27832            false,
27833            false,
27834        )
27835        .unwrap();
27836
27837        let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
27838            .unwrap()
27839            .unwrap();
27840        let entities = lint::collect_entities_from_index_path(&root).unwrap();
27841        let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
27842
27843        assert!(
27844            result
27845                .annotations
27846                .iter()
27847                .any(|ann| ann.text == "alpha_helper")
27848        );
27849    }
27850
27851    // --- search timeout ---
27852
27853    #[test]
27854    fn search_direct_runs_ok() {
27855        let dir = tempfile::tempdir().unwrap();
27856        let search_dir = dir.path().to_path_buf();
27857        let cache_dir = search_dir.join(".tsift/search-cache");
27858        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27859        let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical");
27860        assert!(result.is_ok(), "direct search should succeed");
27861        assert!(
27862            cache_dir.exists(),
27863            "search should create the configured cache dir"
27864        );
27865    }
27866
27867    #[test]
27868    fn search_timeout_zero_disables_timeout() {
27869        let dir = tempfile::tempdir().unwrap();
27870        let search_dir = dir.path().to_path_buf();
27871        let cache_dir = search_dir.join(".tsift/search-cache");
27872        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27873        let result = run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[]);
27874        assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
27875        assert!(
27876            cache_dir.exists(),
27877            "timeout=0 should keep using the stable search cache dir"
27878        );
27879    }
27880
27881    #[test]
27882    fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
27883        let dir = tempfile::tempdir().unwrap();
27884        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
27885        cmd_index(
27886            dir.path(),
27887            false,
27888            false,
27889            false,
27890            false,
27891            false,
27892            false,
27893            None,
27894            false,
27895            false,
27896            false,
27897            false,
27898            false,
27899            false,
27900        )
27901        .unwrap();
27902        let db_path = dir.path().join(".tsift/index.db");
27903        std::fs::remove_file(&db_path).unwrap();
27904        let search_target = SearchIndexTarget {
27905            label: "index".to_string(),
27906            db_path,
27907            source_root: dir.path().to_path_buf(),
27908            scope_name: None,
27909            reindex_cmd: format!("tsift index {}", dir.path().display()),
27910        };
27911
27912        let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
27913
27914        assert!(message.contains("timed out after 1s"));
27915        assert!(message.contains("index is missing"));
27916        assert!(message.contains("Run `tsift index"));
27917        assert!(!message.contains("search root looks fresh"));
27918    }
27919
27920    #[test]
27921    fn search_worker_output_path_uses_json_suffix() {
27922        let path = next_search_worker_output_path();
27923        assert!(path.extension().is_some_and(|ext| ext == "json"));
27924    }
27925
27926    // --- index quiet mode ---
27927
27928    #[test]
27929    fn index_quiet_suppresses_file_list() {
27930        let dir = setup_graph_index();
27931        let result = cmd_index(
27932            dir.path(),
27933            false,
27934            true,
27935            false,
27936            false,
27937            true,
27938            false,
27939            None,
27940            false,
27941            false,
27942            false,
27943            false,
27944            false,
27945            false,
27946        );
27947        assert!(result.is_ok());
27948    }
27949
27950    #[test]
27951    fn index_exit_code_implies_quiet() {
27952        let dir = setup_graph_index();
27953        let result = cmd_index(
27954            dir.path(),
27955            false,
27956            true,
27957            false,
27958            false,
27959            false,
27960            false,
27961            None,
27962            false,
27963            false,
27964            false,
27965            false,
27966            false,
27967            false,
27968        );
27969        assert!(result.is_ok());
27970    }
27971
27972    #[test]
27973    fn index_quiet_json_omits_changes() {
27974        let dir = setup_graph_index();
27975        let result = cmd_index(
27976            dir.path(),
27977            false,
27978            true,
27979            false,
27980            false,
27981            true,
27982            false,
27983            None,
27984            true,
27985            false,
27986            false,
27987            false,
27988            false,
27989            false,
27990        );
27991        assert!(result.is_ok());
27992    }
27993
27994    #[test]
27995    fn cli_workflow_defaults_to_search_topic() {
27996        let cli = parse_cli(["tsift", "workflow"]);
27997        match cli.command {
27998            Some(Commands::Workflow { topic, json }) => {
27999                assert_eq!(topic, "search");
28000                assert!(!json);
28001            }
28002            _ => panic!("expected Workflow command"),
28003        }
28004    }
28005
28006    #[test]
28007    fn search_workflow_recipe_preserves_handles_across_expansions() {
28008        let recipe = workflow::search_workflow_recipe();
28009        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
28010        assert_eq!(
28011            step_names,
28012            vec![
28013                "exact-anchor",
28014                "semantic-search",
28015                "explain-symbol",
28016                "summarize-selection",
28017                "digest-expansion"
28018            ]
28019        );
28020        assert!(
28021            recipe
28022                .handle_contract
28023                .iter()
28024                .any(|item| item.contains("originating command"))
28025        );
28026        assert!(
28027            recipe.steps[1]
28028                .preserves
28029                .iter()
28030                .any(|item| item.contains("sfam-*"))
28031        );
28032        assert!(
28033            recipe.steps[2]
28034                .preserves
28035                .iter()
28036                .any(|item| item.contains("ecall-*"))
28037        );
28038        assert!(
28039            recipe.steps[4]
28040                .preserves
28041                .iter()
28042                .any(|item| item.contains("artifact handles"))
28043        );
28044    }
28045
28046    // --- JSON compact vs pretty ---
28047
28048    #[test]
28049    fn to_json_compact_default() {
28050        let val = serde_json::json!({"a": 1, "b": [2, 3]});
28051        let compact = to_json(&val, false, false).unwrap();
28052        assert!(!compact.contains('\n'));
28053        assert!(
28054            compact.contains("\"a\":1")
28055                || compact.contains("\"a\": 1")
28056                || compact.contains("\"a\":")
28057        );
28058    }
28059
28060    #[test]
28061    fn to_json_pretty_indents() {
28062        let val = serde_json::json!({"a": 1, "b": [2, 3]});
28063        let pretty = to_json(&val, true, false).unwrap();
28064        assert!(pretty.contains('\n'));
28065        assert!(pretty.contains("  "));
28066    }
28067
28068    #[test]
28069    fn to_json_compact_is_shorter() {
28070        let val =
28071            serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
28072        let compact = to_json(&val, false, false).unwrap();
28073        let pretty = to_json(&val, true, false).unwrap();
28074        assert!(compact.len() < pretty.len());
28075    }
28076
28077    #[test]
28078    fn terse_renames_keys() {
28079        let val =
28080            serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
28081        let result = to_json(&val, false, true).unwrap();
28082        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28083        assert!(parsed["_s"].is_object());
28084        let d = &parsed["d"];
28085        assert_eq!(d["cf"], "a.rs");
28086        assert_eq!(d["cn"], "main");
28087        assert_eq!(d["csl"], 10);
28088    }
28089
28090    #[test]
28091    fn terse_schema_only_includes_used_keys() {
28092        let val = serde_json::json!({"name": "test", "score": 0.5});
28093        let result = to_json(&val, false, true).unwrap();
28094        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28095        let schema = parsed["_s"].as_object().unwrap();
28096        assert_eq!(schema["n"], "name");
28097        assert_eq!(schema["sc"], "score");
28098        assert!(!schema.contains_key("cf"));
28099    }
28100
28101    #[test]
28102    fn terse_nested_arrays() {
28103        let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
28104        let result = to_json(&val, false, true).unwrap();
28105        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28106        let d = &parsed["d"];
28107        assert_eq!(d["crs"][0]["cn"], "a");
28108        assert_eq!(d["crs"][0]["cf"], "b.rs");
28109    }
28110
28111    #[test]
28112    fn terse_preserves_unknown_keys() {
28113        let val = serde_json::json!({"custom_field": "value", "name": "test"});
28114        let result = to_json(&val, false, true).unwrap();
28115        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28116        let d = &parsed["d"];
28117        assert_eq!(d["custom_field"], "value");
28118        assert_eq!(d["n"], "test");
28119    }
28120
28121    // --- ultra-terse ---
28122
28123    #[test]
28124    fn ultra_terse_strips_properties_from_graph_nodes() {
28125        let val = serde_json::json!({
28126            "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
28127        });
28128        let result = to_json_schema(&val, false, true, true, false).unwrap();
28129        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28130        let node = &parsed["d"]["nodes"][0];
28131        assert_eq!(node["id"], "fn:main");
28132        assert_eq!(node["k"], "fn");
28133        assert_eq!(node["n"], "main");
28134        assert!(node.get("properties").is_none());
28135    }
28136
28137    #[test]
28138    fn ultra_terse_strips_properties_from_graph_edges() {
28139        let val = serde_json::json!({
28140            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
28141        });
28142        let result = to_json_schema(&val, false, true, true, false).unwrap();
28143        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28144        let edge = &parsed["d"]["edges"][0];
28145        assert_eq!(edge["from_id"], "a");
28146        assert_eq!(edge["to_id"], "b");
28147        assert_eq!(edge["k"], "c");
28148        assert!(edge.get("properties").is_none());
28149    }
28150
28151    #[test]
28152    fn ultra_terse_abbreviates_edge_kinds() {
28153        let val = serde_json::json!({
28154            "edges": [
28155                {"from_id": "a", "to_id": "b", "kind": "defines"},
28156                {"from_id": "a", "to_id": "c", "kind": "contains"},
28157                {"from_id": "a", "to_id": "d", "kind": "imports"},
28158                {"from_id": "a", "to_id": "e", "kind": "mentions"},
28159                {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
28160                {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
28161                {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
28162                {"from_id": "a", "to_id": "i", "kind": "uses"},
28163                {"from_id": "a", "to_id": "j", "kind": "parent"},
28164                {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
28165            ]
28166        });
28167        let result = to_json_schema(&val, false, true, true, false).unwrap();
28168        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28169        let edges = &parsed["d"]["edges"].as_array().unwrap();
28170        assert_eq!(edges[0]["k"], "d");
28171        assert_eq!(edges[1]["k"], "ct");
28172        assert_eq!(edges[2]["k"], "i");
28173        assert_eq!(edges[3]["k"], "m");
28174        assert_eq!(edges[4]["k"], "sr");
28175        assert_eq!(edges[5]["k"], "bt");
28176        assert_eq!(edges[6]["k"], "sctx");
28177        assert_eq!(edges[7]["k"], "u");
28178        assert_eq!(edges[8]["k"], "p");
28179        assert_eq!(edges[9]["k"], "unknown_edge");
28180    }
28181
28182    #[test]
28183    fn ultra_terse_strips_provenance_freshness_from_edges() {
28184        let val = serde_json::json!({
28185            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
28186        });
28187        let result = to_json_schema(&val, false, true, true, false).unwrap();
28188        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28189        let edge = &parsed["d"]["edges"][0];
28190        assert!(edge.get("provenance").is_none());
28191        assert!(edge.get("freshness").is_none());
28192        assert_eq!(edge["k"], "c");
28193    }
28194
28195    #[test]
28196    fn ultra_terse_truncates_snippets() {
28197        let long_snippet = "x".repeat(120);
28198        let val = serde_json::json!({"snippet": long_snippet});
28199        let result = to_json_schema(&val, false, true, true, false).unwrap();
28200        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28201        let snipped = parsed["d"]["sn"].as_str().unwrap();
28202        assert_eq!(snipped.len(), 80);
28203        assert!(snipped.ends_with("..."));
28204    }
28205
28206    #[test]
28207    fn ultra_terse_truncates_abbreviated_snippet_key() {
28208        let long_snippet = "y".repeat(100);
28209        let val = serde_json::json!({"snippet": long_snippet});
28210        let result = to_json_schema(&val, false, true, true, false).unwrap();
28211        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28212        let snipped = parsed["d"]["sn"].as_str().unwrap();
28213        assert_eq!(snipped.len(), 80);
28214        assert!(snipped.ends_with("..."));
28215    }
28216
28217    #[test]
28218    fn ultra_terse_compacts_coverage_snapshot() {
28219        let val = serde_json::json!({
28220            "mode": "incremental",
28221            "total_sector_count": 10,
28222            "dirty_sector_count": 2,
28223            "active_rebuild": Some("rebuild-1"),
28224            "completed_dirty_sector_count": 1,
28225            "mounted_sector_count": 8,
28226            "rebuilding_sector_count": 1,
28227            "resumed_sector_count": 3,
28228            "reused_sector_count": 5
28229        });
28230        let result = to_json_schema(&val, false, true, true, false).unwrap();
28231        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28232        let d = &parsed["d"];
28233        assert_eq!(d["mode"], "incremental");
28234        assert_eq!(d["total_sector_count"], 10);
28235        assert_eq!(d["dirty_sector_count"], 2);
28236        assert!(d.get("active_rebuild").is_none());
28237        assert!(d.get("completed_dirty_sector_count").is_none());
28238        assert!(d.get("mounted_sector_count").is_none());
28239        assert!(d.get("rebuilding_sector_count").is_none());
28240        assert!(d.get("resumed_sector_count").is_none());
28241        assert!(d.get("reused_sector_count").is_none());
28242    }
28243
28244    #[test]
28245    fn ultra_terse_short_snippet_unchanged() {
28246        let val = serde_json::json!({"snippet": "short text"});
28247        let result = to_json_schema(&val, false, true, true, false).unwrap();
28248        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28249        assert_eq!(parsed["d"]["sn"], "short text");
28250    }
28251
28252    #[test]
28253    fn ultra_terse_non_graph_object_properties_preserved() {
28254        let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
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!(parsed["d"]["config"]["properties"].is_object());
28258    }
28259
28260    // --- schema-then-values ---
28261
28262    #[test]
28263    fn schema_converts_homogeneous_arrays() {
28264        let val = serde_json::json!({"symbols": [
28265            {"name": "foo", "kind": "fn", "line": 10},
28266            {"name": "bar", "kind": "fn", "line": 20}
28267        ]});
28268        let result = to_json_schema(&val, false, false, false, true).unwrap();
28269        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28270        let syms = &parsed["symbols"];
28271        let columns = syms["_c"]
28272            .as_array()
28273            .unwrap()
28274            .iter()
28275            .map(|value| value.as_str().unwrap())
28276            .collect::<Vec<_>>();
28277        let row0 = syms["_r"][0].as_array().unwrap();
28278        let row1 = syms["_r"][1].as_array().unwrap();
28279        let name_index = columns.iter().position(|column| *column == "name").unwrap();
28280        let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
28281        let line_index = columns.iter().position(|column| *column == "line").unwrap();
28282        assert_eq!(row0[name_index], "foo");
28283        assert_eq!(row0[kind_index], "fn");
28284        assert_eq!(row0[line_index], 10);
28285        assert_eq!(row1[name_index], "bar");
28286        assert_eq!(row1[kind_index], "fn");
28287        assert_eq!(row1[line_index], 20);
28288    }
28289
28290    #[test]
28291    fn schema_skips_short_arrays() {
28292        let val = serde_json::json!({"items": [{"name": "only"}]});
28293        let result = to_json_schema(&val, false, false, false, true).unwrap();
28294        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28295        assert!(parsed["items"].is_array());
28296        assert_eq!(parsed["items"][0]["name"], "only");
28297    }
28298
28299    #[test]
28300    fn schema_skips_heterogeneous_arrays() {
28301        let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
28302        let result = to_json_schema(&val, false, false, false, true).unwrap();
28303        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28304        assert!(parsed["items"].is_array());
28305        assert_eq!(parsed["items"][0]["a"], 1);
28306    }
28307
28308    #[test]
28309    fn schema_with_terse_combines() {
28310        let val = serde_json::json!({"callers": [
28311            {"caller_name": "a", "caller_file": "x.rs"},
28312            {"caller_name": "b", "caller_file": "y.rs"}
28313        ]});
28314        let result = to_json_schema(&val, false, true, false, true).unwrap();
28315        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28316        assert!(parsed["_s"].is_object());
28317        let d = &parsed["d"];
28318        let crs = &d["crs"];
28319        assert!(crs["_c"].is_array());
28320        assert!(crs["_r"].is_array());
28321        let columns = crs["_c"]
28322            .as_array()
28323            .unwrap()
28324            .iter()
28325            .map(|value| value.as_str().unwrap())
28326            .collect::<Vec<_>>();
28327        let row = crs["_r"][0].as_array().unwrap();
28328        let name_index = columns.iter().position(|column| *column == "cn").unwrap();
28329        let file_index = columns.iter().position(|column| *column == "cf").unwrap();
28330        assert_eq!(row[name_index], "a");
28331        assert_eq!(row[file_index], "x.rs");
28332    }
28333
28334    #[test]
28335    fn schema_preserves_non_object_arrays() {
28336        let val = serde_json::json!({"tags": ["a", "b", "c"]});
28337        let result = to_json_schema(&val, false, false, false, true).unwrap();
28338        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28339        assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
28340    }
28341
28342    #[test]
28343    fn cli_accepts_global_schema_flag() {
28344        let cli = parse_cli(["tsift", "--schema", "search", "test"]);
28345        assert!(cli.schema);
28346        assert!(matches!(cli.command, Some(Commands::Search { .. })));
28347    }
28348
28349    #[test]
28350    fn cli_accepts_global_envelope_flag() {
28351        let cli = parse_cli([
28352            "tsift",
28353            "--envelope",
28354            "context-pack",
28355            "tasks/software/tsift.md",
28356        ]);
28357        assert!(cli.envelope);
28358        assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
28359    }
28360
28361    #[test]
28362    fn cli_accepts_locks_command() {
28363        let cli = parse_cli(["tsift", "locks"]);
28364        assert!(matches!(cli.command, Some(Commands::Locks { .. })));
28365    }
28366
28367    #[test]
28368    fn cli_parses_memory_budget_guard_command() {
28369        let cli = parse_cli([
28370            "tsift",
28371            "memory",
28372            "budget-guard",
28373            "--file",
28374            "tool.log",
28375            "--budget-tokens",
28376            "1000",
28377            "--json",
28378        ]);
28379        match cli.command {
28380            Some(Commands::Memory {
28381                command:
28382                    crate::cli::MemoryCommand::BudgetGuard {
28383                        file,
28384                        budget_tokens,
28385                        json,
28386                        ..
28387                    },
28388            }) => {
28389                assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
28390                assert_eq!(budget_tokens, 1000);
28391                assert!(json);
28392            }
28393            _ => panic!("expected memory budget-guard command"),
28394        }
28395    }
28396
28397    #[test]
28398    fn cli_parses_memory_capture_agent_doc_closeout_command() {
28399        let cli = parse_cli([
28400            "tsift",
28401            "memory",
28402            "capture-agent-doc-closeout",
28403            ".",
28404            "--session-path",
28405            "tasks/software/tsift.md",
28406            "--prompt-target",
28407            "do [#tsiftmemhooks]",
28408            "--response-summary",
28409            "wired closeout capture",
28410            "--commit-hash",
28411            "abc123",
28412            "--session-check-status",
28413            "clean",
28414            "--json",
28415        ]);
28416        match cli.command {
28417            Some(Commands::Memory {
28418                command:
28419                    crate::cli::MemoryCommand::CaptureAgentDocCloseout {
28420                        path,
28421                        session_path,
28422                        prompt_target,
28423                        response_summary,
28424                        commit_hash,
28425                        session_check_status,
28426                        json,
28427                    },
28428            }) => {
28429                assert_eq!(path, std::path::PathBuf::from("."));
28430                assert_eq!(
28431                    session_path,
28432                    std::path::PathBuf::from("tasks/software/tsift.md")
28433                );
28434                assert_eq!(prompt_target, "do [#tsiftmemhooks]");
28435                assert_eq!(response_summary, "wired closeout capture");
28436                assert_eq!(commit_hash.as_deref(), Some("abc123"));
28437                assert_eq!(session_check_status, "clean");
28438                assert!(json);
28439            }
28440            _ => panic!("expected memory capture-agent-doc-closeout command"),
28441        }
28442    }
28443
28444    #[test]
28445    fn cli_locks_accepts_scope_flag() {
28446        let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
28447        match cli.command {
28448            Some(Commands::Locks { scope, .. }) => {
28449                assert_eq!(scope.as_deref(), Some("alpha"));
28450            }
28451            _ => panic!("expected Locks command"),
28452        }
28453    }
28454
28455    #[test]
28456    fn cli_search_accepts_autoindex_flag() {
28457        let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
28458        match cli.command {
28459            Some(Commands::Search {
28460                autoindex,
28461                no_autoindex,
28462                ..
28463            }) => {
28464                assert!(autoindex);
28465                assert!(!no_autoindex);
28466            }
28467            _ => panic!("expected Search command"),
28468        }
28469    }
28470
28471    #[test]
28472    fn cli_search_accepts_exact_flag() {
28473        let cli = parse_cli(["tsift", "search", "test", "--exact"]);
28474        match cli.command {
28475            Some(Commands::Search {
28476                exact, strategy, ..
28477            }) => {
28478                assert!(exact);
28479                assert!(strategy.is_none());
28480            }
28481            _ => panic!("expected Search command"),
28482        }
28483    }
28484
28485    #[test]
28486    fn cli_parses_diff_digest_command() {
28487        let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
28488        match cli.command {
28489            Some(Commands::DiffDigest {
28490                json,
28491                path,
28492                cached,
28493                revision,
28494                max_parsed_files,
28495            }) => {
28496                assert!(json);
28497                assert_eq!(path, PathBuf::from("."));
28498                assert!(!cached);
28499                assert!(revision.is_none());
28500                assert_eq!(max_parsed_files, 25);
28501            }
28502            _ => panic!("expected DiffDigest command"),
28503        }
28504    }
28505
28506    #[test]
28507    fn cli_rejects_conflicting_diff_digest_modes() {
28508        match try_parse_cli([
28509            "tsift",
28510            "diff-digest",
28511            "--cached",
28512            "--revision",
28513            "HEAD",
28514            ".",
28515        ]) {
28516            Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
28517            Err(err) => {
28518                assert!(err.to_string().contains("--cached"));
28519                assert!(err.to_string().contains("--revision"));
28520            }
28521        }
28522    }
28523
28524    #[test]
28525    fn cli_parses_test_digest_command() {
28526        let cli = parse_cli([
28527            "tsift",
28528            "test-digest",
28529            "--path",
28530            ".",
28531            "--input",
28532            "target/test.log",
28533            "--runner",
28534            "cargo",
28535            "--json",
28536        ]);
28537        match cli.command {
28538            Some(Commands::TestDigest {
28539                json,
28540                path,
28541                input,
28542                runner,
28543            }) => {
28544                assert!(json);
28545                assert_eq!(path, PathBuf::from("."));
28546                assert_eq!(input, Some(PathBuf::from("target/test.log")));
28547                assert_eq!(runner.as_deref(), Some("cargo"));
28548            }
28549            _ => panic!("expected TestDigest command"),
28550        }
28551    }
28552
28553    #[test]
28554    fn cli_parses_log_digest_command() {
28555        let cli = parse_cli([
28556            "tsift",
28557            "log-digest",
28558            "--path",
28559            ".",
28560            "--input",
28561            "target/build.log",
28562            "--json",
28563        ]);
28564        match cli.command {
28565            Some(Commands::LogDigest { json, path, input }) => {
28566                assert!(json);
28567                assert_eq!(path, PathBuf::from("."));
28568                assert_eq!(input, Some(PathBuf::from("target/build.log")));
28569            }
28570            _ => panic!("expected LogDigest command"),
28571        }
28572    }
28573
28574    #[test]
28575    fn cli_parses_metric_digest_command() {
28576        let cli = parse_cli([
28577            "tsift",
28578            "metric-digest",
28579            "--input",
28580            "target/runs.json",
28581            "--baseline",
28582            "target/prior.json",
28583            "--metric",
28584            "session_mae",
28585            "--lower-is-better",
28586            "session_mae",
28587            "--history",
28588            "4",
28589            "--top",
28590            "2",
28591            "--json",
28592        ]);
28593        match cli.command {
28594            Some(Commands::MetricDigest {
28595                input,
28596                baseline,
28597                metrics,
28598                lower_is_better,
28599                history,
28600                top,
28601                json,
28602                ..
28603            }) => {
28604                assert!(json);
28605                assert_eq!(input, Some(PathBuf::from("target/runs.json")));
28606                assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
28607                assert_eq!(metrics, vec!["session_mae"]);
28608                assert_eq!(lower_is_better, vec!["session_mae"]);
28609                assert_eq!(history, 4);
28610                assert_eq!(top, 2);
28611            }
28612            _ => panic!("expected MetricDigest command"),
28613        }
28614    }
28615
28616    #[test]
28617    fn cli_parses_dci_benchmark_command() {
28618        let cli = parse_cli([
28619            "tsift",
28620            "dci-benchmark",
28621            "--fixture",
28622            "fixtures/dci-search-benchmark.json",
28623            "--json",
28624        ]);
28625        match cli.command {
28626            Some(Commands::DciBenchmark { fixture, json }) => {
28627                assert!(json);
28628                assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
28629            }
28630            _ => panic!("expected DciBenchmark command"),
28631        }
28632    }
28633
28634    #[test]
28635    fn cli_parses_session_digest_command() {
28636        let cli = parse_cli([
28637            "tsift",
28638            "session-digest",
28639            "--path",
28640            ".",
28641            "--input",
28642            "target/session.md",
28643            "--source",
28644            "markdown",
28645            "--json",
28646        ]);
28647        match cli.command {
28648            Some(Commands::SessionDigest {
28649                json,
28650                path,
28651                input,
28652                source,
28653            }) => {
28654                assert!(json);
28655                assert_eq!(path, PathBuf::from("."));
28656                assert_eq!(input, Some(PathBuf::from("target/session.md")));
28657                assert_eq!(source.as_deref(), Some("markdown"));
28658            }
28659            _ => panic!("expected SessionDigest command"),
28660        }
28661    }
28662
28663    #[test]
28664    fn cli_parses_session_cost_command() {
28665        let cli = parse_cli([
28666            "tsift",
28667            "session-cost",
28668            "--input",
28669            "target/session.jsonl",
28670            "--source",
28671            "codex-jsonl",
28672            "--json",
28673        ]);
28674        match cli.command {
28675            Some(Commands::SessionCost {
28676                json,
28677                input,
28678                source,
28679            }) => {
28680                assert!(json);
28681                assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
28682                assert_eq!(source.as_deref(), Some("codex-jsonl"));
28683            }
28684            _ => panic!("expected SessionCost command"),
28685        }
28686    }
28687
28688    #[test]
28689    fn cli_parses_session_review_command() {
28690        let cli = parse_cli([
28691            "tsift",
28692            "session-review",
28693            "tasks/software/tsift.md",
28694            "--next-context",
28695            "--json",
28696        ]);
28697        match cli.command {
28698            Some(Commands::SessionReview {
28699                json,
28700                next_context,
28701                path,
28702                ..
28703            }) => {
28704                assert!(json);
28705                assert!(next_context);
28706                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28707            }
28708            _ => panic!("expected SessionReview command"),
28709        }
28710    }
28711
28712    #[test]
28713    fn cli_search_accepts_budget_flags() {
28714        let cli = parse_cli([
28715            "tsift",
28716            "search",
28717            "alpha_helper",
28718            "--max-items",
28719            "3",
28720            "--max-bytes",
28721            "96",
28722        ]);
28723        match cli.command {
28724            Some(Commands::Search {
28725                max_items,
28726                max_bytes,
28727                ..
28728            }) => {
28729                assert_eq!(max_items, Some(3));
28730                assert_eq!(max_bytes, Some(96));
28731            }
28732            _ => panic!("expected Search command"),
28733        }
28734    }
28735
28736    #[test]
28737    fn cli_search_accepts_budget_preset() {
28738        let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
28739        match cli.command {
28740            Some(Commands::Search { budget, .. }) => {
28741                assert_eq!(budget, Some(ResponseBudgetPreset::Small));
28742            }
28743            _ => panic!("expected Search command"),
28744        }
28745    }
28746
28747    #[test]
28748    fn cli_search_accepts_ast_facet_filters() {
28749        let cli = parse_cli([
28750            "tsift",
28751            "search",
28752            "setup",
28753            "--lang",
28754            "markdown",
28755            "--kind",
28756            "list_item",
28757            "--node-kind",
28758            "list_item",
28759            "--section",
28760            "Install",
28761            "--parent",
28762            "Run setup.",
28763            "--child",
28764            "Confirm setup.",
28765            "--fence-language",
28766            "rust",
28767            "--list-depth",
28768            "1",
28769            "--heading-level",
28770            "2",
28771        ]);
28772        match cli.command {
28773            Some(Commands::Search {
28774                lang,
28775                kind,
28776                node_kind,
28777                section,
28778                parent,
28779                child,
28780                fence_language,
28781                list_depth,
28782                heading_level,
28783                ..
28784            }) => {
28785                assert_eq!(lang, vec!["markdown"]);
28786                assert_eq!(kind, vec!["list_item"]);
28787                assert_eq!(node_kind, vec!["list_item"]);
28788                assert_eq!(section, vec!["Install"]);
28789                assert_eq!(parent, vec!["Run setup."]);
28790                assert_eq!(child, vec!["Confirm setup."]);
28791                assert_eq!(fence_language, vec!["rust"]);
28792                assert_eq!(list_depth, vec![1]);
28793                assert_eq!(heading_level, vec![2]);
28794            }
28795            _ => panic!("expected Search command"),
28796        }
28797    }
28798
28799    #[test]
28800    fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
28801        let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
28802        assert_eq!(small.preview_items(), 3);
28803        assert_eq!(small.preview_bytes(), 120);
28804        assert_eq!(small.follow_up_items(), 4);
28805
28806        let overridden =
28807            ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
28808        assert_eq!(overridden.preview_items(), 7);
28809        assert_eq!(overridden.preview_bytes(), 120);
28810        assert_eq!(overridden.follow_up_items(), 7);
28811
28812        let envelope_default = ResponseBudget::from_cli(None, None, None, true);
28813        assert!(envelope_default.is_active());
28814    }
28815
28816    #[test]
28817    fn cli_explain_accepts_budget_flags() {
28818        let cli = parse_cli([
28819            "tsift",
28820            "explain",
28821            "alpha_helper",
28822            "--max-items",
28823            "2",
28824            "--max-bytes",
28825            "80",
28826        ]);
28827        match cli.command {
28828            Some(Commands::Explain {
28829                max_items,
28830                max_bytes,
28831                ..
28832            }) => {
28833                assert_eq!(max_items, Some(2));
28834                assert_eq!(max_bytes, Some(80));
28835            }
28836            _ => panic!("expected Explain command"),
28837        }
28838    }
28839
28840    #[test]
28841    fn cli_session_review_accepts_budget_flags() {
28842        let cli = parse_cli([
28843            "tsift",
28844            "session-review",
28845            "tasks/software/tsift.md",
28846            "--max-items",
28847            "4",
28848            "--max-bytes",
28849            "120",
28850        ]);
28851        match cli.command {
28852            Some(Commands::SessionReview {
28853                max_items,
28854                max_bytes,
28855                ..
28856            }) => {
28857                assert_eq!(max_items, Some(4));
28858                assert_eq!(max_bytes, Some(120));
28859            }
28860            _ => panic!("expected SessionReview command"),
28861        }
28862    }
28863
28864    #[test]
28865    fn cli_parses_context_pack_command() {
28866        let cli = parse_cli([
28867            "tsift",
28868            "context-pack",
28869            "tasks/software/tsift.md",
28870            "--test-input",
28871            "target/test.log",
28872            "--runner",
28873            "cargo",
28874            "--log-input",
28875            "target/build.log",
28876            "--max-items",
28877            "3",
28878            "--max-bytes",
28879            "96",
28880            "--json",
28881        ]);
28882        match cli.command {
28883            Some(Commands::ContextPack {
28884                path,
28885                test_input,
28886                runner,
28887                log_input,
28888                json,
28889                max_items,
28890                max_bytes,
28891                budget,
28892                convex_snapshot,
28893            }) => {
28894                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28895                assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
28896                assert_eq!(runner.as_deref(), Some("cargo"));
28897                assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
28898                assert!(json);
28899                assert_eq!(max_items, Some(3));
28900                assert_eq!(max_bytes, Some(96));
28901                assert!(budget.is_none());
28902                assert!(convex_snapshot.is_none());
28903            }
28904            _ => panic!("expected ContextPack command"),
28905        }
28906    }
28907
28908    #[test]
28909    fn cli_parses_token_savings_command() {
28910        let cli = parse_cli([
28911            "tsift",
28912            "token-savings",
28913            "--fixture",
28914            "fixtures/tsift-token-savings.json",
28915            "--fail-under",
28916            "--json",
28917        ]);
28918        match cli.command {
28919            Some(Commands::TokenSavings {
28920                fixture,
28921                fail_under,
28922                json,
28923            }) => {
28924                assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
28925                assert!(fail_under);
28926                assert!(json);
28927            }
28928            _ => panic!("expected TokenSavings command"),
28929        }
28930    }
28931
28932    #[test]
28933    fn token_savings_report_records_fixture_thresholds() {
28934        let raw_symbols = [
28935            "validate_user",
28936            "validateUser",
28937            "ValidateUser",
28938            "validate-user",
28939            "VALIDATE_USER",
28940            "Validate_User",
28941            "raw_symbol",
28942            "rawSymbol",
28943            "RawSymbol",
28944            "raw-symbol",
28945            "RAW_SYMBOL",
28946            "Raw_Symbol",
28947        ]
28948        .iter()
28949        .enumerate()
28950        .map(|(idx, identifier)| TokenSavingsRawSymbol {
28951            identifier: (*identifier).to_string(),
28952            file: format!("src/example_{idx}.rs"),
28953            line: (idx + 1) as u64,
28954            context: "function".to_string(),
28955        })
28956        .collect();
28957        let fixture = TokenSavingsFixture {
28958            schema_version: 1,
28959            description: "fixture".to_string(),
28960            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28961            cases: vec![TokenSavingsFixtureCase {
28962                name: "search-preview".to_string(),
28963                surface: "search".to_string(),
28964                minimum_savings_percent: 40.0,
28965                raw_symbols,
28966                tagpath_families: vec![
28967                    TokenSavingsFamily {
28968                        canonical: "validate_user".to_string(),
28969                        count: 6,
28970                        aliases: BTreeMap::new(),
28971                    },
28972                    TokenSavingsFamily {
28973                        canonical: "raw_symbol".to_string(),
28974                        count: 6,
28975                        aliases: BTreeMap::new(),
28976                    },
28977                ],
28978                context_pack_inputs: None,
28979                session_review_inputs: None,
28980                source_read_inputs: None,
28981                markdown_projection_inputs: None,
28982            }],
28983        };
28984
28985        let report = build_token_savings_report(&fixture).unwrap();
28986
28987        assert!(report.pass);
28988        assert_eq!(report.cases[0].raw_symbol_count, 12);
28989        assert_eq!(report.cases[0].family_count, 2);
28990        assert_eq!(report.cases[0].status, "pass");
28991        assert!(report.cases[0].byte_delta > 0);
28992        assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
28993        assert!(report.cases[0].savings_percent >= 40.0);
28994    }
28995
28996    #[test]
28997    fn token_savings_source_read_inputs_preserve_required_anchors() {
28998        let fixture = TokenSavingsFixture {
28999            schema_version: 1,
29000            description: "fixture".to_string(),
29001            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29002            cases: vec![TokenSavingsFixtureCase {
29003                name: "source-read".to_string(),
29004                surface: "source-read".to_string(),
29005                minimum_savings_percent: 40.0,
29006                raw_symbols: Vec::new(),
29007                tagpath_families: Vec::new(),
29008                context_pack_inputs: None,
29009                session_review_inputs: None,
29010                source_read_inputs: Some(TokenSavingsSourceReadInputs {
29011                    reads: vec![TokenSavingsSourceReadInput {
29012                        command: "sed -n '40,160p' src/main.rs".to_string(),
29013                        file: "src/main.rs".to_string(),
29014                        raw_start: 40,
29015                        raw_lines: 121,
29016                        raw_excerpt: "line 40\n".repeat(121),
29017                        envelope_start: 40,
29018                        envelope_lines: 121,
29019                        required_line_anchors: vec![40, 120, 160],
29020                    }],
29021                }),
29022                markdown_projection_inputs: None,
29023            }],
29024        };
29025
29026        let report = build_token_savings_report(&fixture).unwrap();
29027
29028        assert!(report.pass);
29029        assert_eq!(report.cases[0].surface, "source-read");
29030        assert!(report.cases[0].savings_percent >= 40.0);
29031    }
29032
29033    #[test]
29034    fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
29035        let fixture = TokenSavingsFixture {
29036            schema_version: 1,
29037            description: "fixture".to_string(),
29038            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29039            cases: vec![TokenSavingsFixtureCase {
29040                name: "source-read".to_string(),
29041                surface: "source-read".to_string(),
29042                minimum_savings_percent: 40.0,
29043                raw_symbols: Vec::new(),
29044                tagpath_families: Vec::new(),
29045                context_pack_inputs: None,
29046                session_review_inputs: None,
29047                source_read_inputs: Some(TokenSavingsSourceReadInputs {
29048                    reads: vec![TokenSavingsSourceReadInput {
29049                        command: "cat src/main.rs".to_string(),
29050                        file: "src/main.rs".to_string(),
29051                        raw_start: 1,
29052                        raw_lines: 200,
29053                        raw_excerpt: "line\n".repeat(200),
29054                        envelope_start: 1,
29055                        envelope_lines: 80,
29056                        required_line_anchors: vec![120],
29057                    }],
29058                }),
29059                markdown_projection_inputs: None,
29060            }],
29061        };
29062
29063        let err = match build_token_savings_report(&fixture) {
29064            Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
29065            Err(err) => err,
29066        };
29067
29068        assert!(err.to_string().contains("hides required line anchor 120"));
29069    }
29070
29071    #[test]
29072    fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
29073        let fixture = TokenSavingsFixture {
29074            schema_version: 1,
29075            description: "fixture".to_string(),
29076            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
29077            cases: vec![TokenSavingsFixtureCase {
29078                name: "markdown-projection".to_string(),
29079                surface: "context-pack".to_string(),
29080                minimum_savings_percent: 40.0,
29081                raw_symbols: Vec::new(),
29082                tagpath_families: Vec::new(),
29083                context_pack_inputs: None,
29084                session_review_inputs: None,
29085                source_read_inputs: None,
29086                markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
29087                    documents: vec![TokenSavingsMarkdownProjectionInput {
29088                        command: "context-pack markdown body".to_string(),
29089                        file: "tasks/software/tsift.md".to_string(),
29090                        raw_markdown: "# Heading\n\n".repeat(120),
29091                        outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
29092                        selected_nodes: vec!["mdast-selected".to_string()],
29093                        expand:
29094                            "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
29095                                .to_string(),
29096                    }],
29097                }),
29098            }],
29099        };
29100
29101        let report = build_token_savings_report(&fixture).unwrap();
29102
29103        assert!(report.pass);
29104        assert_eq!(report.cases[0].surface, "context-pack");
29105        assert!(report.cases[0].savings_percent >= 40.0);
29106    }
29107
29108    #[test]
29109    fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
29110        let mut content = String::from("# Cache Root\n\n");
29111        for idx in 0..96 {
29112            content.push_str(&format!(
29113                "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
29114            ));
29115        }
29116
29117        let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
29118        assert!(!first.cache_hit);
29119        assert!(first.nodes.len() > 200);
29120
29121        let sections = markdown_section_spans(&content).unwrap();
29122        let list_items = markdown_block_spans(&content, "list_item").unwrap();
29123        let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
29124        let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
29125
29126        assert!(second.cache_hit);
29127        assert_eq!(second.nodes.len(), first.nodes.len());
29128        assert_eq!(sections.len(), 97);
29129        assert_eq!(list_items.len(), 96);
29130        assert_eq!(code_blocks.len(), 96);
29131        let first_code = first
29132            .nodes
29133            .iter()
29134            .find(|node| node.kind == "code_block")
29135            .expect("expected a Markdown code block");
29136        let first_code_node = markdown_ast_node(
29137            Path::new("/repo"),
29138            "semantic-edit",
29139            first_code,
29140            content.as_bytes(),
29141            &first.nodes,
29142            8,
29143        );
29144        assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
29145        assert_eq!(
29146            first_code_node.metadata.embedded_symbols[0].name,
29147            "sample_0"
29148        );
29149        assert_eq!(
29150            first_code_node.metadata.embedded_symbols[0].language,
29151            "rust"
29152        );
29153    }
29154
29155    #[test]
29156    fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
29157        let response = empty_search_response(Path::new("/repo"), "lexical");
29158        let symbol_hits = vec![index::SymbolHit {
29159            name: "alpha_helper_with_a_long_name".to_string(),
29160            kind: "function".to_string(),
29161            language: "rust".to_string(),
29162            file: "/repo/src/lib.rs".to_string(),
29163            line: 12,
29164            end_line: None,
29165            node_kind: None,
29166            start_byte: None,
29167            end_byte: None,
29168            body_start_byte: None,
29169            body_end_byte: None,
29170            tags: None,
29171            score: 0.98,
29172            match_type: "exact_name".to_string(),
29173            tagpath_handle: None,
29174        }];
29175
29176        let report = build_relative_search_budget_report(
29177            "alpha_helper_with_a_long_name",
29178            "lexical",
29179            Path::new("/repo"),
29180            &response,
29181            &symbol_hits,
29182            ResponseBudget::new(Some(1), Some(12)),
29183            &SearchFacetFilters::default(),
29184        );
29185
29186        assert_eq!(report.symbols.len(), 1);
29187        assert!(report.symbols[0].handle.starts_with("sfam-"));
29188        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
29189        assert_eq!(report.symbols[0].name, "alpha_hel...");
29190        assert_eq!(report.symbols[0].file, "src/lib.rs");
29191        assert!(report.symbols[0].expand.contains("tsift search"));
29192    }
29193
29194    #[test]
29195    fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
29196        let dir = tempfile::tempdir().unwrap();
29197        let src_dir = dir.path().join("src");
29198        fs::create_dir_all(&src_dir).unwrap();
29199        let source = "fn alpha_helper() {\n    beta();\n}\n";
29200        let file = src_dir.join("lib.rs");
29201        fs::write(&file, source).unwrap();
29202        let body_start = source.find("{\n").unwrap() + 1;
29203        let body_end = source.rfind("\n}").unwrap() + 1;
29204
29205        let response = empty_search_response(dir.path(), "lexical");
29206        let symbol_hits = vec![index::SymbolHit {
29207            name: "alpha_helper".to_string(),
29208            kind: "function".to_string(),
29209            language: "rust".to_string(),
29210            file: file.to_string_lossy().to_string(),
29211            line: 0,
29212            end_line: Some(2),
29213            node_kind: Some("function_item".to_string()),
29214            start_byte: Some(0),
29215            end_byte: Some(i64::try_from(source.len()).unwrap()),
29216            body_start_byte: Some(i64::try_from(body_start).unwrap()),
29217            body_end_byte: Some(i64::try_from(body_end).unwrap()),
29218            tags: Some("alpha,helper".to_string()),
29219            score: 0.98,
29220            match_type: "exact_name".to_string(),
29221            tagpath_handle: None,
29222        }];
29223
29224        let report = build_relative_search_budget_report(
29225            "alpha helper",
29226            "lexical",
29227            dir.path(),
29228            &response,
29229            &symbol_hits,
29230            ResponseBudget::new(Some(5), Some(96)),
29231            &SearchFacetFilters::default(),
29232        );
29233
29234        let symbol = &report.symbols[0];
29235        assert_eq!(symbol.language, "rust");
29236        assert_eq!(symbol.end_line, Some(2));
29237        let ast = symbol
29238            .ast
29239            .as_ref()
29240            .expect("search symbol preview should expose an AST span artifact");
29241        assert_eq!(ast.artifact_kind, "ast_span");
29242        assert!(ast.span.handle.starts_with("span-"));
29243        assert_eq!(ast.span.node_kind, "function_item");
29244        assert_eq!(ast.span.start_byte, 0);
29245        assert_eq!(ast.span.end_byte, source.len());
29246        assert_eq!(ast.span.body_start_byte, Some(body_start));
29247        assert_eq!(ast.span.body_end_byte, Some(body_end));
29248        assert!(ast.expand.source_window.contains("source-read"));
29249        assert!(
29250            ast.expand
29251                .source_body
29252                .as_ref()
29253                .unwrap()
29254                .contains("source-read")
29255        );
29256        assert!(ast.expand.symbol_read.contains("symbol-read"));
29257        assert!(ast.expand.markdown_ast.is_none());
29258    }
29259
29260    #[test]
29261    fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
29262        let dir = tempfile::tempdir().unwrap();
29263        let source = "# Guide\n\n## Install\n\n- Run setup.\n";
29264        let file = dir.path().join("README.md");
29265        fs::write(&file, source).unwrap();
29266        let heading_start = source.find("## Install").unwrap();
29267        let heading_end = source.len();
29268
29269        let response = empty_search_response(dir.path(), "lexical");
29270        let symbol_hits = vec![index::SymbolHit {
29271            name: "Install".to_string(),
29272            kind: "heading".to_string(),
29273            language: "markdown".to_string(),
29274            file: file.to_string_lossy().to_string(),
29275            line: 2,
29276            end_line: Some(4),
29277            node_kind: Some("atx_heading".to_string()),
29278            start_byte: Some(i64::try_from(heading_start).unwrap()),
29279            end_byte: Some(i64::try_from(heading_end).unwrap()),
29280            body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
29281            body_end_byte: Some(i64::try_from(heading_end).unwrap()),
29282            tags: Some("install".to_string()),
29283            score: 1.0,
29284            match_type: "exact_name".to_string(),
29285            tagpath_handle: None,
29286        }];
29287
29288        let report = build_relative_search_budget_report(
29289            "Install",
29290            "lexical",
29291            dir.path(),
29292            &response,
29293            &symbol_hits,
29294            ResponseBudget::new(Some(5), Some(96)),
29295            &SearchFacetFilters::default(),
29296        );
29297
29298        let ast = report.symbols[0]
29299            .ast
29300            .as_ref()
29301            .expect("Markdown search symbol should expose an AST span artifact");
29302        assert_eq!(ast.span.node_kind, "atx_heading");
29303        assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
29304        let markdown_ast = ast
29305            .expand
29306            .markdown_ast
29307            .as_ref()
29308            .expect("Markdown symbols should include markdown-ast expansion");
29309        assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
29310        assert!(markdown_ast.contains("--node"), "{markdown_ast}");
29311        assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
29312        assert!(ast.expand.source_window.contains("source-read"));
29313        assert!(ast.expand.symbol_read.contains("symbol-read"));
29314    }
29315
29316    #[test]
29317    fn search_budget_report_exposes_markdown_embedded_code_symbols() {
29318        let dir = tempfile::tempdir().unwrap();
29319        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29320        let file = dir.path().join("README.md");
29321        fs::write(&file, source).unwrap();
29322        let fence_start = source.find("```rust").unwrap();
29323        let body_start = source.find("fn sample").unwrap();
29324        let body_end = body_start + "fn sample() {}\n".len();
29325
29326        let response = empty_search_response(dir.path(), "lexical");
29327        let symbol_hits = vec![index::SymbolHit {
29328            name: "rust".to_string(),
29329            kind: "code_block".to_string(),
29330            language: "markdown".to_string(),
29331            file: file.to_string_lossy().to_string(),
29332            line: 2,
29333            end_line: Some(4),
29334            node_kind: Some("fenced_code_block".to_string()),
29335            start_byte: Some(i64::try_from(fence_start).unwrap()),
29336            end_byte: Some(i64::try_from(source.len()).unwrap()),
29337            body_start_byte: Some(i64::try_from(body_start).unwrap()),
29338            body_end_byte: Some(i64::try_from(body_end).unwrap()),
29339            tags: Some("rust".to_string()),
29340            score: 1.0,
29341            match_type: "exact_name".to_string(),
29342            tagpath_handle: None,
29343        }];
29344
29345        let report = build_relative_search_budget_report(
29346            "rust",
29347            "lexical",
29348            dir.path(),
29349            &response,
29350            &symbol_hits,
29351            ResponseBudget::new(Some(5), Some(96)),
29352            &SearchFacetFilters::default(),
29353        );
29354
29355        let embedded = &report.symbols[0]
29356            .ast
29357            .as_ref()
29358            .unwrap()
29359            .span
29360            .markdown
29361            .as_ref()
29362            .unwrap()
29363            .embedded_symbols;
29364        assert_eq!(embedded.len(), 1);
29365        assert_eq!(embedded[0].name, "sample");
29366        assert_eq!(embedded[0].kind, "function");
29367        assert_eq!(embedded[0].language, "rust");
29368        assert_eq!(embedded[0].node_kind, "function_item");
29369        assert!(embedded[0].handle.starts_with("span-"));
29370        assert_eq!(embedded[0].start_byte, body_start);
29371        assert_eq!(embedded[0].start_line, 4);
29372    }
29373
29374    fn test_lexical_search_hit(
29375        path: &Path,
29376        rank: usize,
29377        score: f64,
29378        snippet: &str,
29379    ) -> sift::SearchHit {
29380        sift::SearchHit {
29381            artifact_id: format!("hit-{rank}"),
29382            artifact_kind: sift::ContextArtifactKind::File,
29383            budget: sift::ArtifactBudget::from_text(snippet, 1),
29384            confidence: sift::ScoreConfidence::High,
29385            freshness: sift::ArtifactFreshness {
29386                modified_unix_secs: None,
29387                observed_unix_secs: 0,
29388            },
29389            location: Some("line 1".to_string()),
29390            path: path.to_string_lossy().to_string(),
29391            provenance: sift::ArtifactProvenance {
29392                adapter: sift::AcquisitionAdapterKind::FileSystem,
29393                source: "test lexical hit".to_string(),
29394                synthetic: false,
29395            },
29396            rank,
29397            score,
29398            snippet: snippet.to_string(),
29399        }
29400    }
29401
29402    fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
29403        summarize::Summary {
29404            id: 0,
29405            symbol_name: symbol_name.to_string(),
29406            file_path: file_path.to_string(),
29407            content_hash: "hash".to_string(),
29408            summary: summary.to_string(),
29409            entities: None,
29410            relationships: None,
29411            concept_labels: None,
29412            extracted_at: "2026-06-02T00:00:00Z".to_string(),
29413            model: "test".to_string(),
29414            tokens_input: None,
29415            tokens_output: None,
29416        }
29417    }
29418
29419    #[test]
29420    fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
29421        let dir = tempfile::tempdir().unwrap();
29422        let src_dir = dir.path().join("src");
29423        fs::create_dir_all(&src_dir).unwrap();
29424        let source = "fn alpha_helper() {}\n";
29425        let file = src_dir.join("lib.rs");
29426        let broad_file = dir.path().join("README.md");
29427        fs::write(&file, source).unwrap();
29428        fs::write(
29429            &broad_file,
29430            "alpha helper alpha helper alpha helper in prose\n",
29431        )
29432        .unwrap();
29433
29434        let mut response = empty_search_response(dir.path(), "lexical");
29435        response.hits.push(test_lexical_search_hit(
29436            &broad_file,
29437            1,
29438            240.0,
29439            "alpha helper alpha helper alpha helper in prose",
29440        ));
29441        let symbol_hits = vec![index::SymbolHit {
29442            name: "alpha_helper".to_string(),
29443            kind: "function".to_string(),
29444            language: "rust".to_string(),
29445            file: file.to_string_lossy().to_string(),
29446            line: 0,
29447            end_line: Some(0),
29448            node_kind: Some("function_item".to_string()),
29449            start_byte: Some(0),
29450            end_byte: Some(i64::try_from(source.len()).unwrap()),
29451            body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29452            body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29453            tags: Some("alpha,helper".to_string()),
29454            score: 0.8,
29455            match_type: "all_tags".to_string(),
29456            tagpath_handle: None,
29457        }];
29458
29459        let report = build_relative_search_budget_report(
29460            "alpha helper",
29461            "lexical",
29462            dir.path(),
29463            &response,
29464            &symbol_hits,
29465            ResponseBudget::new(Some(5), Some(128)),
29466            &SearchFacetFilters::default(),
29467        );
29468
29469        assert_eq!(report.ranked[0].source, "symbol_span");
29470        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
29471        assert!(report.ranked[0].score > report.ranked[1].score);
29472        assert_eq!(report.ranked[1].source, "lexical_file");
29473    }
29474
29475    #[test]
29476    fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
29477        let dir = tempfile::tempdir().unwrap();
29478        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29479        let file = dir.path().join("README.md");
29480        fs::write(&file, source).unwrap();
29481        let summary_db =
29482            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
29483        summary_db
29484            .insert(&test_summary(
29485                "rust",
29486                "README.md",
29487                "Rust fence contains a sample function.",
29488            ))
29489            .unwrap();
29490
29491        let fence_start = source.find("```rust").unwrap();
29492        let body_start = source.find("fn sample").unwrap();
29493        let body_end = body_start + "fn sample() {}\n".len();
29494        let response = empty_search_response(dir.path(), "lexical");
29495        let symbol_hits = vec![index::SymbolHit {
29496            name: "rust".to_string(),
29497            kind: "code_block".to_string(),
29498            language: "markdown".to_string(),
29499            file: file.to_string_lossy().to_string(),
29500            line: 2,
29501            end_line: Some(4),
29502            node_kind: Some("fenced_code_block".to_string()),
29503            start_byte: Some(i64::try_from(fence_start).unwrap()),
29504            end_byte: Some(i64::try_from(source.len()).unwrap()),
29505            body_start_byte: Some(i64::try_from(body_start).unwrap()),
29506            body_end_byte: Some(i64::try_from(body_end).unwrap()),
29507            tags: Some("rust".to_string()),
29508            score: 1.0,
29509            match_type: "exact_name".to_string(),
29510            tagpath_handle: None,
29511        }];
29512
29513        let report = build_relative_search_budget_report(
29514            "rust",
29515            "lexical",
29516            dir.path(),
29517            &response,
29518            &symbol_hits,
29519            ResponseBudget::new(Some(5), Some(128)),
29520            &SearchFacetFilters::default(),
29521        );
29522
29523        let symbol = &report.symbols[0];
29524        assert_eq!(symbol.summary_refs, 1);
29525        assert_eq!(symbol.graph_neighbors, 1);
29526        assert!(
29527            report.ranked[0]
29528                .reasons
29529                .iter()
29530                .any(|reason| reason == "summary_refs:1")
29531        );
29532        assert!(
29533            report.ranked[0]
29534                .reasons
29535                .iter()
29536                .any(|reason| reason == "graph_neighbors:1")
29537        );
29538    }
29539
29540    fn markdown_search_facet_fixture() -> tempfile::TempDir {
29541        let dir = tempfile::tempdir().unwrap();
29542        let source = r#"# Guide
29543
29544## Install
29545
29546- Run setup.
29547  - Confirm setup.
29548
29549```rust
29550fn sample() {}
29551```
29552"#;
29553        fs::write(dir.path().join("README.md"), source).unwrap();
29554        let index_dir = dir.path().join(".tsift");
29555        fs::create_dir_all(&index_dir).unwrap();
29556        run_index_update(
29557            &index_dir.join("index.db"),
29558            dir.path(),
29559            "indexing markdown search facet fixture".to_string(),
29560            dir.path(),
29561            None,
29562            false,
29563            false,
29564        )
29565        .unwrap();
29566        dir
29567    }
29568
29569    fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
29570        let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
29571        db.symbol_search(query, 20).unwrap()
29572    }
29573
29574    #[test]
29575    fn search_facet_filters_match_scalar_symbol_fields() {
29576        let dir = tempfile::tempdir().unwrap();
29577        let hits = vec![
29578            index::SymbolHit {
29579                name: "alpha_helper".to_string(),
29580                kind: "function".to_string(),
29581                language: "rust".to_string(),
29582                file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
29583                line: 0,
29584                end_line: None,
29585                node_kind: Some("function_item".to_string()),
29586                start_byte: None,
29587                end_byte: None,
29588                body_start_byte: None,
29589                body_end_byte: None,
29590                tags: None,
29591                score: 1.0,
29592                match_type: "exact_name".to_string(),
29593                tagpath_handle: None,
29594            },
29595            index::SymbolHit {
29596                name: "Install".to_string(),
29597                kind: "heading".to_string(),
29598                language: "markdown".to_string(),
29599                file: dir.path().join("README.md").to_string_lossy().to_string(),
29600                line: 0,
29601                end_line: None,
29602                node_kind: Some("atx_heading".to_string()),
29603                start_byte: None,
29604                end_byte: None,
29605                body_start_byte: None,
29606                body_end_byte: None,
29607                tags: None,
29608                score: 0.9,
29609                match_type: "exact_name".to_string(),
29610                tagpath_handle: None,
29611            },
29612        ];
29613
29614        let filtered = apply_search_facet_filters(
29615            dir.path(),
29616            hits,
29617            &SearchFacetFilters {
29618                languages: vec!["rust".to_string()],
29619                kinds: vec!["function".to_string()],
29620                node_kinds: vec!["function_item".to_string()],
29621                ..SearchFacetFilters::default()
29622            },
29623        );
29624
29625        assert_eq!(filtered.len(), 1);
29626        assert_eq!(filtered[0].name, "alpha_helper");
29627    }
29628
29629    #[test]
29630    fn search_facet_filters_match_markdown_sections_and_block_metadata() {
29631        let dir = markdown_search_facet_fixture();
29632
29633        let nested_list = apply_search_facet_filters(
29634            dir.path(),
29635            markdown_search_facet_hits(dir.path(), "setup"),
29636            &SearchFacetFilters {
29637                sections: vec!["Install".to_string()],
29638                parents: vec!["Run setup.".to_string()],
29639                list_depths: vec![1],
29640                ..SearchFacetFilters::default()
29641            },
29642        );
29643        assert_eq!(nested_list.len(), 1);
29644        assert_eq!(nested_list[0].name, "Confirm setup.");
29645
29646        let parent_list = apply_search_facet_filters(
29647            dir.path(),
29648            markdown_search_facet_hits(dir.path(), "setup"),
29649            &SearchFacetFilters {
29650                children: vec!["Confirm setup.".to_string()],
29651                ..SearchFacetFilters::default()
29652            },
29653        );
29654        assert_eq!(parent_list.len(), 1);
29655        assert_eq!(parent_list[0].name, "Run setup.");
29656
29657        let heading = apply_search_facet_filters(
29658            dir.path(),
29659            markdown_search_facet_hits(dir.path(), "Install"),
29660            &SearchFacetFilters {
29661                heading_levels: vec![2],
29662                node_kinds: vec!["atx_heading".to_string()],
29663                ..SearchFacetFilters::default()
29664            },
29665        );
29666        assert_eq!(heading.len(), 1);
29667        assert_eq!(heading[0].name, "Install");
29668
29669        let fence = apply_search_facet_filters(
29670            dir.path(),
29671            markdown_search_facet_hits(dir.path(), "rust"),
29672            &SearchFacetFilters {
29673                fence_languages: vec!["rust".to_string()],
29674                kinds: vec!["code_block".to_string()],
29675                ..SearchFacetFilters::default()
29676            },
29677        );
29678        assert_eq!(fence.len(), 1);
29679        assert_eq!(fence[0].kind, "code_block");
29680
29681        let embedded_child = apply_search_facet_filters(
29682            dir.path(),
29683            markdown_search_facet_hits(dir.path(), "rust"),
29684            &SearchFacetFilters {
29685                children: vec!["sample".to_string()],
29686                kinds: vec!["code_block".to_string()],
29687                ..SearchFacetFilters::default()
29688            },
29689        );
29690        assert_eq!(embedded_child.len(), 1);
29691        assert_eq!(embedded_child[0].name, "rust");
29692    }
29693
29694    #[test]
29695    fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
29696        let response = empty_search_response(Path::new("/repo"), "lexical");
29697        let symbol_hits = vec![
29698            index::SymbolHit {
29699                name: "alpha_helper".to_string(),
29700                kind: "function".to_string(),
29701                language: "rust".to_string(),
29702                file: "/repo/src/lib.rs".to_string(),
29703                line: 12,
29704                end_line: None,
29705                node_kind: None,
29706                start_byte: None,
29707                end_byte: None,
29708                body_start_byte: None,
29709                body_end_byte: None,
29710                tags: Some("alpha,helper".to_string()),
29711                score: 0.98,
29712                match_type: "exact_name".to_string(),
29713                tagpath_handle: None,
29714            },
29715            index::SymbolHit {
29716                name: "alphaHelper".to_string(),
29717                kind: "method".to_string(),
29718                language: "rust".to_string(),
29719                file: "/repo/src/main.rs".to_string(),
29720                line: 34,
29721                end_line: None,
29722                node_kind: None,
29723                start_byte: None,
29724                end_byte: None,
29725                body_start_byte: None,
29726                body_end_byte: None,
29727                tags: Some("alpha,helper".to_string()),
29728                score: 0.93,
29729                match_type: "tag_overlap".to_string(),
29730                tagpath_handle: None,
29731            },
29732            index::SymbolHit {
29733                name: "alpha_helper".to_string(),
29734                kind: "function".to_string(),
29735                language: "rust".to_string(),
29736                file: "/repo/src/worker.rs".to_string(),
29737                line: 56,
29738                end_line: None,
29739                node_kind: None,
29740                start_byte: None,
29741                end_byte: None,
29742                body_start_byte: None,
29743                body_end_byte: None,
29744                tags: Some("alpha,helper".to_string()),
29745                score: 0.91,
29746                match_type: "tag_overlap".to_string(),
29747                tagpath_handle: None,
29748            },
29749        ];
29750
29751        let report = build_relative_search_budget_report(
29752            "alpha helper",
29753            "lexical",
29754            Path::new("/repo"),
29755            &response,
29756            &symbol_hits,
29757            ResponseBudget::new(Some(5), Some(48)),
29758            &SearchFacetFilters::default(),
29759        );
29760
29761        assert_eq!(report.symbol_total, 1);
29762        assert_eq!(report.raw_symbol_total, 3);
29763        assert_eq!(report.symbols.len(), 1);
29764        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
29765        assert_eq!(report.symbols[0].match_count, 3);
29766        assert_eq!(report.symbols[0].surface_count, 2);
29767        assert_eq!(report.symbols[0].file_count, 3);
29768        assert_eq!(
29769            report.symbols[0].surface_examples,
29770            vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
29771        );
29772        assert!(report.symbols[0].name.contains("(+1 variant)"));
29773        assert!(report.symbols[0].file.contains("(+2 files)"));
29774        assert!(report.symbols[0].expand.contains("tsift search"));
29775        assert!(report.symbols[0].expand.contains("alpha helper"));
29776    }
29777
29778    #[test]
29779    fn search_budget_report_carries_active_filters() {
29780        let response = empty_search_response(Path::new("/repo"), "lexical");
29781        let symbol_hits = vec![index::SymbolHit {
29782            name: "alpha_helper".to_string(),
29783            kind: "function".to_string(),
29784            language: "rust".to_string(),
29785            file: "/repo/src/lib.rs".to_string(),
29786            line: 12,
29787            end_line: None,
29788            node_kind: Some("function_item".to_string()),
29789            start_byte: None,
29790            end_byte: None,
29791            body_start_byte: None,
29792            body_end_byte: None,
29793            tags: Some("alpha,helper".to_string()),
29794            score: 0.98,
29795            match_type: "exact_name".to_string(),
29796            tagpath_handle: None,
29797        }];
29798        let filters = SearchFacetFilters {
29799            languages: vec!["rust".to_string()],
29800            kinds: vec!["function".to_string()],
29801            node_kinds: vec!["function_item".to_string()],
29802            ..SearchFacetFilters::default()
29803        };
29804
29805        let report = build_relative_search_budget_report(
29806            "alpha helper",
29807            "lexical",
29808            Path::new("/repo"),
29809            &response,
29810            &symbol_hits,
29811            ResponseBudget::new(Some(5), Some(48)),
29812            &filters,
29813        );
29814
29815        assert_eq!(report.filters, filters);
29816        assert_eq!(
29817            search_facet_filters_summary(&report.filters),
29818            "lang=rust kind=function node-kind=function_item"
29819        );
29820    }
29821
29822    #[test]
29823    fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
29824        let mut response = empty_search_response(Path::new("/repo"), "lexical");
29825        response.indexed_artifacts = 450;
29826        let symbol_hits = vec![
29827            index::SymbolHit {
29828                name: "alpha_helper".to_string(),
29829                kind: "function".to_string(),
29830                language: "rust".to_string(),
29831                file: "/repo/src/lib.rs".to_string(),
29832                line: 12,
29833                end_line: None,
29834                node_kind: None,
29835                start_byte: None,
29836                end_byte: None,
29837                body_start_byte: None,
29838                body_end_byte: None,
29839                tags: Some("alpha,helper".to_string()),
29840                score: 0.98,
29841                match_type: "exact_name".to_string(),
29842                tagpath_handle: None,
29843            },
29844            index::SymbolHit {
29845                name: "beta_helper".to_string(),
29846                kind: "function".to_string(),
29847                language: "rust".to_string(),
29848                file: "/repo/src/beta.rs".to_string(),
29849                line: 21,
29850                end_line: None,
29851                node_kind: None,
29852                start_byte: None,
29853                end_byte: None,
29854                body_start_byte: None,
29855                body_end_byte: None,
29856                tags: Some("beta,helper".to_string()),
29857                score: 0.92,
29858                match_type: "tag_overlap".to_string(),
29859                tagpath_handle: None,
29860            },
29861        ];
29862
29863        let report = build_relative_search_budget_report(
29864            "helper",
29865            "lexical",
29866            Path::new("/repo"),
29867            &response,
29868            &symbol_hits,
29869            ResponseBudget::new(Some(1), Some(64)),
29870            &SearchFacetFilters::default(),
29871        );
29872
29873        let guard = report
29874            .scale_guard
29875            .as_ref()
29876            .expect("broad previews should emit a scale guard");
29877        assert_eq!(guard.level, "high-hit");
29878        assert_eq!(guard.signals.indexed_artifacts, 450);
29879        assert_eq!(guard.signals.raw_symbol_matches, 2);
29880        assert!(
29881            guard
29882                .narrow_commands
29883                .iter()
29884                .any(|command| command.contains("--exact"))
29885        );
29886        assert!(
29887            guard
29888                .narrow_commands
29889                .iter()
29890                .any(|command| command.contains("alpha helper"))
29891        );
29892        assert!(
29893            guard
29894                .narrow_commands
29895                .last()
29896                .unwrap()
29897                .contains("workflow search")
29898        );
29899    }
29900
29901    #[test]
29902    fn explain_budget_report_limits_edges_and_members() {
29903        let symbols = vec![index::StoredSymbol {
29904            name: "alpha_helper".to_string(),
29905            kind: "function".to_string(),
29906            language: "rust".to_string(),
29907            signature: None,
29908            file: "src/lib.rs".to_string(),
29909            line: 10,
29910            end_line: None,
29911            node_kind: None,
29912            start_byte: None,
29913            end_byte: None,
29914            body_start_byte: None,
29915            body_end_byte: None,
29916            parent_module: None,
29917            visibility: None,
29918            tags: None,
29919            tagpath_handle: None,
29920        }];
29921        let callers = vec![
29922            index::StoredEdge {
29923                caller_file: "src/main.rs".to_string(),
29924                caller_name: "main".to_string(),
29925                caller_line: 1,
29926                callee_name: "alpha_helper".to_string(),
29927                call_site_line: 3,
29928                tagpath_handle: None,
29929            },
29930            index::StoredEdge {
29931                caller_file: "src/worker.rs".to_string(),
29932                caller_name: "worker".to_string(),
29933                caller_line: 5,
29934                callee_name: "alpha_helper".to_string(),
29935                call_site_line: 8,
29936                tagpath_handle: None,
29937            },
29938        ];
29939        let community = graph::Community {
29940            id: 1,
29941            members: vec![
29942                graph::CommunityMember::new("alpha_helper"),
29943                graph::CommunityMember::new("main"),
29944                graph::CommunityMember::new("worker"),
29945            ],
29946            modularity_contribution: 0.5,
29947        };
29948
29949        let report = build_explain_budget_report(
29950            "alpha_helper",
29951            Path::new("/repo"),
29952            &symbols,
29953            &callers,
29954            2,
29955            false,
29956            &[],
29957            0,
29958            false,
29959            Some(&community),
29960            ResponseBudget::new(Some(1), Some(24)),
29961        );
29962
29963        assert_eq!(report.definitions.len(), 1);
29964        assert_eq!(report.callers.len(), 1);
29965        assert!(report.truncated);
29966        assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
29967        assert_eq!(
29968            report.definitions[0].tag_alias.as_deref(),
29969            Some("alpha/helper")
29970        );
29971        assert!(report.callers[0].handle.starts_with("ecall-"));
29972        assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
29973    }
29974
29975    #[test]
29976    fn session_review_next_context_budget_limits_lists() {
29977        let report = session_review::SessionReviewReport {
29978            root: "/repo".to_string(),
29979            target: "tasks/software/tsift.md".to_string(),
29980            target_kind: "file".to_string(),
29981            sessions_considered: 1,
29982            sessions_matched: 1,
29983            claude_sessions: 1,
29984            codex_sessions: 0,
29985            agent_doc_logs: 0,
29986            prompt_target_count: 2,
29987            command_groups: 0,
29988            file_groups: 2,
29989            symbol_groups: 1,
29990            failure_groups: 1,
29991            runtime_event_groups: 0,
29992            restart_churn_groups: 0,
29993            closeout_groups: 0,
29994            usage_samples: 1,
29995            prompt_tokens: 120,
29996            cached_input_tokens: 80,
29997            cache_creation_input_tokens: 0,
29998            output_tokens: 40,
29999            reasoning_output_tokens: 0,
30000            total_tokens: 240,
30001            cached_input_ratio: Some(40.0),
30002            largest_turn_total_tokens: 240,
30003            aggregate_cost: session_review::SessionReviewCostSummary {
30004                scope: "bounded_matched_sessions".to_string(),
30005                sessions: 1,
30006                usage_samples: 1,
30007                prompt_tokens: 120,
30008                cached_input_tokens: 80,
30009                cache_creation_input_tokens: 0,
30010                output_tokens: 40,
30011                reasoning_output_tokens: 0,
30012                total_tokens: 240,
30013                cached_input_ratio: Some(40.0),
30014                largest_turn_total_tokens: 240,
30015            },
30016            latest_session_cost: Some(session_review::SessionReviewCostSummary {
30017                scope: "latest_matched_session".to_string(),
30018                sessions: 1,
30019                usage_samples: 1,
30020                prompt_tokens: 120,
30021                cached_input_tokens: 80,
30022                cache_creation_input_tokens: 0,
30023                output_tokens: 40,
30024                reasoning_output_tokens: 0,
30025                total_tokens: 240,
30026                cached_input_ratio: Some(66.67),
30027                largest_turn_total_tokens: 240,
30028            }),
30029            guardrails: vec![
30030                session_cost::SessionCostGuardrail {
30031                    kind: "cache_resend".to_string(),
30032                    severity: "warn".to_string(),
30033                    message: "cached input ratio was high".to_string(),
30034                    guidance: "compact or restart the session".to_string(),
30035                },
30036                session_cost::SessionCostGuardrail {
30037                    kind: "prompt_budget".to_string(),
30038                    severity: "warn".to_string(),
30039                    message: "largest prompt turn reached 999999 tokens".to_string(),
30040                    guidance: "compact the session before another large turn".to_string(),
30041                },
30042                session_cost::SessionCostGuardrail {
30043                    kind: "restart_loop".to_string(),
30044                    severity: "warn".to_string(),
30045                    message: "restart churn detected".to_string(),
30046                    guidance: "restart cleanly".to_string(),
30047                },
30048                session_cost::SessionCostGuardrail {
30049                    kind: "noop_closeout".to_string(),
30050                    severity: "warn".to_string(),
30051                    message: "commit_already_current appeared 8 times".to_string(),
30052                    guidance: "avoid reopening without new edits".to_string(),
30053                },
30054            ],
30055            loop_clusters: vec![],
30056            file_read_diagnostics: vec![],
30057            prompt_targets: vec![
30058                session_review::SessionReviewPromptTarget {
30059                    text: "do one".to_string(),
30060                    occurrences: 1,
30061                },
30062                session_review::SessionReviewPromptTarget {
30063                    text: "do two".to_string(),
30064                    occurrences: 1,
30065                },
30066            ],
30067            commands: vec![],
30068            touched_files: vec![],
30069            touched_symbols: vec![],
30070            failures: vec![],
30071            runtime_events: vec![],
30072            restart_churn: vec![],
30073            closeout: vec![],
30074            largest_turns: vec![],
30075            sessions: vec![session_review::SessionReviewSession {
30076                source: "claude_jsonl".to_string(),
30077                path: "/tmp/session.jsonl".to_string(),
30078                matched_by: vec!["path".to_string()],
30079                modified_unix_secs: None,
30080                prompt_target_count: 2,
30081                command_groups: 0,
30082                file_groups: 2,
30083                symbol_groups: 1,
30084                failure_groups: 1,
30085                runtime_event_groups: 0,
30086                restart_churn_groups: 0,
30087                closeout_groups: 0,
30088                usage_samples: 1,
30089                prompt_tokens: 120,
30090                cached_input_tokens: 80,
30091                cache_creation_input_tokens: 0,
30092                output_tokens: 40,
30093                reasoning_output_tokens: 0,
30094                total_tokens: 240,
30095                largest_turn_total_tokens: 240,
30096            }],
30097            next_context: session_review::SessionReviewNextContext {
30098                target: "tasks/software/tsift.md".to_string(),
30099                active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
30100                last_verification: session_review::SessionReviewVerificationState {
30101                    status: "green".to_string(),
30102                    detail: "cargo test".to_string(),
30103                },
30104                touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
30105                touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
30106                unresolved_failures: vec![session_review::SessionReviewFailure {
30107                    kind: "timeout".to_string(),
30108                    message: "search timed out".to_string(),
30109                    occurrences: 1,
30110                    command: None,
30111                    session_path: None,
30112                }],
30113                next_digest_commands: vec![
30114                    "tsift session-review --next-context tasks/software/tsift.md".to_string(),
30115                    "tsift diff-digest .".to_string(),
30116                    "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
30117                    "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
30118                ],
30119            },
30120            warnings: vec![],
30121        };
30122
30123        let budget_report = build_session_review_next_context_budget_report(
30124            &report,
30125            ResponseBudget::new(Some(1), Some(12)),
30126            None,
30127        );
30128
30129        assert!(budget_report.truncated);
30130        assert_eq!(budget_report.prompt_targets, vec!["do one"]);
30131        assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
30132        assert!(
30133            budget_report.touched_symbol_refs[0]
30134                .handle
30135                .starts_with("ncsym-")
30136        );
30137        assert_eq!(
30138            budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
30139            Some("alpha/helper")
30140        );
30141        assert!(
30142            budget_report.unresolved_failures[0]
30143                .handle
30144                .starts_with("snf-")
30145        );
30146        assert_eq!(budget_report.next_digest_commands.len(), 4);
30147        assert_eq!(
30148            budget_report.next_digest_commands[2],
30149            "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
30150        );
30151        assert_eq!(budget_report.next_token_actions.len(), 1);
30152        assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
30153
30154        let full_action_report = build_session_review_next_context_budget_report(
30155            &report,
30156            ResponseBudget::new(Some(4), Some(120)),
30157            None,
30158        );
30159        assert_eq!(
30160            full_action_report
30161                .next_token_actions
30162                .iter()
30163                .map(|action| action.kind.as_str())
30164                .collect::<Vec<_>>(),
30165            vec![
30166                "prompt_budget",
30167                "cache_resend",
30168                "restart_loop",
30169                "noop_closeout"
30170            ]
30171        );
30172        assert_eq!(
30173            full_action_report.next_token_actions[0]
30174                .compact_command
30175                .as_deref(),
30176            Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
30177        );
30178        assert_eq!(
30179            full_action_report.next_token_actions[0]
30180                .restart_command
30181                .as_deref(),
30182            Some("agent-doc start \"tasks/software/tsift.md\"")
30183        );
30184        assert!(
30185            full_action_report.next_token_actions[0]
30186                .digest_commands
30187                .iter()
30188                .any(|command| command
30189                    == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
30190        );
30191    }
30192
30193    #[test]
30194    fn context_pack_diff_preview_limits_files_and_symbols() {
30195        let report = diff_digest::DiffDigestReport {
30196            root: "/repo".to_string(),
30197            mode: diff_digest::DiffDigestMode::WorkingTree,
30198            revision: None,
30199            files_changed: 2,
30200            files_with_current_summaries: 1,
30201            symbols_touched: 3,
30202            call_edges_added: 1,
30203            call_edges_removed: 0,
30204            files: vec![
30205                diff_digest::DiffDigestFile {
30206                    path: "src/lib.rs".to_string(),
30207                    status: diff_digest::DiffDigestFileStatus::Modified,
30208                    touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
30209                    summary_state: diff_digest::DiffDigestSummaryState::Current,
30210                    current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30211                        symbol: "alpha_helper".to_string(),
30212                        summary: "alpha helper handles the main alpha workflow".to_string(),
30213                    }],
30214                    added_call_edges: vec!["alpha->beta".to_string()],
30215                    removed_call_edges: vec![],
30216                    warnings: vec!["stale parse".to_string()],
30217                },
30218                diff_digest::DiffDigestFile {
30219                    path: "src/main.rs".to_string(),
30220                    status: diff_digest::DiffDigestFileStatus::Added,
30221                    touched_symbols: vec!["main".to_string()],
30222                    summary_state: diff_digest::DiffDigestSummaryState::Missing,
30223                    current_summaries: vec![],
30224                    added_call_edges: vec![],
30225                    removed_call_edges: vec![],
30226                    warnings: vec![],
30227                },
30228            ],
30229        };
30230
30231        let preview =
30232            build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
30233
30234        assert!(preview.truncated);
30235        assert_eq!(preview.files.len(), 1);
30236        assert_eq!(preview.files[0].path, "src/lib.rs");
30237        assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
30238        assert!(
30239            preview.files[0].touched_symbol_refs[0]
30240                .handle
30241                .starts_with("cdsym-")
30242        );
30243        assert_eq!(
30244            preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
30245            Some("alpha/he...")
30246        );
30247        assert!(
30248            preview.files[0].summary_refs[0]
30249                .handle
30250                .starts_with("cdsum-")
30251        );
30252        assert_eq!(
30253            preview.files[0].summary_refs[0].tag_alias.as_deref(),
30254            Some("alpha/he...")
30255        );
30256        assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
30257        assert_eq!(
30258            preview.files[0].summary_refs[0].expand,
30259            "tsift summarize --file \"src/lib.rs\""
30260        );
30261        assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
30262    }
30263
30264    #[test]
30265    fn context_pack_status_reminders_include_stale_index_state() {
30266        let dir = setup_graph_index();
30267        std::thread::sleep(std::time::Duration::from_millis(50));
30268        std::fs::write(
30269            dir.path().join("main.rs"),
30270            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
30271        )
30272        .unwrap();
30273
30274        let reminders = context_pack_status_reminders(dir.path());
30275
30276        assert_eq!(reminders.len(), 1);
30277        assert!(reminders[0].contains("index stale"));
30278        assert!(reminders[0].contains("tsift index ."));
30279    }
30280
30281    // #gdbgatecold regression-lock: the trusted context-pack pipeline must
30282    // share its index-inspection across `prepare_agent_doc_index_gate` and
30283    // `context_pack_status_reminders` (both call `IndexDb::inspect_read_only`
30284    // on the same `(root, .tsift/index.db)` key). With the scope guard
30285    // active in `build_context_pack_report_with_profile`, the second call
30286    // hits the cache, so we should record one miss and at least one hit.
30287    #[test]
30288    fn build_context_pack_reuses_inspect_within_scope() {
30289        let dir = setup_graph_index();
30290        init_git_repo(dir.path());
30291        let _guard = index::InspectScopeGuard::new();
30292        let _ = build_context_pack_report(
30293            dir.path(),
30294            None,
30295            None,
30296            None,
30297            ResponseBudget::new(Some(2), Some(96)),
30298        )
30299        .unwrap();
30300        let (hits, misses) = index::inspect_scope_stats();
30301        assert!(
30302            hits >= 1,
30303            "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
30304        );
30305        assert!(
30306            misses >= 1,
30307            "expected at least one initial inspect miss (hits={hits}, misses={misses})"
30308        );
30309    }
30310
30311    // #gdbgatecold scope-isolation: outside of any scope, every call to
30312    // `IndexDb::inspect_read_only` must hit the disk fresh. This locks in
30313    // the contract that the search/status fast-paths never reuse a cached
30314    // inspection across consecutive top-level calls.
30315    #[test]
30316    fn inspect_read_only_outside_scope_does_not_cache() {
30317        let dir = setup_graph_index();
30318        let db_path = dir.path().join(".tsift/index.db");
30319        let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30320        let (hits, misses) = index::inspect_scope_stats();
30321        assert_eq!(
30322            (hits, misses),
30323            (0, 0),
30324            "no scope guard => no hits/misses recorded"
30325        );
30326        let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30327        let (hits, _) = index::inspect_scope_stats();
30328        assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
30329    }
30330
30331    #[test]
30332    fn context_pack_refreshes_stale_index_before_handoff() {
30333        let dir = setup_graph_index();
30334        init_git_repo(dir.path());
30335        std::thread::sleep(std::time::Duration::from_millis(50));
30336        std::fs::write(
30337            dir.path().join("main.rs"),
30338            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
30339        )
30340        .unwrap();
30341
30342        let report = build_context_pack_report(
30343            dir.path(),
30344            None,
30345            None,
30346            None,
30347            ResponseBudget::new(Some(2), Some(96)),
30348        )
30349        .unwrap();
30350
30351        assert!(
30352            report
30353                .status_reminders
30354                .iter()
30355                .any(|reminder| reminder.contains("index refreshed")
30356                    && reminder.contains("context-pack handoff")),
30357            "expected context-pack refresh diagnostic, got {:?}",
30358            report.status_reminders
30359        );
30360        assert!(
30361            !report
30362                .status_reminders
30363                .iter()
30364                .any(|reminder| reminder.contains("index stale")),
30365            "stale reminder should be gone after refresh: {:?}",
30366            report.status_reminders
30367        );
30368
30369        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
30370        let summary = db.compute_changes(dir.path()).unwrap();
30371        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
30372    }
30373
30374    #[test]
30375    fn context_pack_materializes_source_handles_into_graph_store() {
30376        let dir = tempfile::tempdir().unwrap();
30377        let packet = ExplorationPacket {
30378            budget: exploration_budget_for_counts(2, 1),
30379            relationship_map: vec![ExplorationRelation {
30380                from: "file:main.rs".to_string(),
30381                relation: "touches_symbol".to_string(),
30382                to: "symbol:helper".to_string(),
30383                label: Some("modified diff".to_string()),
30384            }],
30385            source_windows: vec![ExplorationSourceWindow {
30386                handle: "xwin-test".to_string(),
30387                file: "main.rs".to_string(),
30388                start: 1,
30389                end: 32,
30390                reason: "changed file".to_string(),
30391                expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
30392            }],
30393            worker_context: vec![ExplorationWorkerContext {
30394                handle: "xwrk-test".to_string(),
30395                target: "tasks/software/tsift.md".to_string(),
30396                summary: "do #kgnv".to_string(),
30397                expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
30398                    .to_string(),
30399            }],
30400            no_reread_guidance: "use windows".to_string(),
30401        };
30402
30403        let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
30404        assert_eq!(packet.source_windows[0].handle, "xwin-test");
30405
30406        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
30407        let source_handles = store.nodes_by_kind("source_handle").unwrap();
30408        assert_eq!(source_handles.len(), 1);
30409        assert_eq!(
30410            source_handles[0].properties.get("file"),
30411            Some(&"main.rs".to_string())
30412        );
30413        assert_eq!(
30414            store
30415                .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
30416                .unwrap()
30417                .len(),
30418            1
30419        );
30420        let worker_context = store.nodes_by_kind("worker_context").unwrap();
30421        assert_eq!(worker_context.len(), 1);
30422        assert_eq!(
30423            store
30424                .outgoing_edges("xwrk-test", Some("scopes_source"))
30425                .unwrap()
30426                .len(),
30427            1
30428        );
30429    }
30430
30431    #[test]
30432    fn context_pack_records_graph_orchestration_observability() {
30433        let dir = setup_traversal_project();
30434        init_git_repo(dir.path());
30435        let session = dir.path().join("tasks/software/tsift.md");
30436        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
30437
30438        let report = build_context_pack_report(
30439            &session,
30440            None,
30441            None,
30442            None,
30443            ResponseBudget::new(Some(4), Some(160)),
30444        )
30445        .unwrap();
30446
30447        assert_eq!(
30448            report.graph_orchestration.contract_version,
30449            CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
30450        );
30451        assert_eq!(
30452            report
30453                .graph_orchestration
30454                .projection_freshness
30455                .status
30456                .as_str(),
30457            "current"
30458        );
30459        assert!(!report.graph_orchestration.projection_hashes.is_empty());
30460        assert_eq!(report.graph_orchestration.readiness.status, "blocked");
30461        assert_eq!(
30462            report.graph_orchestration.readiness.reason,
30463            "summary_cache_empty"
30464        );
30465        assert!(report.graph_orchestration.readiness.fail_closed);
30466        assert!(
30467            report
30468                .graph_orchestration
30469                .readiness
30470                .next_commands
30471                .iter()
30472                .any(|command| command == "tsift summarize --extract ."),
30473            "{:?}",
30474            report.graph_orchestration.readiness.next_commands
30475        );
30476        assert!(
30477            report
30478                .graph_orchestration
30479                .evidence_packet_ids
30480                .iter()
30481                .all(|id| !id.starts_with("gevd-")),
30482            "evidence packet ids should be empty when readiness is blocked: {:?}",
30483            report.graph_orchestration.evidence_packet_ids
30484        );
30485        assert!(
30486            report
30487                .graph_orchestration
30488                .conflict_matrix_decisions
30489                .iter()
30490                .any(|decision| decision.contains("readiness blocked")),
30491            "conflict-matrix decisions should reference readiness block: {:?}",
30492            report.graph_orchestration.conflict_matrix_decisions
30493        );
30494        assert!(
30495            !report
30496                .graph_orchestration
30497                .follow_up_commands
30498                .iter()
30499                .any(|command| command.contains("conflict-matrix")),
30500            "conflict-matrix command should not appear when readiness is blocked: {:?}",
30501            report.graph_orchestration.follow_up_commands
30502        );
30503        assert!(
30504            report
30505                .graph_orchestration
30506                .follow_up_commands
30507                .iter()
30508                .any(|command| command == "tsift summarize --extract ."),
30509            "{:?}",
30510            report.graph_orchestration.follow_up_commands
30511        );
30512        assert!(
30513            !report
30514                .graph_orchestration
30515                .worker_ownership_blocks
30516                .is_empty()
30517        );
30518    }
30519
30520    #[test]
30521    fn convex_sync_report_chunks_upserts_and_tombstones() {
30522        let dir = setup_traversal_project();
30523        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
30524        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
30525        let mut snapshot = projection.to_convex_rows();
30526        snapshot.nodes.push(ConvexNodeRow {
30527            external_id: "stale-node".to_string(),
30528            kind: "backlog".to_string(),
30529            label: "stale".to_string(),
30530            properties: BTreeMap::new(),
30531            provenance: Vec::new(),
30532            freshness: None,
30533        });
30534        snapshot.edges.clear();
30535        snapshot.edges.push(ConvexEdgeRow {
30536            edge_key: "stale-edge".to_string(),
30537            from_external_id: "stale-node".to_string(),
30538            to_external_id: "stale-node".to_string(),
30539            kind: "mentions".to_string(),
30540            properties: BTreeMap::new(),
30541            provenance: Vec::new(),
30542            freshness: None,
30543        });
30544        let snapshot_path = dir.path().join("convex-snapshot.json");
30545        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30546
30547        let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
30548
30549        assert_eq!(report.freshness.status, "stale");
30550        assert!(report.freshness.fail_closed);
30551        assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
30552        assert!(
30553            report.edge_upserts.len() > 1,
30554            "snapshot without edges should upsert local edges"
30555        );
30556        assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
30557        assert_eq!(
30558            report.chunks.first().map(|chunk| chunk.operation.as_str()),
30559            Some("delete_edges"),
30560            "edge tombstones should be planned before node tombstones"
30561        );
30562        assert!(
30563            report
30564                .chunks
30565                .iter()
30566                .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
30567            "expected chunked edge upserts, got {:?}",
30568            report.chunks
30569        );
30570    }
30571
30572    #[test]
30573    fn convex_snapshot_validation_fails_closed_when_stale() {
30574        let dir = setup_traversal_project();
30575        build_traversal_graph(dir.path(), dir.path(), None).unwrap();
30576        let snapshot = ConvexProjectionRows::default();
30577        let snapshot_path = dir.path().join("empty-convex-snapshot.json");
30578        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30579
30580        let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
30581        assert!(
30582            err.to_string()
30583                .contains("Convex graph projection is not current"),
30584            "{err}"
30585        );
30586    }
30587
30588    #[test]
30589    fn convex_sync_report_marks_live_apply_mode_without_network() {
30590        let dir = setup_traversal_project();
30591        let report =
30592            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30593
30594        assert!(!report.dry_run);
30595        assert!(
30596            !report
30597                .diagnostics
30598                .iter()
30599                .any(|diagnostic| diagnostic.contains("dry-run only")),
30600            "apply-mode report should not claim dry-run diagnostics"
30601        );
30602        assert!(
30603            report
30604                .chunks
30605                .iter()
30606                .any(|chunk| chunk.operation == "upsert_nodes"),
30607            "live apply mode should still expose chunked idempotent operations"
30608        );
30609    }
30610
30611    #[test]
30612    fn convex_sync_apply_round_trips_with_http_backend() {
30613        use std::net::TcpListener;
30614        use std::sync::{Arc, Mutex};
30615
30616        let dir = setup_traversal_project();
30617        let report =
30618            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30619        let expected_chunks = report.chunks.len();
30620        assert!(expected_chunks > 0);
30621
30622        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
30623        let endpoint = format!("http://{}", listener.local_addr().unwrap());
30624        let operations = Arc::new(Mutex::new(Vec::<String>::new()));
30625        let server_operations = Arc::clone(&operations);
30626        let server = std::thread::spawn(move || {
30627            for _ in 0..expected_chunks {
30628                let (mut stream, _) = listener.accept().unwrap();
30629                let mut reader = BufReader::new(stream.try_clone().unwrap());
30630                let mut request_line = String::new();
30631                reader.read_line(&mut request_line).unwrap();
30632                assert!(request_line.starts_with("POST "));
30633
30634                let mut content_length = 0usize;
30635                loop {
30636                    let mut line = String::new();
30637                    reader.read_line(&mut line).unwrap();
30638                    if line == "\r\n" {
30639                        break;
30640                    }
30641                    if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
30642                        content_length = value.trim().parse().unwrap();
30643                    }
30644                }
30645
30646                let mut body = vec![0u8; content_length];
30647                reader.read_exact(&mut body).unwrap();
30648                let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
30649                server_operations
30650                    .lock()
30651                    .unwrap()
30652                    .push(request["operation"].as_str().unwrap().to_string());
30653
30654                let response = br#"{"status":"ok","message":"accepted"}"#;
30655                write!(
30656                    stream,
30657                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
30658                    response.len()
30659                )
30660                .unwrap();
30661                stream.write_all(response).unwrap();
30662            }
30663        });
30664
30665        cmd_convex_sync(
30666            ConvexSyncOptions {
30667                path: dir.path(),
30668                scope: None,
30669                snapshot: None,
30670                chunk_size: 100,
30671                remote_snapshot: false,
30672                apply: true,
30673                endpoint: Some(&endpoint),
30674                auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
30675            },
30676            OutputFormat {
30677                json_output: false,
30678                compact: true,
30679                pretty: false,
30680                terse: false,
30681                ultra_terse: false,
30682                schema: false,
30683                envelope: false,
30684            },
30685        )
30686        .unwrap();
30687        server.join().unwrap();
30688
30689        let operations = operations.lock().unwrap().clone();
30690        assert!(operations.contains(&"upsert_nodes".to_string()));
30691        assert!(operations.contains(&"upsert_edges".to_string()));
30692    }
30693
30694    #[test]
30695    fn context_pack_diff_preview_attaches_tag_ontology_refs() {
30696        let root = tempfile::tempdir().unwrap();
30697        fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
30698        fs::write(
30699            root.path().join(".naming/tags/alpha.md"),
30700            "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
30701        )
30702        .unwrap();
30703        let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
30704        let report = diff_digest::DiffDigestReport {
30705            root: root.path().display().to_string(),
30706            mode: diff_digest::DiffDigestMode::WorkingTree,
30707            revision: None,
30708            files_changed: 1,
30709            files_with_current_summaries: 1,
30710            symbols_touched: 1,
30711            call_edges_added: 0,
30712            call_edges_removed: 0,
30713            files: vec![diff_digest::DiffDigestFile {
30714                path: "src/lib.rs".to_string(),
30715                status: diff_digest::DiffDigestFileStatus::Modified,
30716                touched_symbols: vec!["alpha_helper".to_string()],
30717                summary_state: diff_digest::DiffDigestSummaryState::Current,
30718                current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30719                    symbol: "alpha_helper".to_string(),
30720                    summary: "alpha helper summary".to_string(),
30721                }],
30722                added_call_edges: vec![],
30723                removed_call_edges: vec![],
30724                warnings: vec![],
30725            }],
30726        };
30727
30728        let preview = build_context_pack_diff_preview(
30729            &report,
30730            ResponseBudget::new(Some(1), Some(80)),
30731            Some(&ontology),
30732        );
30733
30734        let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
30735        assert!(symbol_ref.handle.starts_with("tont-"));
30736        assert_eq!(symbol_ref.tag, "alpha");
30737        assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
30738        assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
30739        assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
30740        assert_eq!(
30741            preview.files[0].summary_refs[0].ontology_refs[0].path,
30742            ".naming/tags/alpha.md"
30743        );
30744    }
30745
30746    #[test]
30747    fn context_pack_test_preview_limits_failure_groups() {
30748        let report = test_digest::TestDigestReport {
30749            root: "/repo".to_string(),
30750            runner: "cargo".to_string(),
30751            failures: 2,
30752            grouped_failures: 2,
30753            counts: test_digest::TestDigestCounts {
30754                passed: Some(8),
30755                failed: Some(2),
30756                skipped: Some(1),
30757            },
30758            failure_groups: vec![
30759                test_digest::TestDigestFailure {
30760                    tests: vec!["suite::alpha_failure".to_string()],
30761                    message: "assertion failed".to_string(),
30762                    path: Some("src/lib.rs".to_string()),
30763                    line: Some(42),
30764                    column: None,
30765                    occurrences: 1,
30766                    summary_state: test_digest::TestDigestSummaryState::Current,
30767                    current_summaries: vec![test_digest::TestDigestSummarySnippet {
30768                        symbol: "alpha_failure".to_string(),
30769                        summary: "failure summary for alpha test".to_string(),
30770                    }],
30771                },
30772                test_digest::TestDigestFailure {
30773                    tests: vec!["suite::beta_failure".to_string()],
30774                    message: "panic".to_string(),
30775                    path: Some("src/main.rs".to_string()),
30776                    line: Some(7),
30777                    column: None,
30778                    occurrences: 1,
30779                    summary_state: test_digest::TestDigestSummaryState::Missing,
30780                    current_summaries: vec![],
30781                },
30782            ],
30783            warnings: vec!["warning text".to_string()],
30784        };
30785
30786        let preview =
30787            build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30788
30789        assert!(preview.truncated);
30790        assert_eq!(preview.failure_groups.len(), 1);
30791        assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
30792        assert_eq!(preview.failure_groups[0].message, "assertion f...");
30793        assert!(
30794            preview.failure_groups[0].summary_refs[0]
30795                .handle
30796                .starts_with("ctsum-")
30797        );
30798        assert_eq!(
30799            preview.failure_groups[0].summary_refs[0].expand,
30800            "tsift summarize --file \"src/lib.rs\""
30801        );
30802        assert_eq!(preview.warnings, vec!["warning text"]);
30803    }
30804
30805    #[test]
30806    fn context_pack_log_preview_limits_signals_and_refs() {
30807        let report = log_digest::LogDigestReport {
30808            root: "/repo".to_string(),
30809            total_lines: 12,
30810            non_empty_lines: 10,
30811            signal_groups: 2,
30812            repeated_line_groups: 2,
30813            repeated_line_occurrences: 3,
30814            file_ref_groups: 2,
30815            symbol_ref_groups: 2,
30816            stack_groups: 1,
30817            signals: vec![
30818                log_digest::LogDigestSignal {
30819                    severity: "error".to_string(),
30820                    message: "src/lib.rs:42 boom".to_string(),
30821                    path: Some("src/lib.rs".to_string()),
30822                    line: Some(42),
30823                    column: None,
30824                    occurrences: 2,
30825                    summary_state: log_digest::LogDigestSummaryState::Current,
30826                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
30827                        symbol: "alpha_helper".to_string(),
30828                        summary: "alpha helper cached log summary".to_string(),
30829                    }],
30830                },
30831                log_digest::LogDigestSignal {
30832                    severity: "warn".to_string(),
30833                    message: "slow path".to_string(),
30834                    path: None,
30835                    line: None,
30836                    column: None,
30837                    occurrences: 1,
30838                    summary_state: log_digest::LogDigestSummaryState::Unavailable,
30839                    current_summaries: vec![],
30840                },
30841            ],
30842            repeated_lines: vec![
30843                log_digest::LogDigestRepeatedLine {
30844                    line: "retrying work item alpha".to_string(),
30845                    occurrences: 3,
30846                },
30847                log_digest::LogDigestRepeatedLine {
30848                    line: "retrying work item beta".to_string(),
30849                    occurrences: 2,
30850                },
30851            ],
30852            file_refs: vec![
30853                log_digest::LogDigestFileRef {
30854                    path: "src/lib.rs".to_string(),
30855                    line: Some(42),
30856                    column: None,
30857                    occurrences: 2,
30858                    summary_state: log_digest::LogDigestSummaryState::Current,
30859                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
30860                        symbol: "alpha_helper".to_string(),
30861                        summary: "alpha helper cached file summary".to_string(),
30862                    }],
30863                },
30864                log_digest::LogDigestFileRef {
30865                    path: "src/main.rs".to_string(),
30866                    line: Some(7),
30867                    column: None,
30868                    occurrences: 1,
30869                    summary_state: log_digest::LogDigestSummaryState::Missing,
30870                    current_summaries: vec![],
30871                },
30872            ],
30873            symbol_refs: vec![
30874                log_digest::LogDigestSymbolRef {
30875                    symbol: "alpha_helper".to_string(),
30876                    occurrences: 2,
30877                    summary_state: log_digest::LogDigestSummaryState::Current,
30878                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
30879                        symbol: "alpha_helper".to_string(),
30880                        summary: "alpha helper cached symbol summary".to_string(),
30881                    }],
30882                },
30883                log_digest::LogDigestSymbolRef {
30884                    symbol: "beta_helper".to_string(),
30885                    occurrences: 1,
30886                    summary_state: log_digest::LogDigestSummaryState::Missing,
30887                    current_summaries: vec![],
30888                },
30889            ],
30890            stack_traces: vec![log_digest::LogDigestStackGroup {
30891                frames: vec!["frame one".to_string()],
30892                occurrences: 1,
30893            }],
30894            warnings: vec!["warning text".to_string()],
30895        };
30896
30897        let preview =
30898            build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30899
30900        assert!(preview.truncated);
30901        assert_eq!(preview.signals.len(), 1);
30902        assert_eq!(preview.signals[0].message, "src/lib.rs:...");
30903        assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
30904        assert_eq!(preview.file_refs.len(), 1);
30905        assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
30906        assert!(
30907            preview.signals[0].summary_refs[0]
30908                .handle
30909                .starts_with("clsum-")
30910        );
30911        assert!(
30912            preview.file_refs[0].summary_refs[0]
30913                .handle
30914                .starts_with("clfsum-")
30915        );
30916        assert!(
30917            preview.symbol_refs[0].summary_refs[0]
30918                .handle
30919                .starts_with("clssum-")
30920        );
30921        assert_eq!(
30922            preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
30923            Some("alpha/helper")
30924        );
30925        assert_eq!(
30926            preview.symbol_refs[0].summary_refs[0].expand,
30927            "tsift summarize \"alpha_helper\""
30928        );
30929        assert_eq!(preview.warnings, vec!["warning text"]);
30930    }
30931
30932    #[test]
30933    fn cli_search_rejects_exact_with_strategy_flag() {
30934        let cli = try_parse_cli([
30935            "tsift",
30936            "search",
30937            "test",
30938            "--exact",
30939            "--strategy",
30940            "lexical",
30941        ]);
30942        assert!(cli.is_err());
30943    }
30944
30945    #[test]
30946    fn cli_search_autoindexes_by_default() {
30947        let cli = parse_cli(["tsift", "search", "test"]);
30948        match cli.command {
30949            Some(Commands::Search {
30950                autoindex,
30951                no_autoindex,
30952                ..
30953            }) => {
30954                assert!(!autoindex);
30955                assert!(!no_autoindex);
30956                assert!(autoindex || !no_autoindex);
30957            }
30958            _ => panic!("expected Search command"),
30959        }
30960    }
30961
30962    #[test]
30963    fn cli_search_accepts_no_autoindex_flag() {
30964        let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
30965        match cli.command {
30966            Some(Commands::Search {
30967                autoindex,
30968                no_autoindex,
30969                ..
30970            }) => {
30971                assert!(!autoindex);
30972                assert!(no_autoindex);
30973            }
30974            _ => panic!("expected Search command"),
30975        }
30976    }
30977
30978    #[test]
30979    fn cli_search_rejects_conflicting_autoindex_flags() {
30980        let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
30981        assert!(cli.is_err());
30982    }
30983
30984    // --- relativize paths ---
30985
30986    #[test]
30987    fn cli_accepts_global_absolute_flag() {
30988        let cli = parse_cli(["tsift", "--absolute", "status"]);
30989        assert!(cli.absolute);
30990        assert!(matches!(cli.command, Some(Commands::Status { .. })));
30991    }
30992
30993    #[test]
30994    fn cli_accepts_global_tabular_flag() {
30995        let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
30996        assert!(cli.tabular);
30997        assert!(matches!(cli.command, Some(Commands::Search { .. })));
30998    }
30999
31000    #[test]
31001    fn cli_tabular_with_graph() {
31002        let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
31003        assert!(cli.tabular);
31004        assert!(matches!(cli.command, Some(Commands::Graph { .. })));
31005    }
31006
31007    #[test]
31008    fn cli_tabular_with_communities() {
31009        let cli = parse_cli(["tsift", "--tabular", "communities"]);
31010        assert!(cli.tabular);
31011        assert!(matches!(cli.command, Some(Commands::Communities { .. })));
31012    }
31013
31014    #[test]
31015    fn cli_tabular_with_explain() {
31016        let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
31017        assert!(cli.tabular);
31018        assert!(matches!(cli.command, Some(Commands::Explain { .. })));
31019    }
31020
31021    #[test]
31022    fn cli_traverse_accepts_path_target_and_html_format() {
31023        let cli = parse_cli([
31024            "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
31025        ]);
31026        match cli.command {
31027            Some(Commands::Traverse {
31028                node,
31029                to,
31030                path,
31031                format,
31032                ..
31033            }) => {
31034                assert_eq!(node.as_deref(), Some("#kgnv"));
31035                assert_eq!(to.as_deref(), Some("main"));
31036                assert_eq!(path, PathBuf::from("."));
31037                assert_eq!(format, TraverseFormat::Html);
31038            }
31039            _ => panic!("expected Traverse command"),
31040        }
31041    }
31042
31043    #[test]
31044    fn cli_parses_semantic_related_command() {
31045        let cli = parse_cli([
31046            "tsift",
31047            "semantic",
31048            "graph navigation",
31049            "--path",
31050            ".",
31051            "--kind",
31052            "all",
31053            "--limit",
31054            "3",
31055            "--json",
31056        ]);
31057        match cli.command {
31058            Some(Commands::Semantic {
31059                query,
31060                path,
31061                kind,
31062                limit,
31063                json,
31064                ..
31065            }) => {
31066                assert_eq!(query, "graph navigation");
31067                assert_eq!(path, PathBuf::from("."));
31068                assert_eq!(kind, SemanticRelatedKind::All);
31069                assert_eq!(limit, 3);
31070                assert!(json);
31071            }
31072            _ => panic!("expected Semantic command"),
31073        }
31074    }
31075
31076    #[test]
31077    fn cli_parses_convex_sync_command() {
31078        let cli = parse_cli([
31079            "tsift",
31080            "convex-sync",
31081            ".",
31082            "--snapshot",
31083            "rows.json",
31084            "--chunk-size",
31085            "25",
31086            "--json",
31087        ]);
31088        match cli.command {
31089            Some(Commands::ConvexSync {
31090                path,
31091                snapshot,
31092                chunk_size,
31093                json,
31094                ..
31095            }) => {
31096                assert_eq!(path, PathBuf::from("."));
31097                assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
31098                assert_eq!(chunk_size, 25);
31099                assert!(json);
31100            }
31101            _ => panic!("expected ConvexSync command"),
31102        }
31103    }
31104
31105    #[test]
31106    fn cli_parses_convex_sync_live_flags() {
31107        let cli = parse_cli([
31108            "tsift",
31109            "convex-sync",
31110            ".",
31111            "--remote-snapshot",
31112            "--apply",
31113            "--endpoint",
31114            "https://example.test/convex-graph",
31115            "--auth-token-env",
31116            "TSIFT_TEST_TOKEN",
31117        ]);
31118        match cli.command {
31119            Some(Commands::ConvexSync {
31120                remote_snapshot,
31121                apply,
31122                endpoint,
31123                auth_token_env,
31124                ..
31125            }) => {
31126                assert!(remote_snapshot);
31127                assert!(apply);
31128                assert_eq!(
31129                    endpoint.as_deref(),
31130                    Some("https://example.test/convex-graph")
31131                );
31132                assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
31133            }
31134            _ => panic!("expected ConvexSync command"),
31135        }
31136    }
31137
31138    #[test]
31139    fn cli_parses_graph_db_query() {
31140        let cli = parse_cli([
31141            "tsift",
31142            "graph-db",
31143            "--backend",
31144            "convex-snapshot",
31145            "--convex-snapshot",
31146            "rows.json",
31147            "--json",
31148            "neighborhood",
31149            "gbak-kgnv",
31150            "--depth",
31151            "2",
31152            "--edge-kind",
31153            "mentions",
31154            "--property",
31155            "path=tasks/software/tsift.md",
31156            "--cursor",
31157            "gbak-old",
31158            "--limit",
31159            "10",
31160        ]);
31161        match cli.command {
31162            Some(Commands::GraphDb {
31163                backend,
31164                convex_snapshot,
31165                json,
31166                query,
31167                ..
31168            }) => {
31169                assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
31170                assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
31171                assert!(json);
31172                match query {
31173                    GraphDbQuery::Neighborhood {
31174                        id,
31175                        depth,
31176                        edge_kind,
31177                        cursor,
31178                        limit,
31179                        property_filters,
31180                    } => {
31181                        assert_eq!(id, "gbak-kgnv");
31182                        assert_eq!(depth, 2);
31183                        assert_eq!(edge_kind.as_deref(), Some("mentions"));
31184                        assert_eq!(cursor.as_deref(), Some("gbak-old"));
31185                        assert_eq!(limit, Some(10));
31186                        assert_eq!(
31187                            property_filters,
31188                            vec!["path=tasks/software/tsift.md".to_string()]
31189                        );
31190                    }
31191                    _ => panic!("expected graph-db neighborhood query"),
31192                }
31193            }
31194            _ => panic!("expected GraphDb command"),
31195        }
31196    }
31197
31198    #[test]
31199    fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
31200        let cli = parse_cli([
31201            "tsift",
31202            "graph-db",
31203            "--json",
31204            "backend-eval",
31205            "--candidate",
31206            "surrealdb",
31207            "--target",
31208            "gval",
31209            "--full-projection",
31210        ]);
31211        match cli.command {
31212            Some(Commands::GraphDb { json, query, .. }) => {
31213                assert!(json);
31214                match query {
31215                    GraphDbQuery::BackendEval {
31216                        candidates,
31217                        targets,
31218                        full_projection,
31219                    } => {
31220                        assert_eq!(candidates, vec!["surrealdb".to_string()]);
31221                        assert_eq!(targets, vec!["gval".to_string()]);
31222                        assert!(full_projection);
31223                    }
31224                    _ => panic!("expected graph-db backend-eval query"),
31225                }
31226            }
31227            _ => panic!("expected GraphDb command"),
31228        }
31229    }
31230
31231    #[test]
31232    fn cli_parses_graph_db_tokensave_backend() {
31233        let cli = parse_cli([
31234            "tsift",
31235            "graph-db",
31236            "--backend",
31237            "tokensave",
31238            "--json",
31239            "node",
31240            "fn:main",
31241        ]);
31242        match cli.command {
31243            Some(Commands::GraphDb {
31244                backend,
31245                json,
31246                query,
31247                ..
31248            }) => {
31249                assert_eq!(backend, GraphDbBackend::Tokensave);
31250                assert!(json);
31251                match query {
31252                    GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
31253                    _ => panic!("expected graph-db node query"),
31254                }
31255            }
31256            _ => panic!("expected GraphDb command"),
31257        }
31258    }
31259
31260    #[test]
31261    fn cli_parses_analyze_command() {
31262        let cli = parse_cli([
31263            "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
31264            "--limit", "7", "--json",
31265        ]);
31266        match cli.command {
31267            Some(Commands::Analyze {
31268                path,
31269                scope,
31270                entry_points,
31271                limit,
31272                json,
31273            }) => {
31274                assert_eq!(path, PathBuf::from("."));
31275                assert_eq!(scope.as_deref(), Some("core"));
31276                assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
31277                assert_eq!(limit, 7);
31278                assert!(json);
31279            }
31280            _ => panic!("expected Analyze command"),
31281        }
31282    }
31283
31284    #[test]
31285    fn cli_parses_graph_db_related_query() {
31286        let cli = parse_cli([
31287            "tsift",
31288            "graph-db",
31289            "--json",
31290            "related",
31291            "voice avatar memory retrieval",
31292            "--kind",
31293            "all",
31294            "--depth",
31295            "3",
31296            "--seed-limit",
31297            "4",
31298            "--limit",
31299            "12",
31300        ]);
31301        match cli.command {
31302            Some(Commands::GraphDb { json, query, .. }) => {
31303                assert!(json);
31304                match query {
31305                    GraphDbQuery::Related {
31306                        query,
31307                        kind,
31308                        depth,
31309                        seed_limit,
31310                        limit,
31311                    } => {
31312                        assert_eq!(query, "voice avatar memory retrieval");
31313                        assert_eq!(kind, SemanticRelatedKind::All);
31314                        assert_eq!(depth, 3);
31315                        assert_eq!(seed_limit, 4);
31316                        assert_eq!(limit, 12);
31317                    }
31318                    _ => panic!("expected graph-db related query"),
31319                }
31320            }
31321            _ => panic!("expected GraphDb command"),
31322        }
31323    }
31324
31325    #[test]
31326    fn cli_parses_graph_db_compact_query() {
31327        let cli = parse_cli([
31328            "tsift",
31329            "graph-db",
31330            "--path",
31331            ".",
31332            "compact",
31333            "--apply",
31334            "--prune-tombstones",
31335            "--confirmed-convex-reconciled",
31336        ]);
31337        match cli.command {
31338            Some(Commands::GraphDb { query, .. }) => match query {
31339                GraphDbQuery::Compact {
31340                    apply,
31341                    prune_tombstones,
31342                    confirmed_convex_reconciled,
31343                } => {
31344                    assert!(apply);
31345                    assert!(prune_tombstones);
31346                    assert!(confirmed_convex_reconciled);
31347                }
31348                _ => panic!("expected graph-db compact query"),
31349            },
31350            _ => panic!("expected GraphDb command"),
31351        }
31352    }
31353
31354    #[test]
31355    fn cli_parses_impact_command() {
31356        let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
31357        match cli.command {
31358            Some(Commands::Impact {
31359                path,
31360                cached,
31361                limit,
31362                ..
31363            }) => {
31364                assert_eq!(path, PathBuf::from("."));
31365                assert!(cached);
31366                assert_eq!(limit, 5);
31367            }
31368            _ => panic!("expected Impact command"),
31369        }
31370    }
31371
31372    #[test]
31373    fn cli_parses_conflict_matrix_command() {
31374        let cli = parse_cli([
31375            "tsift",
31376            "conflict-matrix",
31377            "--path",
31378            "tasks/software/tsift.md",
31379            "--depth",
31380            "4",
31381            "--limit",
31382            "12",
31383            "--impact-limit",
31384            "6",
31385            "--json",
31386            "pwcm",
31387            "#g6kf",
31388        ]);
31389        match cli.command {
31390            Some(Commands::ConflictMatrix {
31391                targets,
31392                path,
31393                depth,
31394                limit,
31395                impact_limit,
31396                json,
31397                ..
31398            }) => {
31399                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31400                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31401                assert_eq!(depth, 4);
31402                assert_eq!(limit, 12);
31403                assert_eq!(impact_limit, 6);
31404                assert!(json);
31405            }
31406            _ => panic!("expected ConflictMatrix command"),
31407        }
31408    }
31409
31410    #[test]
31411    fn cli_parses_dispatch_trace_command() {
31412        let cli = parse_cli([
31413            "tsift",
31414            "dispatch-trace",
31415            "--path",
31416            "tasks/software/tsift.md",
31417            "--format",
31418            "html",
31419            "--depth",
31420            "4",
31421            "pwcm",
31422            "#g6kf",
31423        ]);
31424        match cli.command {
31425            Some(Commands::DispatchTrace {
31426                targets,
31427                path,
31428                format,
31429                depth,
31430                ..
31431            }) => {
31432                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31433                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31434                assert_eq!(format, DispatchTraceFormat::Html);
31435                assert_eq!(depth, 4);
31436            }
31437            _ => panic!("expected DispatchTrace command"),
31438        }
31439    }
31440
31441    #[test]
31442    fn cli_parses_dependency_dag_command() {
31443        let cli = parse_cli([
31444            "tsift",
31445            "dependency-dag",
31446            "--path",
31447            "tasks/software/tsift.md",
31448            "--depth",
31449            "5",
31450            "--limit",
31451            "20",
31452            "--json",
31453            "alpha",
31454            "#beta",
31455        ]);
31456        match cli.command {
31457            Some(Commands::DependencyDag {
31458                targets,
31459                path,
31460                depth,
31461                limit,
31462                json,
31463                ..
31464            }) => {
31465                assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
31466                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31467                assert_eq!(depth, 5);
31468                assert_eq!(limit, 20);
31469                assert!(json);
31470            }
31471            _ => panic!("expected DependencyDag command"),
31472        }
31473    }
31474
31475    #[test]
31476    fn relativize_strips_root_prefix() {
31477        let root = std::path::Path::new("/home/user/project");
31478        assert_eq!(
31479            relativize("/home/user/project/src/main.rs", root),
31480            "src/main.rs"
31481        );
31482    }
31483
31484    #[test]
31485    fn relativize_leaves_non_matching_path() {
31486        let root = std::path::Path::new("/home/user/project");
31487        assert_eq!(
31488            relativize("/other/path/file.rs", root),
31489            "/other/path/file.rs"
31490        );
31491    }
31492
31493    #[test]
31494    fn relativize_leaves_already_relative() {
31495        let root = std::path::Path::new("/home/user/project");
31496        assert_eq!(relativize("src/main.rs", root), "src/main.rs");
31497    }
31498
31499    #[test]
31500    fn relativize_pathbuf_strips_prefix() {
31501        let root = std::path::Path::new("/home/user/project");
31502        let path = std::path::Path::new("/home/user/project/src/lib.rs");
31503        assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
31504    }
31505
31506    #[test]
31507    fn relativize_edges_strips_caller_file() {
31508        let root = std::path::Path::new("/tmp/proj");
31509        let mut edges = vec![index::StoredEdge {
31510            caller_file: "/tmp/proj/src/main.rs".to_string(),
31511            caller_name: "main".to_string(),
31512            caller_line: 1,
31513            callee_name: "helper".to_string(),
31514            call_site_line: 5,
31515            tagpath_handle: None,
31516        }];
31517        relativize_edges(&mut edges, root);
31518        assert_eq!(edges[0].caller_file, "src/main.rs");
31519    }
31520
31521    #[test]
31522    fn relativize_json_paths_strips_known_keys() {
31523        let root = std::path::Path::new("/tmp/proj");
31524        let mut val = serde_json::json!({
31525            "file": "/tmp/proj/src/main.rs",
31526            "path": "/tmp/proj/test.rs",
31527            "name": "/tmp/proj/not-a-path",
31528            "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
31529        });
31530        relativize_json_paths(&mut val, root);
31531        assert_eq!(val["file"], "src/main.rs");
31532        assert_eq!(val["path"], "test.rs");
31533        assert_eq!(val["name"], "/tmp/proj/not-a-path");
31534        assert_eq!(val["hits"][0]["path"], "nested.rs");
31535    }
31536
31537    // --- limit caps ---
31538
31539    #[test]
31540    fn cli_graph_accepts_limit_flag() {
31541        let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
31542        match cli.command {
31543            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
31544            _ => panic!("expected Graph command"),
31545        }
31546    }
31547
31548    #[test]
31549    fn cli_graph_default_limit_is_20() {
31550        let cli = parse_cli(["tsift", "graph", "main"]);
31551        match cli.command {
31552            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
31553            _ => panic!("expected Graph command"),
31554        }
31555    }
31556
31557    #[test]
31558    fn cli_communities_accepts_limit_flag() {
31559        let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
31560        match cli.command {
31561            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
31562            _ => panic!("expected Communities command"),
31563        }
31564    }
31565
31566    #[test]
31567    fn cli_communities_default_limit_is_10() {
31568        let cli = parse_cli(["tsift", "communities"]);
31569        match cli.command {
31570            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
31571            _ => panic!("expected Communities command"),
31572        }
31573    }
31574
31575    #[test]
31576    fn cli_explain_accepts_limit_flag() {
31577        let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
31578        match cli.command {
31579            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
31580            _ => panic!("expected Explain command"),
31581        }
31582    }
31583
31584    #[test]
31585    fn cli_explain_default_limit_is_15() {
31586        let cli = parse_cli(["tsift", "explain", "main"]);
31587        match cli.command {
31588            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
31589            _ => panic!("expected Explain command"),
31590        }
31591    }
31592
31593    #[test]
31594    fn cli_limit_zero_means_unlimited() {
31595        let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
31596        match cli.command {
31597            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
31598            _ => panic!("expected Graph command"),
31599        }
31600    }
31601
31602    #[test]
31603    fn graph_cmd_limit_runs_ok() {
31604        let dir = setup_graph_index();
31605        let result = cmd_graph(
31606            "main",
31607            dir.path(),
31608            false,
31609            false,
31610            None,
31611            1,
31612            false,
31613            false,
31614            false,
31615            false,
31616            false,
31617            false,
31618            false,
31619            TagpathSearchOpts::default(),
31620        );
31621        assert!(result.is_ok());
31622    }
31623
31624    #[test]
31625    fn graph_cmd_unlimited_runs_ok() {
31626        let dir = setup_graph_index();
31627        let result = cmd_graph(
31628            "main",
31629            dir.path(),
31630            false,
31631            false,
31632            None,
31633            0,
31634            false,
31635            false,
31636            false,
31637            false,
31638            false,
31639            false,
31640            false,
31641            TagpathSearchOpts::default(),
31642        );
31643        assert!(result.is_ok());
31644    }
31645
31646    #[test]
31647    fn graph_cmd_tabular_runs_ok() {
31648        let dir = setup_graph_index();
31649        let result = cmd_graph(
31650            "main",
31651            dir.path(),
31652            false,
31653            false,
31654            None,
31655            20,
31656            false,
31657            false,
31658            false,
31659            false,
31660            false,
31661            true,
31662            false,
31663            TagpathSearchOpts::default(),
31664        );
31665        assert!(result.is_ok());
31666    }
31667
31668    #[test]
31669    fn communities_cmd_tabular_runs_ok() {
31670        let dir = setup_graph_index();
31671        let result = cmd_communities(
31672            dir.path(),
31673            None,
31674            1,
31675            10,
31676            false,
31677            false,
31678            false,
31679            false,
31680            true,
31681            false,
31682            TagpathSearchOpts::default(),
31683        );
31684        assert!(result.is_ok());
31685    }
31686
31687    #[test]
31688    fn explain_cmd_tabular_runs_ok() {
31689        let dir = setup_graph_index();
31690        let result = cmd_explain(
31691            "main",
31692            dir.path(),
31693            None,
31694            15,
31695            false,
31696            false,
31697            false,
31698            false,
31699            false,
31700            true,
31701            false,
31702            false,
31703        );
31704        assert!(result.is_ok());
31705    }
31706
31707    #[test]
31708    fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
31709        // #gdbcacheprove: .agent-doc runtime markdown (snapshots, baselines, archives,
31710        // session docs, runtime logs) must not contribute to the source watermark, or
31711        // every agent-doc cycle would invalidate the graph-db backend-eval cache and
31712        // force a full rebuild on the next run.
31713        let cases = [
31714            ".agent-doc",
31715            ".agent-doc/snapshots/abc.md",
31716            ".agent-doc/baselines/abc.md",
31717            ".agent-doc/archives/2026.md",
31718            ".agent-doc/runtime/run.jsonl",
31719            "src/foo/.agent-doc",
31720            "src/foo/.agent-doc/snapshots/x.md",
31721            "./.agent-doc/snapshots/x.md",
31722        ];
31723        for path in cases {
31724            assert!(
31725                traversal_relative_path_is_generated_artifact(path),
31726                "expected `{path}` to be excluded from source watermark"
31727            );
31728        }
31729        // Real source paths must NOT be excluded.
31730        for path in [
31731            "src/main.rs",
31732            "tests/perf_gate.rs",
31733            "fixtures/x.json",
31734            "agent-doc/src/lib.rs", // sibling dir without the leading dot
31735            "src/.agent-doc-helper.rs",
31736        ] {
31737            assert!(
31738                !traversal_relative_path_is_generated_artifact(path),
31739                "expected `{path}` to be included in source watermark"
31740            );
31741        }
31742    }
31743
31744    #[test]
31745    fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
31746        // #cachelookupshift: the conflict-matrix preparation cache key hashes
31747        // file_state snapshot rows + every markdown file under the root. Any
31748        // .tsift/, target/, or .agent-doc/ path slipping past the filter would
31749        // shift the watermark every run because those directories mutate as a
31750        // side effect of running tsift itself. This test locks the artifact
31751        // filter against regressions for each prefix variant
31752        // (bare, root-anchored, nested, and './' leading).
31753        let cases = [
31754            ".tsift",
31755            ".tsift/index.db",
31756            ".tsift/indexes/foo/index.db",
31757            ".tsift/conflict-matrix-cache/inputs/abc.json",
31758            ".tsift/summaries.db",
31759            "src/foo/.tsift",
31760            "src/foo/.tsift/graph.db",
31761            "./.tsift/index.db",
31762            "target",
31763            "target/debug/build/x",
31764            "target/release/tsift",
31765            "src/foo/target/debug/x",
31766            "./target/release/x",
31767        ];
31768        for path in cases {
31769            assert!(
31770                traversal_relative_path_is_generated_artifact(path),
31771                "expected `{path}` to be excluded from source watermark"
31772            );
31773        }
31774        // Look-alike paths must NOT be excluded — only true artifact dirs.
31775        for path in [
31776            "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
31777            "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
31778            "src/tsift-extras/lib.rs",
31779            "tsift/README.md",
31780            "src/targeting.rs",
31781            "src/.tsiftrc",
31782            "src/agent-doc-helper.rs",
31783        ] {
31784            assert!(
31785                !traversal_relative_path_is_generated_artifact(path),
31786                "expected `{path}` to be included in source watermark"
31787            );
31788        }
31789    }
31790
31791    #[test]
31792    fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
31793        // #cachelookupshift: the conflict-matrix preparation cache only hits
31794        // when traversal_source_watermark returns the same hash for two
31795        // consecutive calls on identical source state. Lock that invariant so
31796        // a future change that folds wall-clock time, a directory mtime, or
31797        // any other non-content input into the hash trips this test before
31798        // regressing the preparation_cache_lookup hit rate. We exercise the
31799        // session_only=true path with a hinted markdown file so the test does
31800        // not need a full index DB to drive the index-snapshot branch.
31801        let dir = tempfile::tempdir().unwrap();
31802        let root = dir.path();
31803        std::fs::create_dir_all(root.join("src")).unwrap();
31804        std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
31805        let hint = root.join("README.md");
31806        std::fs::write(&hint, "# stable\n").unwrap();
31807        // Add a generated-artifact directory that must NOT affect the watermark.
31808        std::fs::create_dir_all(root.join(".tsift")).unwrap();
31809        std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
31810        std::fs::create_dir_all(root.join("target/debug")).unwrap();
31811        std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
31812
31813        let first = traversal_source_watermark(root, &hint, None, true)
31814            .expect("first watermark call must succeed")
31815            .expect("first watermark must produce a hash for hinted markdown");
31816        let second = traversal_source_watermark(root, &hint, None, true)
31817            .expect("second watermark call must succeed")
31818            .expect("second watermark must produce a hash for hinted markdown");
31819        assert_eq!(
31820            first, second,
31821            "watermark must be identical across back-to-back invocations on a quiescent root"
31822        );
31823
31824        // Mutating a generated-artifact file must NOT shift the hash.
31825        std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
31826        std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
31827        let third = traversal_source_watermark(root, &hint, None, true)
31828            .expect("third watermark call must succeed")
31829            .expect("third watermark must produce a hash for hinted markdown");
31830        assert_eq!(
31831            first, third,
31832            "watermark must ignore mutations under .tsift/ and target/"
31833        );
31834
31835        // Mutating the hinted markdown file MUST shift the hash so the
31836        // preparation cache invalidates correctly when user state changes.
31837        // Sleep briefly to push the file mtime past the original even on
31838        // coarse-resolution filesystems.
31839        std::thread::sleep(std::time::Duration::from_millis(20));
31840        std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
31841        let fourth = traversal_source_watermark(root, &hint, None, true)
31842            .expect("fourth watermark call must succeed")
31843            .expect("fourth watermark must produce a hash for hinted markdown");
31844        assert_ne!(
31845            first, fourth,
31846            "watermark must invalidate when the hinted markdown file changes"
31847        );
31848    }
31849
31850    #[test]
31851    fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
31852        // #gcachemiss: full-projection cache keys must not miss just because the
31853        // SQLite summary cache file header or mtime churned. Only the semantic rows
31854        // that feed traversal projection should participate in the source watermark.
31855        let dir = tempfile::tempdir().unwrap();
31856        let root = dir.path();
31857        std::fs::write(root.join("README.md"), "# stable\n").unwrap();
31858        let summaries_db_path = root.join(".tsift/summaries.db");
31859        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31860        let mut summary = summarize::Summary {
31861            id: 0,
31862            symbol_name: "main".to_string(),
31863            file_path: "src/main.rs".to_string(),
31864            content_hash: "hash-main".to_string(),
31865            summary: "main wires the CLI".to_string(),
31866            entities: Some(vec![summarize::Entity {
31867                name: "Cli".to_string(),
31868                kind: "type".to_string(),
31869                description: "Command-line interface".to_string(),
31870            }]),
31871            relationships: None,
31872            concept_labels: Some(vec!["cli".to_string()]),
31873            extracted_at: "1700000000".to_string(),
31874            model: "test-model".to_string(),
31875            tokens_input: Some(10),
31876            tokens_output: Some(5),
31877        };
31878        summary_db.insert(&summary).unwrap();
31879        drop(summary_db);
31880
31881        let hint = root.join("README.md");
31882        let first = traversal_source_watermark(root, &hint, None, true)
31883            .expect("first watermark call must succeed")
31884            .expect("first watermark must produce a hash");
31885
31886        std::thread::sleep(std::time::Duration::from_millis(20));
31887        let conn = Connection::open(&summaries_db_path).unwrap();
31888        conn.pragma_update(None, "user_version", 1).unwrap();
31889        conn.pragma_update(None, "user_version", 0).unwrap();
31890        drop(conn);
31891
31892        let second = traversal_source_watermark(root, &hint, None, true)
31893            .expect("second watermark call must succeed")
31894            .expect("second watermark must produce a hash");
31895        assert_eq!(
31896            first, second,
31897            "metadata-only summaries.db churn must not invalidate the source watermark"
31898        );
31899
31900        summary.entities = Some(vec![summarize::Entity {
31901            name: "GraphCache".to_string(),
31902            kind: "type".to_string(),
31903            description: "Stable full-projection cache input".to_string(),
31904        }]);
31905        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31906        summary_db.delete_by_file("src/main.rs").unwrap();
31907        summary_db.insert(&summary).unwrap();
31908        drop(summary_db);
31909
31910        let third = traversal_source_watermark(root, &hint, None, true)
31911            .expect("third watermark call must succeed")
31912            .expect("third watermark must produce a hash");
31913        assert_ne!(
31914            first, third,
31915            "semantic summary row changes must invalidate the source watermark"
31916        );
31917    }
31918
31919    #[test]
31920    fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
31921        // #gfullhot: backend-eval full-projection cache keys should be based on
31922        // the indexed graph inputs, not file_state mtimes. Touching a source file
31923        // without changing extracted symbols/call edges must still hit the cache.
31924        let dir = tempfile::tempdir().unwrap();
31925        let root = dir.path();
31926        std::fs::create_dir_all(root.join("src")).unwrap();
31927        std::fs::create_dir_all(root.join(".tsift")).unwrap();
31928        let source = root.join("src/lib.rs");
31929        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
31930        std::fs::write(&source, source_body).unwrap();
31931        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31932        db.rebuild(root).unwrap();
31933        drop(db);
31934
31935        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31936            .unwrap()
31937            .value;
31938        std::thread::sleep(std::time::Duration::from_millis(20));
31939        std::fs::write(&source, source_body).unwrap();
31940        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31941        db.apply_changes(root).unwrap();
31942        drop(db);
31943
31944        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31945            .unwrap()
31946            .value;
31947        assert_eq!(
31948            first, second,
31949            "mtime-only source index churn must not invalidate the full-projection cache"
31950        );
31951    }
31952
31953    #[test]
31954    fn full_projection_source_watermark_ignores_session_markdown_churn() {
31955        // #gfullhot: the full-projection performance cache isolates code graph
31956        // and semantic-summary inputs. Current session evidence is measured by
31957        // the bounded real dataset, so unrelated task-doc edits must not force a
31958        // million-row full-projection rebuild.
31959        let dir = tempfile::tempdir().unwrap();
31960        let root = dir.path();
31961        std::fs::create_dir_all(root.join("src")).unwrap();
31962        std::fs::create_dir_all(root.join("tasks/software")).unwrap();
31963        std::fs::create_dir_all(root.join(".tsift")).unwrap();
31964        std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
31965        let task_doc = root.join("tasks/software/tsift.md");
31966        std::fs::write(
31967            &task_doc,
31968            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
31969        )
31970        .unwrap();
31971        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31972        db.rebuild(root).unwrap();
31973        drop(db);
31974
31975        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31976            .unwrap()
31977            .value;
31978        std::fs::write(
31979            &task_doc,
31980            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
31981        )
31982        .unwrap();
31983        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31984            .unwrap()
31985            .value;
31986        assert_eq!(
31987            first, second,
31988            "session markdown churn must not invalidate the full-projection code/summary cache"
31989        );
31990    }
31991
31992    #[test]
31993    fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
31994        // #gfullhot: once a full-project projection is cached, repeated samples
31995        // with unchanged graph inputs must report zero source_graph_build and
31996        // projection_rows work even if indexed file mtimes changed.
31997        let dir = tempfile::tempdir().unwrap();
31998        let root = dir.path();
31999        std::fs::create_dir_all(root.join("src")).unwrap();
32000        std::fs::create_dir_all(root.join(".tsift")).unwrap();
32001        let source = root.join("src/lib.rs");
32002        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
32003        std::fs::write(&source, source_body).unwrap();
32004        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32005        db.rebuild(root).unwrap();
32006        drop(db);
32007
32008        let (_projection, _warnings, _phases, first_stats) =
32009            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
32010        assert!(
32011            !first_stats.hit,
32012            "the first full-projection run should populate the cache"
32013        );
32014
32015        std::thread::sleep(std::time::Duration::from_millis(20));
32016        std::fs::write(&source, source_body).unwrap();
32017        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
32018        db.apply_changes(root).unwrap();
32019        drop(db);
32020
32021        let (_projection, _warnings, phases, second_stats) =
32022            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
32023        assert!(second_stats.hit, "mtime-only churn should still cache-hit");
32024        let source_graph_build = phases
32025            .iter()
32026            .find(|phase| phase.name == "full_projection.source_graph_build")
32027            .expect("cache hit must report source_graph_build");
32028        let projection_rows = phases
32029            .iter()
32030            .find(|phase| phase.name == "full_projection.projection_rows")
32031            .expect("cache hit must report projection_rows");
32032        assert_eq!(source_graph_build.duration_micros, 0);
32033        assert_eq!(projection_rows.duration_micros, 0);
32034    }
32035
32036    #[test]
32037    fn build_token_capped_preview_within_cap() {
32038        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 2", "}"];
32039        let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
32040        assert!(!capped.was_capped);
32041        assert_eq!(capped.preview.len(), 3);
32042        assert_eq!(capped.capped_end, 3);
32043    }
32044
32045    #[test]
32046    fn build_token_capped_preview_truncates_long_body() {
32047        let owned: Vec<String> = (0..200).map(|i| format!("    let line_{i} = {i};")).collect();
32048        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
32049        let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
32050        assert!(capped.was_capped);
32051        assert!(capped.preview.len() < 200);
32052        assert!(capped.capped_end < 200);
32053        assert!(!capped.preview.is_empty());
32054    }
32055
32056    #[test]
32057    fn build_token_capped_preview_respects_start_offset() {
32058        let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
32059        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
32060        let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
32061        assert!(capped.was_capped);
32062        assert!(capped.capped_end >= 50);
32063        assert!(capped.capped_end < 100);
32064        assert_eq!(capped.preview[0].line, 50);
32065    }
32066
32067    #[test]
32068    fn response_budget_body_token_cap_defaults() {
32069        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
32070        assert_eq!(budget.body_token_cap(), 1500);
32071
32072        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
32073        assert_eq!(budget.body_token_cap(), 500);
32074
32075        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
32076        assert_eq!(budget.body_token_cap(), 3000);
32077    }
32078
32079    #[test]
32080    fn build_token_capped_preview_empty_input() {
32081        let lines: Vec<&str> = vec![];
32082        let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
32083        assert!(!capped.was_capped);
32084        assert!(capped.preview.is_empty());
32085    }
32086
32087    #[test]
32088    fn build_token_capped_preview_single_long_line_fits() {
32089        let lines: Vec<&str> = vec!["short"];
32090        let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
32091        assert!(!capped.was_capped);
32092        assert_eq!(capped.preview.len(), 1);
32093        assert_eq!(capped.capped_end, 1);
32094    }
32095
32096    #[test]
32097    fn edge_index_replaces_from_id_to_id_with_positions() {
32098        let input = serde_json::json!({
32099            "nodes": [
32100                {"id": "symbol:src/lib.rs:foo"},
32101                {"id": "symbol:src/lib.rs:bar"},
32102                {"id": "symbol:src/lib.rs:baz"}
32103            ],
32104            "edges": [
32105                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
32106                {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
32107            ]
32108        });
32109        let result = edge_index_transform(input);
32110        let edges = result.get("edges").unwrap().as_array().unwrap();
32111        assert_eq!(edges.len(), 2);
32112        assert_eq!(edges[0]["from"], 0);
32113        assert_eq!(edges[0]["to"], 1);
32114        assert_eq!(edges[1]["from"], 1);
32115        assert_eq!(edges[1]["to"], 2);
32116        assert!(edges[0].get("from_id").is_none());
32117        assert!(edges[0].get("to_id").is_none());
32118    }
32119
32120    #[test]
32121    fn edge_index_preserves_unresolved_ids_as_strings() {
32122        let input = serde_json::json!({
32123            "nodes": [{"id": "symbol:src/lib.rs:foo"}],
32124            "edges": [
32125                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
32126            ]
32127        });
32128        let result = edge_index_transform(input);
32129        let edge = &result["edges"][0];
32130        assert_eq!(edge["from"], 0);
32131        assert_eq!(edge["to_id"], "symbol:other.rs:missing");
32132    }
32133
32134    #[test]
32135    fn edge_index_noop_without_nodes_and_edges() {
32136        let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
32137        let result = edge_index_transform(input);
32138        assert_eq!(result["report"]["entries"][0]["from_id"], "a");
32139    }
32140}
32141
32142// --- SQL introspection ---
32143
32144#[derive(Serialize)]
32145struct TableInfo {
32146    name: String,
32147    columns: Vec<ColumnInfo>,
32148    row_count: i64,
32149}
32150
32151#[derive(Serialize)]
32152struct ColumnInfo {
32153    name: String,
32154    #[serde(rename = "type")]
32155    col_type: String,
32156    notnull: bool,
32157    pk: bool,
32158    #[serde(skip_serializing_if = "Option::is_none")]
32159    default_value: Option<String>,
32160}
32161
32162/// Open a SQLite connection (read-only).
32163pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
32164    let conn = Connection::open_with_flags(
32165        path,
32166        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
32167    )
32168    .with_context(|| format!("opening database: {}", path.display()))?;
32169    Ok(conn)
32170}
32171
32172/// List all user tables with column metadata and row counts.
32173pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
32174    let mut stmt = conn.prepare(
32175        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
32176    )?;
32177    let table_names: Vec<String> = stmt
32178        .query_map([], |row| row.get(0))?
32179        .collect::<std::result::Result<Vec<_>, _>>()?;
32180
32181    let mut tables = Vec::new();
32182    for tbl in table_names {
32183        let columns = table_columns(conn, &tbl)?;
32184        let row_count: i64 =
32185            conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
32186                row.get(0)
32187            })?;
32188        tables.push(TableInfo {
32189            name: tbl,
32190            columns,
32191            row_count,
32192        });
32193    }
32194    Ok(tables)
32195}
32196
32197/// Get column metadata for a single table.
32198pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
32199    let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
32200    let cols = stmt
32201        .query_map([], |row| {
32202            Ok(ColumnInfo {
32203                name: row.get(1)?,
32204                col_type: row.get::<_, String>(2).unwrap_or_default(),
32205                notnull: row.get::<_, bool>(3).unwrap_or(false),
32206                pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
32207                default_value: row.get(4)?,
32208            })
32209        })?
32210        .collect::<std::result::Result<Vec<_>, _>>()?;
32211    Ok(cols)
32212}
32213
32214/// Execute an arbitrary SQL query and return rows as JSON values.
32215pub(crate) fn execute_query(
32216    conn: &Connection,
32217    sql: &str,
32218) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
32219    let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
32220    let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
32221    let col_count = col_names.len();
32222
32223    let mut rows = Vec::new();
32224    let mut query_rows = stmt.query([])?;
32225    while let Some(row) = query_rows.next()? {
32226        let mut vals = Vec::with_capacity(col_count);
32227        for i in 0..col_count {
32228            let val = match row.get_ref(i)? {
32229                rusqlite::types::ValueRef::Null => serde_json::Value::Null,
32230                rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
32231                rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
32232                rusqlite::types::ValueRef::Text(s) => {
32233                    serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
32234                }
32235                rusqlite::types::ValueRef::Blob(b) => {
32236                    serde_json::Value::String(format!("<blob {} bytes>", b.len()))
32237                }
32238            };
32239            vals.push(val);
32240        }
32241        rows.push(vals);
32242    }
32243    Ok((col_names, rows))
32244}
32245
32246
32247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32248enum DigestRunnerKind {
32249    Test,
32250    Log,
32251}
32252
32253impl DigestRunnerKind {
32254    fn parse(raw: &str) -> Result<Self> {
32255        match raw.trim().to_ascii_lowercase().as_str() {
32256            "test" => Ok(Self::Test),
32257            "log" => Ok(Self::Log),
32258            other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
32259        }
32260    }
32261
32262    fn as_str(self) -> &'static str {
32263        match self {
32264            Self::Test => "test",
32265            Self::Log => "log",
32266        }
32267    }
32268}
32269
32270/// Simple shell word splitting (handles single and double quotes).
32271pub(crate) fn shell_split(s: &str) -> Vec<&str> {
32272    let mut parts = Vec::new();
32273    let mut i = 0;
32274    let bytes = s.as_bytes();
32275    while i < bytes.len() {
32276        // Skip whitespace
32277        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
32278            i += 1;
32279        }
32280        if i >= bytes.len() {
32281            break;
32282        }
32283        let start = i;
32284        if bytes[i] == b'"' || bytes[i] == b'\'' {
32285            let quote = bytes[i];
32286            i += 1;
32287            while i < bytes.len() && bytes[i] != quote {
32288                i += 1;
32289            }
32290            if i < bytes.len() {
32291                i += 1; // closing quote
32292            }
32293        } else {
32294            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
32295                i += 1;
32296            }
32297        }
32298        parts.push(&s[start..i]);
32299    }
32300    parts
32301}
32302
32303/// Quote a string for shell if it contains special characters.
32304pub(crate) fn shell_quote(s: &str) -> String {
32305    // Strip existing quotes
32306    let unquoted =
32307        if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
32308            &s[1..s.len() - 1]
32309        } else {
32310            s
32311        };
32312
32313    if unquoted
32314        .chars()
32315        .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
32316    {
32317        format!("\"{}\"", unquoted)
32318    } else {
32319        format!(
32320            "\"{}\"",
32321            unquoted.replace('\\', "\\\\").replace('"', "\\\"")
32322        )
32323    }
32324}
32325
32326fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
32327    sift::SearchCoverageSnapshot {
32328        mode: sift::SearchCoverageMode::Sealed,
32329        total_sector_count: 0,
32330        mounted_sector_count: 0,
32331        reused_sector_count: 0,
32332        dirty_sector_count: 0,
32333        completed_dirty_sector_count: 0,
32334        rebuilding_sector_count: 0,
32335        resumed_sector_count: 0,
32336        active_rebuild: None,
32337    }
32338}
32339
32340fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
32341    let total_sector_count = responses
32342        .iter()
32343        .map(|response| response.coverage.total_sector_count)
32344        .sum();
32345    let mounted_sector_count = responses
32346        .iter()
32347        .map(|response| response.coverage.mounted_sector_count)
32348        .sum();
32349    let reused_sector_count = responses
32350        .iter()
32351        .map(|response| response.coverage.reused_sector_count)
32352        .sum();
32353    let dirty_sector_count = responses
32354        .iter()
32355        .map(|response| response.coverage.dirty_sector_count)
32356        .sum();
32357    let completed_dirty_sector_count = responses
32358        .iter()
32359        .map(|response| response.coverage.completed_dirty_sector_count)
32360        .sum();
32361    let rebuilding_sector_count = responses
32362        .iter()
32363        .map(|response| response.coverage.rebuilding_sector_count)
32364        .sum();
32365    let resumed_sector_count = responses
32366        .iter()
32367        .map(|response| response.coverage.resumed_sector_count)
32368        .sum();
32369
32370    let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
32371        sift::SearchCoverageMode::Sealed
32372    } else if completed_dirty_sector_count > 0
32373        || rebuilding_sector_count > 0
32374        || resumed_sector_count > 0
32375    {
32376        sift::SearchCoverageMode::Converging
32377    } else {
32378        sift::SearchCoverageMode::Frontier
32379    };
32380
32381    sift::SearchCoverageSnapshot {
32382        mode,
32383        total_sector_count,
32384        mounted_sector_count,
32385        reused_sector_count,
32386        dirty_sector_count,
32387        completed_dirty_sector_count,
32388        rebuilding_sector_count,
32389        resumed_sector_count,
32390        active_rebuild: responses
32391            .iter()
32392            .find_map(|response| response.coverage.active_rebuild.clone()),
32393    }
32394}
32395
32396fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
32397    sift::SearchResponse {
32398        strategy: strategy.to_string(),
32399        root: root.display().to_string(),
32400        indexed_artifacts: 0,
32401        skipped_artifacts: 0,
32402        coverage: empty_search_coverage(),
32403        hits: Vec::new(),
32404    }
32405}
32406
32407fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
32408    for hit in &mut response.hits {
32409        let path = Path::new(&hit.path);
32410        if path.is_relative() {
32411            hit.path = search_root.join(path).display().to_string();
32412        }
32413    }
32414}
32415
32416fn merge_search_responses(
32417    root: &Path,
32418    strategy: &str,
32419    limit: usize,
32420    responses: Vec<sift::SearchResponse>,
32421) -> sift::SearchResponse {
32422    let indexed_artifacts = responses
32423        .iter()
32424        .map(|response| response.indexed_artifacts)
32425        .sum();
32426    let skipped_artifacts = responses
32427        .iter()
32428        .map(|response| response.skipped_artifacts)
32429        .sum();
32430    let coverage = if responses.is_empty() {
32431        empty_search_coverage()
32432    } else {
32433        aggregate_search_coverage(&responses)
32434    };
32435    let mut hits: Vec<sift::SearchHit> = responses
32436        .into_iter()
32437        .flat_map(|response| response.hits)
32438        .collect();
32439    hits.sort_by(|left, right| {
32440        right
32441            .score
32442            .partial_cmp(&left.score)
32443            .unwrap_or(Ordering::Equal)
32444            .then_with(|| left.path.cmp(&right.path))
32445            .then_with(|| left.location.cmp(&right.location))
32446    });
32447    hits.truncate(limit);
32448    for (rank, hit) in hits.iter_mut().enumerate() {
32449        hit.rank = rank + 1;
32450    }
32451
32452    sift::SearchResponse {
32453        strategy: strategy.to_string(),
32454        root: root.display().to_string(),
32455        indexed_artifacts,
32456        skipped_artifacts,
32457        coverage,
32458        hits,
32459    }
32460}
32461
32462pub(crate) fn federated_sift_search(
32463    root: &Path,
32464    cache_dir: &Path,
32465    query: &str,
32466    limit: usize,
32467    timeout_secs: u64,
32468    strategy: &str,
32469) -> Result<sift::SearchResponse> {
32470    let targets = resolve_search_index_targets(root, root, None, true)?;
32471    if targets.is_empty() {
32472        if config::Config::submodule_dirs(root)?.is_empty() {
32473            return run_search_with_timeout(
32474                root,
32475                cache_dir,
32476                query,
32477                limit,
32478                timeout_secs,
32479                strategy,
32480                &[],
32481            );
32482        }
32483        return Ok(empty_search_response(root, strategy));
32484    }
32485
32486    let mut responses = Vec::with_capacity(targets.len());
32487    for target in &targets {
32488        let mut response = run_search_with_timeout(
32489            &target.source_root,
32490            cache_dir,
32491            query,
32492            limit,
32493            timeout_secs,
32494            strategy,
32495            std::slice::from_ref(target),
32496        )?;
32497        absolutize_search_hit_paths(&mut response, &target.source_root);
32498        response.root = root.display().to_string();
32499        responses.push(response);
32500    }
32501
32502    Ok(merge_search_responses(root, strategy, limit, responses))
32503}
32504
32505/// Federated symbol search across every scoped `.tsift/indexes/<scope>/index.db`
32506/// in the workspace. Per-scope tagpath annotation runs inside the per-scope
32507/// loop so each scope's adapter resolves against its own `.naming.toml` /
32508/// `.naming/index.json` (the workspace root usually has no tagpath of its
32509/// own). The merged `TagpathAnnotationDiagnostic` reports `loaded=true` when
32510/// at least one scope loaded, and `stale=true` with the first stale reason
32511/// when any scope was stale.
32512pub(crate) fn federated_symbol_search(
32513    root: &std::path::Path,
32514    query: &str,
32515    limit: usize,
32516    tagpath_opts: &TagpathSearchOpts,
32517) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
32518    let cfg = config::Config::load(root)?;
32519    let submodules = config::Config::submodule_dirs(root)?;
32520    let mut all_hits: Vec<index::SymbolHit> = Vec::new();
32521    let mut combined = TagpathAnnotationDiagnostic::default();
32522    for scope in &submodules {
32523        if !cfg.federation_for_scope(scope) {
32524            continue;
32525        }
32526        let db_path = cfg.db_path_for(root, &scope.id);
32527        if !db_path.exists() {
32528            continue;
32529        }
32530        let db = index::IndexDb::open_read_only(&db_path)?;
32531        let mut hits = db.symbol_search(query, limit)?;
32532        let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
32533        combined.loaded |= diag.loaded;
32534        if diag.stale && !combined.stale {
32535            combined.stale = true;
32536            combined.reason = diag.reason;
32537        }
32538        all_hits.append(&mut hits);
32539    }
32540    all_hits.sort_by(|a, b| {
32541        b.score
32542            .partial_cmp(&a.score)
32543            .unwrap_or(std::cmp::Ordering::Equal)
32544    });
32545    all_hits.truncate(limit);
32546    Ok((all_hits, combined))
32547}
32548
32549#[derive(Debug, Deserialize)]
32550#[serde(tag = "type", rename_all = "lowercase")]
32551enum RipgrepJsonEvent {
32552    Match {
32553        data: RipgrepMatchData,
32554    },
32555    #[serde(other)]
32556    Other,
32557}
32558
32559#[derive(Debug, Deserialize)]
32560struct RipgrepMatchData {
32561    path: RipgrepTextField,
32562    lines: RipgrepTextField,
32563    line_number: Option<usize>,
32564}
32565
32566#[derive(Debug, Deserialize)]
32567struct RipgrepTextField {
32568    text: Option<String>,
32569}
32570
32571pub(crate) fn federated_exact_search(
32572    root: &Path,
32573    query: &str,
32574    limit: usize,
32575    timeout_secs: u64,
32576) -> Result<sift::SearchResponse> {
32577    let cfg = config::Config::load(root)?;
32578    let mut responses = Vec::new();
32579    for scope in config::Config::submodule_dirs(root)? {
32580        if !cfg.federation_for_scope(&scope) {
32581            continue;
32582        }
32583        let mut response =
32584            run_exact_search_with_timeout(&scope.source_root, query, limit, timeout_secs)?;
32585        absolutize_search_hit_paths(&mut response, &scope.source_root);
32586        response.root = root.display().to_string();
32587        responses.push(response);
32588    }
32589
32590    Ok(merge_search_responses(root, "exact", limit, responses))
32591}
32592
32593pub(crate) fn run_sift_search(
32594    search_path: &Path,
32595    cache_dir: &Path,
32596    query: &str,
32597    limit: usize,
32598    strategy: &str,
32599) -> Result<sift::SearchResponse> {
32600    let engine = Sift::builder().with_cache_dir(cache_dir).build();
32601    let options = SearchOptions::default()
32602        .with_limit(limit)
32603        .with_strategy(strategy.to_string());
32604    let input = SearchInput::new(search_path, query).with_options(options);
32605    engine.search(input).context("sift search failed")
32606}
32607
32608fn exact_search_timeout_message(timeout_secs: u64) -> String {
32609    format!(
32610        "tsift search timed out after {}s (strategy: exact). \
32611         Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
32612        timeout_secs
32613    )
32614}
32615
32616fn exact_search_command(search_path: &Path, query: &str) -> Command {
32617    let mut command = Command::new("rg");
32618    command
32619        .arg("--json")
32620        .arg("--fixed-strings")
32621        .arg("--line-number")
32622        .arg("--hidden")
32623        .arg("--")
32624        .arg(query)
32625        .arg(search_path);
32626    command
32627}
32628
32629fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
32630    let observed_unix_secs = SystemTime::now()
32631        .duration_since(UNIX_EPOCH)
32632        .unwrap_or_default()
32633        .as_secs() as i64;
32634    let modified_unix_secs = fs::metadata(path)
32635        .ok()
32636        .and_then(|metadata| metadata.modified().ok())
32637        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
32638        .map(|duration| duration.as_secs() as i64);
32639    sift::ArtifactFreshness {
32640        observed_unix_secs,
32641        modified_unix_secs,
32642    }
32643}
32644
32645fn parse_exact_search_output(
32646    search_path: &Path,
32647    limit: usize,
32648    raw: &str,
32649) -> Result<sift::SearchResponse> {
32650    if limit == 0 {
32651        return Ok(sift::SearchResponse {
32652            strategy: "exact".to_string(),
32653            root: search_path.display().to_string(),
32654            indexed_artifacts: 0,
32655            skipped_artifacts: 0,
32656            coverage: empty_search_coverage(),
32657            hits: Vec::new(),
32658        });
32659    }
32660
32661    let mut hits = Vec::new();
32662    for line in raw.lines() {
32663        let event: RipgrepJsonEvent =
32664            serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
32665        let RipgrepJsonEvent::Match { data } = event else {
32666            continue;
32667        };
32668        let Some(path_text) = data.path.text else {
32669            continue;
32670        };
32671        let Some(lines_text) = data.lines.text else {
32672            continue;
32673        };
32674        let path = PathBuf::from(path_text);
32675        let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
32676        let rank = hits.len() + 1;
32677        hits.push(sift::SearchHit {
32678            artifact_id: format!(
32679                "exact:{}:{}:{}",
32680                path.display(),
32681                data.line_number.unwrap_or(0),
32682                rank
32683            ),
32684            artifact_kind: sift::ContextArtifactKind::File,
32685            path: path.display().to_string(),
32686            rank,
32687            score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
32688            confidence: sift::ScoreConfidence::High,
32689            location: data.line_number.map(|line| format!("line {}", line)),
32690            snippet: snippet.clone(),
32691            provenance: sift::ArtifactProvenance {
32692                adapter: sift::AcquisitionAdapterKind::FileSystem,
32693                source: "ripgrep -F".to_string(),
32694                synthetic: false,
32695            },
32696            freshness: exact_search_file_timestamp(&path),
32697            budget: sift::ArtifactBudget::from_text(&snippet, 1),
32698        });
32699        if hits.len() >= limit {
32700            break;
32701        }
32702    }
32703
32704    Ok(sift::SearchResponse {
32705        strategy: "exact".to_string(),
32706        root: search_path.display().to_string(),
32707        indexed_artifacts: hits.len(),
32708        skipped_artifacts: 0,
32709        coverage: empty_search_coverage(),
32710        hits,
32711    })
32712}
32713
32714fn exact_search_response_from_process(
32715    search_path: &Path,
32716    limit: usize,
32717    status: std::process::ExitStatus,
32718    stdout: &[u8],
32719    stderr: &[u8],
32720) -> Result<sift::SearchResponse> {
32721    if !status.success() && status.code() != Some(1) {
32722        let message = String::from_utf8_lossy(stderr);
32723        let trimmed = message.trim();
32724        if trimmed.is_empty() {
32725            bail!("ripgrep exact search exited with status {}", status);
32726        }
32727        bail!("{}", trimmed);
32728    }
32729
32730    let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
32731    parse_exact_search_output(search_path, limit, &raw)
32732}
32733
32734fn run_exact_search(search_path: &Path, query: &str, limit: usize) -> Result<sift::SearchResponse> {
32735    let output = exact_search_command(search_path, query)
32736        .output()
32737        .context("running exact search with ripgrep")?;
32738    exact_search_response_from_process(
32739        search_path,
32740        limit,
32741        output.status,
32742        &output.stdout,
32743        &output.stderr,
32744    )
32745}
32746
32747pub(crate) fn run_exact_search_with_timeout(
32748    search_path: &Path,
32749    query: &str,
32750    limit: usize,
32751    timeout_secs: u64,
32752) -> Result<sift::SearchResponse> {
32753    if timeout_secs == 0 {
32754        return run_exact_search(search_path, query, limit);
32755    }
32756
32757    let mut child = exact_search_command(search_path, query)
32758        .stdin(Stdio::null())
32759        .stdout(Stdio::piped())
32760        .stderr(Stdio::piped())
32761        .spawn()
32762        .context("spawning timed exact search worker")?;
32763
32764    let timeout = Duration::from_secs(timeout_secs);
32765    let status = wait_for_child_exit(&mut child, timeout)
32766        .context("waiting for timed exact search worker")?;
32767    if status.is_none() {
32768        let _ = child.kill();
32769        let _ = child.wait();
32770        bail!("{}", exact_search_timeout_message(timeout_secs));
32771    }
32772
32773    let status = status.unwrap();
32774    let stdout = read_child_stdout(&mut child)?;
32775    let stderr = read_child_stderr(&mut child)?;
32776    exact_search_response_from_process(
32777        search_path,
32778        limit,
32779        status,
32780        stdout.as_bytes(),
32781        stderr.as_bytes(),
32782    )
32783}
32784
32785pub(crate) fn run_search_with_timeout(
32786    search_path: &Path,
32787    cache_dir: &Path,
32788    query: &str,
32789    limit: usize,
32790    timeout_secs: u64,
32791    strategy: &str,
32792    search_targets: &[SearchIndexTarget],
32793) -> Result<sift::SearchResponse> {
32794    if timeout_secs == 0 {
32795        return run_sift_search(search_path, cache_dir, query, limit, strategy);
32796    }
32797
32798    let output_path = next_search_worker_output_path();
32799    let mut child = Command::new(
32800        std::env::current_exe().context("resolving tsift executable for timed search")?,
32801    )
32802    .arg("__search-worker")
32803    .arg("--path")
32804    .arg(search_path)
32805    .arg("--cache-dir")
32806    .arg(cache_dir)
32807    .arg("--query")
32808    .arg(query)
32809    .arg("--limit")
32810    .arg(limit.to_string())
32811    .arg("--strategy")
32812    .arg(strategy)
32813    .arg("--output")
32814    .arg(&output_path)
32815    .stdin(Stdio::null())
32816    .stdout(Stdio::null())
32817    .stderr(Stdio::piped())
32818    .spawn()
32819    .context("spawning timed sift search worker")?;
32820
32821    let timeout = Duration::from_secs(timeout_secs);
32822    let status =
32823        wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
32824    if status.is_none() {
32825        let _ = child.kill();
32826        let _ = child.wait();
32827        let _ = fs::remove_file(&output_path);
32828        bail!(
32829            "{}",
32830            search_timeout_message(timeout_secs, strategy, search_targets)?
32831        );
32832    }
32833
32834    let status = status.unwrap();
32835    let stderr = read_child_stderr(&mut child)?;
32836    if !status.success() {
32837        let _ = fs::remove_file(&output_path);
32838        let message = stderr.trim();
32839        if message.is_empty() {
32840            bail!("sift search worker exited with status {}", status);
32841        }
32842        bail!("{}", message);
32843    }
32844
32845    let raw = fs::read_to_string(&output_path)
32846        .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
32847    let _ = fs::remove_file(&output_path);
32848    serde_json::from_str(&raw).context("parsing search worker output")
32849}
32850
32851fn next_search_worker_output_path() -> PathBuf {
32852    let stamp = SystemTime::now()
32853        .duration_since(UNIX_EPOCH)
32854        .unwrap_or_default()
32855        .as_nanos();
32856    std::env::temp_dir().join(format!(
32857        "tsift-search-{}-{}.json",
32858        std::process::id(),
32859        stamp
32860    ))
32861}
32862
32863fn wait_for_child_exit(
32864    child: &mut std::process::Child,
32865    timeout: Duration,
32866) -> Result<Option<std::process::ExitStatus>> {
32867    let started = Instant::now();
32868    loop {
32869        if let Some(status) = child.try_wait()? {
32870            return Ok(Some(status));
32871        }
32872        if started.elapsed() >= timeout {
32873            return Ok(None);
32874        }
32875        let remaining = timeout.saturating_sub(started.elapsed());
32876        std::thread::sleep(remaining.min(Duration::from_millis(10)));
32877    }
32878}
32879
32880fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
32881    let mut stderr = String::new();
32882    if let Some(mut pipe) = child.stderr.take() {
32883        pipe.read_to_string(&mut stderr)
32884            .context("reading search worker stderr")?;
32885    }
32886    Ok(stderr)
32887}
32888
32889fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
32890    let mut stdout = String::new();
32891    if let Some(mut pipe) = child.stdout.take() {
32892        pipe.read_to_string(&mut stdout)
32893            .context("reading search worker stdout")?;
32894    }
32895    Ok(stdout)
32896}
32897
32898pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
32899    if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
32900        fs::write(&path, std::process::id().to_string())
32901            .with_context(|| format!("writing search worker pid file: {path}"))?;
32902    }
32903    if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
32904        let delay_ms = ms
32905            .parse::<u64>()
32906            .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
32907        std::thread::sleep(Duration::from_millis(delay_ms));
32908    }
32909    Ok(())
32910}
32911
32912#[cfg(test)]
32913thread_local! {
32914    static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
32915}
32916
32917#[cfg(test)]
32918enum SearchPostPrecheckLockMode {
32919    RollbackJournal,
32920    Wal,
32921}
32922
32923#[cfg(test)]
32924struct SearchPostPrecheckLockHook {
32925    db_path: PathBuf,
32926    mode: SearchPostPrecheckLockMode,
32927}
32928
32929#[cfg(test)]
32930struct SearchPostPrecheckLockGuard;
32931
32932#[cfg(test)]
32933impl Drop for SearchPostPrecheckLockGuard {
32934    fn drop(&mut self) {
32935        SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32936            hook.borrow_mut().take();
32937        });
32938    }
32939}
32940
32941#[cfg(test)]
32942fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32943    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
32944}
32945
32946#[cfg(test)]
32947fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32948    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
32949}
32950
32951#[cfg(test)]
32952fn install_search_post_precheck_lock_hook(
32953    db_path: PathBuf,
32954    mode: SearchPostPrecheckLockMode,
32955) -> SearchPostPrecheckLockGuard {
32956    SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32957        assert!(
32958            hook.borrow().is_none(),
32959            "search post-precheck lock hook already installed"
32960        );
32961        *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
32962    });
32963    SearchPostPrecheckLockGuard
32964}
32965
32966#[cfg(test)]
32967pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
32968    let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
32969        return Ok(());
32970    };
32971    let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
32972    std::thread::spawn(move || {
32973        let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
32974        match hook.mode {
32975            SearchPostPrecheckLockMode::RollbackJournal => {
32976                conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
32977                    .expect("acquiring rollback-journal hook lock");
32978                fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
32979                    .expect("writing rollback journal marker");
32980            }
32981            SearchPostPrecheckLockMode::Wal => {
32982                conn.execute_batch(
32983                    "PRAGMA journal_mode=WAL;
32984                     PRAGMA wal_autocheckpoint=0;
32985                     CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
32986                     INSERT INTO search_wal_lock_probe DEFAULT VALUES;
32987                     PRAGMA locking_mode=EXCLUSIVE;
32988                     BEGIN EXCLUSIVE;",
32989                )
32990                .expect("acquiring WAL hook lock");
32991                assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
32992            }
32993        }
32994        ready_tx.send(()).expect("signaling search lock hook");
32995        std::thread::sleep(Duration::from_millis(200));
32996        drop(conn);
32997        let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
32998    });
32999    ready_rx
33000        .recv_timeout(Duration::from_secs(1))
33001        .context("waiting for search post-precheck lock hook")?;
33002    Ok(())
33003}
33004
33005#[cfg(not(test))]
33006pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
33007    Ok(())
33008}