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_memgraphrag::append_tsift_memory_graph_projection_rows;
150#[cfg(test)]
151use tsift_memory::MemoryEvent;
152use tsift_cache::cycle_packet_cache;
153use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
154use tsift_resolution as resolution;
155use tsift_search::{impact, sift};
156use tsift_sqlite as substrate;
157use tsift_status::status;
158use tsift_summarize::summarize;
159#[cfg(feature = "backend-surrealdb")]
160use tsift_surrealdb::SurrealdbGraphStore;
161use tsift_tokensave::TokensaveDb;
162
163#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
164pub(crate) enum GraphDbExperimentalBackend {
165    DuckdbDuckpgq,
166    Falkordb,
167    Ladybug,
168    Kuzu,
169    Surrealdb,
170}
171
172#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
173pub(crate) struct SearchFacetFilters {
174    #[serde(skip_serializing_if = "Vec::is_empty", default)]
175    pub(crate) languages: Vec<String>,
176    #[serde(skip_serializing_if = "Vec::is_empty", default)]
177    pub(crate) kinds: Vec<String>,
178    #[serde(skip_serializing_if = "Vec::is_empty", default)]
179    pub(crate) node_kinds: Vec<String>,
180    #[serde(skip_serializing_if = "Vec::is_empty", default)]
181    pub(crate) sections: Vec<String>,
182    #[serde(skip_serializing_if = "Vec::is_empty", default)]
183    pub(crate) parents: Vec<String>,
184    #[serde(skip_serializing_if = "Vec::is_empty", default)]
185    pub(crate) children: Vec<String>,
186    #[serde(skip_serializing_if = "Vec::is_empty", default)]
187    pub(crate) fence_languages: Vec<String>,
188    #[serde(skip_serializing_if = "Vec::is_empty", default)]
189    pub(crate) list_depths: Vec<usize>,
190    #[serde(skip_serializing_if = "Vec::is_empty", default)]
191    pub(crate) heading_levels: Vec<usize>,
192}
193
194impl SearchFacetFilters {
195    pub(crate) fn is_empty(&self) -> bool {
196        self.languages.is_empty()
197            && self.kinds.is_empty()
198            && self.node_kinds.is_empty()
199            && self.sections.is_empty()
200            && self.parents.is_empty()
201            && self.children.is_empty()
202            && self.fence_languages.is_empty()
203            && self.list_depths.is_empty()
204            && self.heading_levels.is_empty()
205    }
206
207    fn needs_ast_context(&self) -> bool {
208        !self.sections.is_empty()
209            || !self.parents.is_empty()
210            || !self.children.is_empty()
211            || !self.fence_languages.is_empty()
212            || !self.list_depths.is_empty()
213            || !self.heading_levels.is_empty()
214    }
215}
216
217#[derive(Serialize)]
218struct GraphDbBackendPromotionGate {
219    status: String,
220    native_adapter_required: bool,
221    required_checks: Vec<String>,
222}
223
224impl GraphDbExperimentalBackend {
225    fn name(self) -> &'static str {
226        match self {
227            Self::DuckdbDuckpgq => "duckdb-duckpgq",
228            Self::Falkordb => "falkordb",
229            Self::Ladybug => "ladybug",
230            Self::Kuzu => "kuzu",
231            Self::Surrealdb => "surrealdb",
232        }
233    }
234
235    fn adapter_label(self) -> &'static str {
236        match self {
237            Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
238            Self::Falkordb => "FalkorDB read-only prototype",
239            Self::Ladybug => "Ladybug read-only prototype",
240            Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
241            Self::Surrealdb => "SurrealDB read-only prototype",
242        }
243    }
244
245    fn projection_load(self) -> &'static str {
246        match self {
247            Self::Falkordb => {
248                "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"
249            }
250            Self::Kuzu => {
251                "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"
252            }
253            Self::Surrealdb => {
254                "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"
255            }
256            _ => {
257                "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
258            }
259        }
260    }
261
262    fn lock_behavior(self) -> &'static str {
263        match self {
264            Self::Falkordb => {
265                "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
266            }
267            Self::Kuzu => {
268                "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"
269            }
270            Self::Surrealdb => {
271                "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
272            }
273            _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
274        }
275    }
276
277    fn install_portability(self) -> &'static str {
278        match self {
279            Self::Falkordb => {
280                "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
281            }
282            Self::Kuzu => {
283                "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"
284            }
285            Self::Surrealdb => {
286                "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"
287            }
288            _ => {
289                "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
290            }
291        }
292    }
293
294    fn prototype_hold_reason(self) -> Option<&'static str> {
295        match self {
296            Self::DuckdbDuckpgq => Some(
297                "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
298            ),
299            Self::Falkordb => Some(
300                "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",
301            ),
302            Self::Ladybug => Some(
303                "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
304            ),
305            Self::Kuzu => Some(
306                "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
307            ),
308            Self::Surrealdb => Some(
309                "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",
310            ),
311        }
312    }
313
314    fn promotion_gate(self) -> GraphDbBackendPromotionGate {
315        match self {
316            Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
317                status: "hold_native_adapter_required".to_string(),
318                native_adapter_required: true,
319                required_checks: vec![
320                    "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
321                        .to_string(),
322                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
323                        .to_string(),
324                    "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
325                    "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
326                        .to_string(),
327                ],
328            },
329            Self::Falkordb => GraphDbBackendPromotionGate {
330                status: "hold_native_adapter_required".to_string(),
331                native_adapter_required: true,
332                required_checks: vec![
333                    "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
334                        .to_string(),
335                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
336                        .to_string(),
337                    "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
338                        .to_string(),
339                    "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
340                        .to_string(),
341                ],
342            },
343            Self::Ladybug => GraphDbBackendPromotionGate {
344                status: "hold_native_adapter_required".to_string(),
345                native_adapter_required: true,
346                required_checks: vec![
347                    "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
348                        .to_string(),
349                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
350                        .to_string(),
351                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
352                        .to_string(),
353                    "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
354                        .to_string(),
355                ],
356            },
357            Self::Kuzu => GraphDbBackendPromotionGate {
358                status: "hold_native_adapter_required".to_string(),
359                native_adapter_required: true,
360                required_checks: vec![
361                    "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
362                        .to_string(),
363                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
364                        .to_string(),
365                    "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
366                        .to_string(),
367                    "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
368                        .to_string(),
369                ],
370            },
371            Self::Surrealdb => GraphDbBackendPromotionGate {
372                status: "hold_native_adapter_required".to_string(),
373                native_adapter_required: true,
374                required_checks: vec![
375                    "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
376                        .to_string(),
377                    "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
378                        .to_string(),
379                    "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
380                        .to_string(),
381                    "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
382                        .to_string(),
383                ],
384            },
385        }
386    }
387
388    fn parse(raw: &str) -> Result<Self> {
389        match raw {
390            "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
391            "falkordb" | "falkor" => Ok(Self::Falkordb),
392            "ladybug" => Ok(Self::Ladybug),
393            "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
394            "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
395            _ => {
396                bail!(
397                    "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
398                )
399            }
400        }
401    }
402}
403
404
405pub fn run() -> Result<()> {
406    let cli = Cli::parse();
407    let compact = cli.compact;
408    let pretty = cli.pretty;
409    let terse = cli.terse || cli.ultra_terse;
410    let ultra_terse = cli.ultra_terse;
411    let absolute = cli.absolute;
412    let tabular = cli.tabular;
413    let schema = cli.schema;
414    let envelope = cli.envelope;
415    match cli.command {
416        Some(Commands::Search {
417            query,
418            path,
419            limit,
420            strategy,
421            exact,
422            scope,
423            federated,
424            lang,
425            kind,
426            node_kind,
427            section,
428            parent,
429            child,
430            fence_language,
431            list_depth,
432            heading_level,
433            json,
434            autoindex,
435            no_autoindex,
436            timeout,
437            max_items,
438            max_bytes,
439            budget,
440            no_tagpath,
441            tagpath_strict,
442        }) => cmd_search_with_budget(
443            query,
444            path,
445            limit,
446            if exact {
447                Some("exact".to_string())
448            } else {
449                strategy
450            },
451            scope,
452            federated,
453            json || terse || schema || envelope,
454            autoindex || !no_autoindex,
455            timeout,
456            compact,
457            pretty,
458            terse,
459            ultra_terse,
460            absolute,
461            tabular,
462            schema,
463            envelope,
464            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
465            TagpathSearchOpts {
466                no_tagpath,
467                strict: tagpath_strict,
468            },
469            SearchFacetFilters {
470                languages: lang,
471                kinds: kind,
472                node_kinds: node_kind,
473                sections: section,
474                parents: parent,
475                children: child,
476                fence_languages: fence_language,
477                list_depths: list_depth,
478                heading_levels: heading_level,
479            },
480        ),
481        Some(Commands::SearchWorker {
482            path,
483            cache_dir,
484            query,
485            limit,
486            strategy,
487            output,
488        }) => cmd_search_worker(&path, &cache_dir, &query, limit, &strategy, &output),
489        Some(Commands::DigestRunner {
490            kind,
491            path,
492            runner,
493            shell_command,
494            json,
495        }) => cmd_digest_runner(
496            &kind,
497            &path,
498            runner.as_deref(),
499            &shell_command,
500            OutputFormat {
501                json_output: json || terse || schema || envelope,
502                compact,
503                pretty,
504                terse,
505                ultra_terse,
506                schema,
507                envelope,
508            },
509        ),
510        Some(Commands::Edit { dry_run, file }) => {
511            cmd_edit(dry_run, file, compact, pretty, terse, schema)
512        }
513        Some(Commands::EditIntents {
514            path,
515            scope,
516            file,
517            json,
518            apply,
519            verify,
520            verify_command,
521            max_items,
522            max_bytes,
523            budget,
524        }) => cmd_edit_intents(
525            &path,
526            scope.as_deref(),
527            file,
528            apply,
529            SemanticEditVerifyOptions {
530                enabled: verify,
531                command: verify_command.as_deref(),
532            },
533            OutputFormat {
534                json_output: json || terse || schema || envelope,
535                compact,
536                pretty,
537                terse,
538                ultra_terse,
539                schema,
540                envelope,
541            },
542            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
543        ),
544        Some(Commands::Index {
545            path,
546            rebuild,
547            check,
548            exit_code,
549            prune,
550            quiet,
551            workspace,
552            submodule,
553            json,
554        }) => cmd_index(
555            &path,
556            rebuild,
557            check,
558            exit_code,
559            prune,
560            quiet,
561            workspace,
562            submodule.as_deref(),
563            json || terse || schema || envelope,
564            compact,
565            pretty,
566            terse,
567            absolute,
568            schema,
569        ),
570        Some(Commands::Rewrite { command, run }) => cmd_rewrite(
571            &command,
572            run,
573            OutputFormat {
574                json_output: terse || schema || envelope,
575                compact,
576                pretty,
577                terse,
578                ultra_terse,
579                schema,
580                envelope,
581            },
582        ),
583        Some(Commands::Route { task, id }) => cmd_route(&task, id),
584        Some(Commands::Memory { command }) => {
585            let json = command.json_output();
586            cmd_memory(
587                command,
588                OutputFormat {
589                    json_output: json || terse || schema || envelope,
590                    compact,
591                    pretty,
592                    terse,
593                    ultra_terse,
594                    schema,
595                    envelope,
596                },
597            )
598        }
599        Some(Commands::Finding { command }) => match command {
600            cli::FindingCommand::Add {
601                path,
602                kind,
603                title,
604                body,
605                about,
606                confidence,
607                status,
608                relates,
609                scope,
610                json,
611            } => commands::finding::cmd_finding_add(
612                &path,
613                &kind,
614                &title,
615                &body,
616                &about,
617                confidence,
618                &status,
619                relates.as_deref(),
620                scope.as_deref(),
621                json || terse || schema || envelope,
622                pretty,
623            ),
624            cli::FindingCommand::List {
625                path,
626                about,
627                kind,
628                status,
629                include_stale,
630                scope,
631                json,
632            } => commands::finding::cmd_finding_list(
633                &path,
634                about.as_deref(),
635                kind.as_deref(),
636                status.as_deref(),
637                include_stale,
638                scope.as_deref(),
639                json || terse || schema || envelope,
640                pretty,
641            ),
642            cli::FindingCommand::Harvest { path, scope, json } => {
643                commands::finding::cmd_finding_harvest(
644                    &path,
645                    scope.as_deref(),
646                    json || terse || schema || envelope,
647                    pretty,
648                )
649            }
650            cli::FindingCommand::Promote { id, path, json } => {
651                commands::finding::cmd_finding_promote(
652                    &path,
653                    &id,
654                    json || terse || schema || envelope,
655                    pretty,
656                )
657            }
658        },
659        Some(Commands::Graph {
660            symbol,
661            path,
662            callers,
663            callees,
664            scope,
665            limit,
666            json,
667            no_tagpath,
668            tagpath_strict,
669        }) => cmd_graph(
670            &symbol,
671            &path,
672            callers,
673            callees,
674            scope.as_deref(),
675            limit,
676            json || terse || schema || envelope,
677            compact,
678            pretty,
679            terse,
680            absolute,
681            tabular,
682            schema,
683            TagpathSearchOpts {
684                no_tagpath,
685                strict: tagpath_strict,
686            },
687        ),
688        Some(Commands::Sql {
689            db,
690            query,
691            table,
692            json,
693        }) => cmd_sql(
694            &db,
695            query,
696            table,
697            json || terse || schema || envelope,
698            compact,
699            pretty,
700            terse,
701            schema,
702        ),
703        Some(Commands::Communities {
704            path,
705            scope,
706            min_size,
707            limit,
708            json,
709            no_tagpath,
710            tagpath_strict,
711        }) => cmd_communities(
712            &path,
713            scope.as_deref(),
714            min_size,
715            limit,
716            json || terse || schema || envelope,
717            compact,
718            pretty,
719            terse,
720            tabular,
721            schema,
722            TagpathSearchOpts {
723                no_tagpath,
724                strict: tagpath_strict,
725            },
726        ),
727        Some(Commands::Analyze {
728            path,
729            scope,
730            entry_points,
731            limit,
732            json,
733        }) => cmd_analyze(
734            &path,
735            scope.as_deref(),
736            &entry_points,
737            limit,
738            OutputFormat {
739                json_output: json || terse || schema || envelope,
740                compact,
741                pretty,
742                terse,
743                ultra_terse,
744                schema,
745                envelope,
746            },
747        ),
748        Some(Commands::Path {
749            from,
750            to,
751            path,
752            scope,
753            json,
754            no_tagpath,
755            tagpath_strict,
756        }) => cmd_path(
757            &from,
758            &to,
759            &path,
760            scope.as_deref(),
761            json || terse || schema || envelope,
762            compact,
763            pretty,
764            terse,
765            schema,
766            TagpathSearchOpts {
767                no_tagpath,
768                strict: tagpath_strict,
769            },
770        ),
771        Some(Commands::Explain {
772            symbol,
773            path,
774            scope,
775            limit,
776            json,
777            max_items,
778            max_bytes,
779            budget,
780            no_tagpath,
781            tagpath_strict,
782        }) => cmd_explain_with_budget(
783            &symbol,
784            &path,
785            scope.as_deref(),
786            limit,
787            json || terse || schema || envelope,
788            compact,
789            pretty,
790            terse,
791            ultra_terse,
792            absolute,
793            tabular,
794            schema,
795            envelope,
796            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
797            TagpathSearchOpts {
798                no_tagpath,
799                strict: tagpath_strict,
800            },
801        ),
802        Some(Commands::Traverse {
803            node,
804            to,
805            path,
806            scope,
807            depth,
808            limit,
809            format,
810            convex_snapshot,
811        }) => cmd_traverse(
812            node.as_deref(),
813            to.as_deref(),
814            &path,
815            scope.as_deref(),
816            depth,
817            limit,
818            format,
819            pretty,
820            terse,
821            schema,
822            convex_snapshot.as_deref(),
823        ),
824        Some(Commands::ConvexSync {
825            path,
826            scope,
827            snapshot,
828            chunk_size,
829            remote_snapshot,
830            apply,
831            endpoint,
832            auth_token_env,
833            json,
834        }) => cmd_convex_sync(
835            ConvexSyncOptions {
836                path: &path,
837                scope: scope.as_deref(),
838                snapshot: snapshot.as_deref(),
839                chunk_size,
840                remote_snapshot,
841                apply,
842                endpoint: endpoint.as_deref(),
843                auth_token_env: &auth_token_env,
844            },
845            OutputFormat {
846                json_output: json || terse || schema || envelope,
847                compact,
848                pretty,
849                terse,
850                ultra_terse,
851                schema,
852                envelope,
853            },
854        ),
855        Some(Commands::GraphDb {
856            path,
857            scope,
858            backend,
859            convex_snapshot,
860            json,
861            query,
862        }) => cmd_graph_db(
863            &path,
864            scope.as_deref(),
865            backend,
866            convex_snapshot.as_deref(),
867            query,
868            OutputFormat {
869                json_output: json || terse || schema || envelope,
870                compact,
871                pretty,
872                terse,
873                ultra_terse,
874                schema,
875                envelope,
876            },
877        ),
878        Some(Commands::SourceRead {
879            file,
880            path,
881            style,
882            start,
883            lines,
884            end,
885            scope,
886            json,
887            max_items,
888            max_bytes,
889            budget,
890        }) => cmd_source_read(
891            &file,
892            &path,
893            style,
894            start,
895            lines,
896            end,
897            scope.as_deref(),
898            OutputFormat {
899                json_output: json || terse || schema || envelope,
900                compact,
901                pretty,
902                terse,
903                ultra_terse,
904                schema,
905                envelope,
906            },
907            absolute,
908            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
909        ),
910        Some(Commands::MarkdownAst {
911            file,
912            path,
913            node,
914            json,
915            max_items,
916            max_bytes,
917            budget,
918        }) => cmd_markdown_ast(
919            &file,
920            &path,
921            node.as_deref(),
922            OutputFormat {
923                json_output: json || terse || schema || envelope,
924                compact,
925                pretty,
926                terse,
927                ultra_terse,
928                schema,
929                envelope,
930            },
931            absolute,
932            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
933        ),
934        Some(Commands::SymbolRead {
935            symbol,
936            file,
937            path,
938            scope,
939            json,
940            max_items,
941            max_bytes,
942            budget,
943        }) => cmd_symbol_read(
944            &symbol,
945            file.as_deref(),
946            &path,
947            scope.as_deref(),
948            OutputFormat {
949                json_output: json || terse || schema || envelope,
950                compact,
951                pretty,
952                terse,
953                ultra_terse,
954                schema,
955                envelope,
956            },
957            absolute,
958            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
959        ),
960        Some(Commands::Audit {
961            skills_dir,
962            manifest,
963            usage,
964            cleanup,
965            report,
966            json,
967        }) => cmd_audit(
968            &skills_dir,
969            manifest,
970            usage,
971            cleanup,
972            report,
973            json || terse || schema || envelope,
974            compact,
975            pretty,
976            terse,
977            schema,
978        ),
979        Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
980            &path,
981            scope.as_deref(),
982            json || terse || schema || envelope,
983            pretty,
984            terse,
985            schema,
986        ),
987        Some(Commands::Init {
988            path,
989            codex,
990            opencode,
991            workspace,
992        }) => cmd_init(&path, codex, opencode, workspace),
993        Some(Commands::Lint {
994            file,
995            index,
996            entities_from,
997            json,
998        }) => cmd_lint(
999            &file,
1000            index,
1001            entities_from,
1002            json || terse || schema || envelope,
1003            compact,
1004            pretty,
1005            terse,
1006            schema,
1007        ),
1008        Some(Commands::Summarize {
1009            symbol,
1010            file,
1011            extract,
1012            diff,
1013            stats,
1014            path,
1015            json,
1016        }) => cmd_summarize(
1017            symbol,
1018            file,
1019            extract,
1020            diff,
1021            stats,
1022            &path,
1023            json || terse || schema || envelope,
1024            compact,
1025            pretty,
1026            terse,
1027            schema,
1028        ),
1029        Some(Commands::Semantic {
1030            query,
1031            path,
1032            scope,
1033            limit,
1034            kind,
1035            json,
1036        }) => cmd_semantic_related(
1037            &query,
1038            &path,
1039            scope.as_deref(),
1040            limit,
1041            kind,
1042            json || terse || schema || envelope,
1043            compact,
1044            pretty,
1045            terse,
1046            schema,
1047        ),
1048        Some(Commands::DiffDigest {
1049            path,
1050            cached,
1051            revision,
1052            max_parsed_files,
1053            json,
1054        }) => cmd_diff_digest(
1055            &path,
1056            cached,
1057            revision.as_deref(),
1058            max_parsed_files,
1059            OutputFormat {
1060                json_output: json || terse || schema || envelope,
1061                compact,
1062                pretty,
1063                terse,
1064                ultra_terse,
1065                schema,
1066                envelope,
1067            },
1068        ),
1069        Some(Commands::Impact {
1070            path,
1071            cached,
1072            revision,
1073            scope,
1074            limit,
1075            json,
1076        }) => cmd_impact(
1077            &path,
1078            cached,
1079            revision.as_deref(),
1080            scope.as_deref(),
1081            limit,
1082            OutputFormat {
1083                json_output: json || terse || schema || envelope,
1084                compact,
1085                pretty,
1086                terse,
1087                ultra_terse,
1088                schema,
1089                envelope,
1090            },
1091        ),
1092        Some(Commands::TestDigest {
1093            path,
1094            input,
1095            runner,
1096            json,
1097        }) => cmd_test_digest(
1098            &path,
1099            input.as_deref(),
1100            runner.as_deref(),
1101            OutputFormat {
1102                json_output: json || terse || schema || envelope,
1103                compact,
1104                pretty,
1105                terse,
1106                ultra_terse,
1107                schema,
1108                envelope,
1109            },
1110        ),
1111        Some(Commands::LogDigest { path, input, json }) => cmd_log_digest(
1112            &path,
1113            input.as_deref(),
1114            OutputFormat {
1115                json_output: json || terse || schema || envelope,
1116                compact,
1117                pretty,
1118                terse,
1119                ultra_terse,
1120                schema,
1121                envelope,
1122            },
1123        ),
1124        Some(Commands::ContextPack {
1125            path,
1126            test_input,
1127            runner,
1128            log_input,
1129            json,
1130            max_items,
1131            max_bytes,
1132            budget,
1133            convex_snapshot,
1134        }) => cmd_context_pack(
1135            &path,
1136            test_input.as_deref(),
1137            runner.as_deref(),
1138            log_input.as_deref(),
1139            OutputFormat {
1140                json_output: json || terse || schema || envelope,
1141                compact,
1142                pretty,
1143                terse,
1144                ultra_terse,
1145                schema,
1146                envelope,
1147            },
1148            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1149            convex_snapshot.as_deref(),
1150        ),
1151        Some(Commands::ConflictMatrix {
1152            targets,
1153            path,
1154            scope,
1155            depth,
1156            limit,
1157            impact_limit,
1158            json,
1159        }) => cmd_conflict_matrix(
1160            &path,
1161            scope.as_deref(),
1162            &targets,
1163            depth,
1164            limit,
1165            impact_limit,
1166            OutputFormat {
1167                json_output: json || terse || schema || envelope,
1168                compact,
1169                pretty,
1170                terse,
1171                ultra_terse,
1172                schema,
1173                envelope,
1174            },
1175        ),
1176        Some(Commands::DispatchTrace {
1177            targets,
1178            path,
1179            scope,
1180            depth,
1181            limit,
1182            impact_limit,
1183            format,
1184            json,
1185        }) => cmd_dispatch_trace(
1186            DispatchTraceOptions {
1187                path: &path,
1188                scope: scope.as_deref(),
1189                raw_targets: &targets,
1190                depth,
1191                limit,
1192                impact_limit,
1193                trace_format: if json {
1194                    DispatchTraceFormat::Json
1195                } else {
1196                    format
1197                },
1198            },
1199            OutputFormat {
1200                json_output: json || terse || schema || envelope,
1201                compact,
1202                pretty,
1203                terse,
1204                ultra_terse,
1205                schema,
1206                envelope,
1207            },
1208        ),
1209        Some(Commands::DependencyDag {
1210            targets,
1211            path,
1212            scope,
1213            depth,
1214            limit,
1215            json,
1216        }) => cmd_dependency_dag(
1217            &path,
1218            scope.as_deref(),
1219            &targets,
1220            depth,
1221            limit,
1222            OutputFormat {
1223                json_output: json || terse || schema || envelope,
1224                compact,
1225                pretty,
1226                terse,
1227                ultra_terse,
1228                schema,
1229                envelope,
1230            },
1231        ),
1232        Some(Commands::TokenSavings {
1233            fixture,
1234            fail_under,
1235            json,
1236        }) => token_savings::cmd_token_savings(
1237            &fixture,
1238            fail_under,
1239            OutputFormat {
1240                json_output: json || terse || schema || envelope,
1241                compact,
1242                pretty,
1243                terse,
1244                ultra_terse,
1245                schema,
1246                envelope,
1247            },
1248        ),
1249        Some(Commands::MetricDigest {
1250            input,
1251            baseline,
1252            metrics,
1253            lower_is_better,
1254            higher_is_better,
1255            history,
1256            top,
1257            json,
1258        }) => cmd_metric_digest(
1259            MetricDigestOptions {
1260                input_path: input.as_deref(),
1261                baseline_path: baseline.as_deref(),
1262                metrics: &metrics,
1263                lower_is_better: &lower_is_better,
1264                higher_is_better: &higher_is_better,
1265                history,
1266                top,
1267            },
1268            OutputFormat {
1269                json_output: json || terse || schema || envelope,
1270                compact,
1271                pretty,
1272                terse,
1273                ultra_terse,
1274                schema,
1275                envelope,
1276            },
1277        ),
1278        Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1279            &fixture,
1280            OutputFormat {
1281                json_output: json || terse || schema || envelope,
1282                compact,
1283                pretty,
1284                terse,
1285                ultra_terse,
1286                schema,
1287                envelope,
1288            },
1289        ),
1290        Some(Commands::TokenGate { command }) => {
1291            cmd_token_gate(command, OutputFormat {
1292                json_output: true,
1293                compact,
1294                pretty,
1295                terse,
1296                ultra_terse,
1297                schema,
1298                envelope,
1299            })?;
1300            Ok(())
1301        },
1302        Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1303            &topic,
1304            OutputFormat {
1305                json_output: json || terse || schema || envelope,
1306                compact,
1307                pretty,
1308                terse,
1309                ultra_terse,
1310                schema,
1311                envelope,
1312            },
1313        ),
1314        Some(Commands::SessionDigest {
1315            path,
1316            input,
1317            source,
1318            json,
1319        }) => cmd_session_digest(
1320            &path,
1321            input.as_deref(),
1322            source.as_deref(),
1323            OutputFormat {
1324                json_output: json || terse || schema || envelope,
1325                compact,
1326                pretty,
1327                terse,
1328                ultra_terse,
1329                schema,
1330                envelope,
1331            },
1332        ),
1333        Some(Commands::SessionCost {
1334            input,
1335            source,
1336            json,
1337        }) => cmd_session_cost(
1338            input.as_deref(),
1339            source.as_deref(),
1340            OutputFormat {
1341                json_output: json || terse || schema || envelope,
1342                compact,
1343                pretty,
1344                terse,
1345                ultra_terse,
1346                schema,
1347                envelope,
1348            },
1349        ),
1350        Some(Commands::SessionReview {
1351            path,
1352            next_context,
1353            json,
1354            max_items,
1355            max_bytes,
1356            budget,
1357        }) => cmd_session_review_with_budget(
1358            &path,
1359            next_context,
1360            OutputFormat {
1361                json_output: json || terse || schema || envelope,
1362                compact,
1363                pretty,
1364                terse,
1365                ultra_terse,
1366                schema,
1367                envelope,
1368            },
1369            ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1370        ),
1371        Some(Commands::Status {
1372            path,
1373            fix,
1374            no_fix,
1375            json,
1376        }) => cmd_status(
1377            &path,
1378            StatusCommandOptions {
1379                fix,
1380                no_fix,
1381                json_output: json || terse || schema || envelope,
1382                compact,
1383                pretty,
1384                terse,
1385                schema,
1386            },
1387        ),
1388        Some(Commands::Locks { path, scope, json }) => cmd_locks(
1389            &path,
1390            scope.as_deref(),
1391            json || terse || schema || envelope,
1392            compact,
1393            pretty,
1394            terse,
1395            schema,
1396        ),
1397        None => {
1398            println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1399            println!("Run `tsift --help` for usage.");
1400            Ok(())
1401        }
1402    }
1403}
1404
1405/// Classify a task description into a model tier.
1406/// Returns (tier_name, model_id).
1407pub fn classify_task(task: &str) -> (&'static str, &'static str) {
1408    let lower = task.to_lowercase();
1409    // Architecture/design signals → opus
1410    for signal in &[
1411        "architect",
1412        "architecture",
1413        "design",
1414        "plan",
1415        "strateg",
1416        "analy",
1417        "review",
1418        "evaluate",
1419        "assess",
1420    ] {
1421        if lower.contains(signal) {
1422            return ("opus", "claude-opus-4-6");
1423        }
1424    }
1425    // Edit/write signals → sonnet
1426    for signal in &[
1427        "edit",
1428        "write",
1429        "fix",
1430        "change",
1431        "update",
1432        "create",
1433        "add ",
1434        "remove",
1435        "delete",
1436        "modify",
1437        "refactor",
1438        "implement",
1439        "build",
1440    ] {
1441        if lower.contains(signal) {
1442            return ("sonnet", "claude-sonnet-4-6");
1443        }
1444    }
1445    // Default: search/lookup → haiku
1446    ("haiku", "claude-haiku-4-5-20251001")
1447}
1448
1449#[cfg(test)]
1450fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
1451    to_json_schema(val, pretty, terse, false, false)
1452}
1453
1454/// Add top-level `tagpath_index_stale: true` + `tagpath_stale_reason: <reason>`
1455/// fields to a JSON response when the tagpath adapter reported any helper
1456/// going stale. JSON consumers (`tsift --envelope` / `--json` callers) can
1457/// then act on the same condition the stderr `tagpath_index_stale: …` log
1458/// already surfaces without parsing logs. No-op when `stale=false` or when
1459/// `value` is not a JSON object.
1460pub(crate) fn inject_tagpath_stale_into_json(
1461    value: &mut serde_json::Value,
1462    stale: bool,
1463    reason: Option<&str>,
1464) {
1465    if !stale {
1466        return;
1467    }
1468    if let Some(obj) = value.as_object_mut() {
1469        obj.insert(
1470            "tagpath_index_stale".to_string(),
1471            serde_json::Value::Bool(true),
1472        );
1473        if let Some(reason) = reason {
1474            obj.insert(
1475                "tagpath_stale_reason".to_string(),
1476                serde_json::Value::String(reason.to_string()),
1477            );
1478        }
1479    }
1480}
1481
1482pub(crate) fn to_json_schema<T: serde::Serialize>(
1483    val: &T,
1484    pretty: bool,
1485    terse: bool,
1486    ultra_terse: bool,
1487    schema: bool,
1488) -> anyhow::Result<String> {
1489    if terse || schema {
1490        let value = serde_json::to_value(val)?;
1491        let mut transformed = if terse { terse_transform(value) } else { value };
1492        if ultra_terse {
1493            transformed = ultra_terse_transform(transformed);
1494            transformed = edge_index_transform(transformed);
1495        }
1496        if schema {
1497            transformed = schema_transform(transformed);
1498        }
1499        if terse {
1500            let terse_schema = terse_schema_for(&transformed);
1501            let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
1502            if pretty {
1503                Ok(serde_json::to_string_pretty(&wrapped)?)
1504            } else {
1505                Ok(serde_json::to_string(&wrapped)?)
1506            }
1507        } else if pretty {
1508            Ok(serde_json::to_string_pretty(&transformed)?)
1509        } else {
1510            Ok(serde_json::to_string(&transformed)?)
1511        }
1512    } else if pretty {
1513        Ok(serde_json::to_string_pretty(val)?)
1514    } else {
1515        Ok(serde_json::to_string(val)?)
1516    }
1517}
1518
1519pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
1520    ToolEnvelopeMetric {
1521        label: label.to_string(),
1522        value: value.to_string(),
1523    }
1524}
1525
1526pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
1527    let mut seen = HashSet::new();
1528    let mut deduped = Vec::new();
1529    for value in values {
1530        if seen.insert(value.clone()) {
1531            deduped.push(value);
1532        }
1533    }
1534    deduped
1535}
1536
1537pub(crate) fn print_json_or_envelope<T: Serialize>(
1538    report: &T,
1539    format: &OutputFormat,
1540    tool: &str,
1541    view: &str,
1542    summary: ToolEnvelopeSummary,
1543    truncated: bool,
1544    follow_up: Vec<String>,
1545) -> Result<()> {
1546    if format.envelope {
1547        let schema = format.schema || tool == "source-read";
1548        let envelope = ToolEnvelope {
1549            tool,
1550            view,
1551            summary,
1552            truncated,
1553            follow_up: dedupe_preserve_order(follow_up),
1554            report,
1555        };
1556        println!(
1557            "{}",
1558            to_json_schema(
1559                &envelope,
1560                format.pretty,
1561                format.terse,
1562                format.ultra_terse,
1563                schema
1564            )?
1565        );
1566    } else {
1567        println!(
1568            "{}",
1569            to_json_schema(
1570                report,
1571                format.pretty,
1572                format.terse,
1573                format.ultra_terse,
1574                format.schema
1575            )?
1576        );
1577    }
1578    Ok(())
1579}
1580
1581pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
1582    bytes.div_ceil(4)
1583}
1584
1585fn cmd_token_gate(
1586    command: cli::TokenGateCommand,
1587    format: OutputFormat,
1588) -> Result<()> {
1589    match command {
1590        cli::TokenGateCommand::Sample {
1591            surface,
1592            path,
1593            scope,
1594            target,
1595            depth,
1596            sample_index,
1597            json: _,
1598        } => cmd_token_gate_sample(&surface, &path, scope.as_deref(), target.as_deref(), depth, sample_index),
1599        cli::TokenGateCommand::Evaluate {
1600            history,
1601            allowed_regression_percent,
1602            json: _,
1603        } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
1604    }
1605}
1606
1607fn cmd_token_gate_sample(
1608    surface: &str,
1609    path: &Path,
1610    scope: Option<&str>,
1611    target: Option<&str>,
1612    depth: usize,
1613    sample_index: usize,
1614) -> Result<()> {
1615    if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
1616        bail!(
1617            "unknown surface `{}`; expected one of: {}",
1618            surface,
1619            token_gate::TOKEN_GATE_SURFACES.join(", ")
1620        );
1621    }
1622
1623    let path_str = path.to_string_lossy().to_string();
1624    let tsift_bin = std::env::current_exe()?;
1625
1626    let args: Vec<String> = match surface {
1627        "context_pack" => vec![
1628            "context-pack".to_string(),
1629            "--json".to_string(),
1630            path_str,
1631        ],
1632        "session_review_next_context" => vec![
1633            "session-review".to_string(),
1634            "--json".to_string(),
1635            "--next-context".to_string(),
1636            path_str,
1637        ],
1638        "graph_db_evidence" => {
1639            let tgt = target.unwrap_or("default").to_string();
1640            vec![
1641                "graph-db".to_string(),
1642                "--json".to_string(),
1643                "--path".to_string(),
1644                path_str,
1645                "evidence".to_string(),
1646                tgt,
1647                "--depth".to_string(),
1648                depth.to_string(),
1649            ]
1650        }
1651        "conflict_matrix" => {
1652            let tgt = target.unwrap_or("default").to_string();
1653            let mut a = vec![
1654                "conflict-matrix".to_string(),
1655                "--json".to_string(),
1656                "--path".to_string(),
1657                path_str,
1658                "--depth".to_string(),
1659                depth.to_string(),
1660            ];
1661            if let Some(s) = scope {
1662                a.push("--scope".to_string());
1663                a.push(s.to_string());
1664            }
1665            a.push(tgt);
1666            a
1667        }
1668        "dispatch_trace" => {
1669            let tgt = target.unwrap_or("default").to_string();
1670            vec![
1671                "dispatch-trace".to_string(),
1672                "--json".to_string(),
1673                "--path".to_string(),
1674                path_str,
1675                tgt,
1676            ]
1677        }
1678        _ => bail!("unhandled surface: {}", surface),
1679    };
1680
1681    let start = Instant::now();
1682    let child = Command::new(&tsift_bin)
1683        .args(&args)
1684        .stdout(Stdio::piped())
1685        .stderr(Stdio::piped())
1686        .env("TSIFT_QUIET", "1")
1687        .spawn();
1688    let output = match child {
1689        Ok(c) => c.wait_with_output()?,
1690        Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
1691    };
1692    let runtime_micros = start.elapsed().as_micros() as f64;
1693
1694    let stdout = String::from_utf8_lossy(&output.stdout);
1695    let envelope_bytes = stdout.trim().len() as f64;
1696    let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
1697
1698    let cache_hit_rate_percent = 0.0;
1699    let raw_read_avoidance = 0.0;
1700    let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
1701
1702    let timestamp = iso_timestamp_now();
1703    let id = format!(
1704        "{surface}-baseline-{}-sample-{sample_index}",
1705        &timestamp[..10]
1706    );
1707    let label = format!(
1708        "token-gate baseline {surface} sample {sample_index} for {}",
1709        path.display()
1710    );
1711
1712    let mut metrics = BTreeMap::new();
1713    metrics.insert("prompt_tokens".to_string(), prompt_tokens);
1714    metrics.insert("envelope_bytes".to_string(), envelope_bytes);
1715    metrics.insert("runtime_micros".to_string(), runtime_micros);
1716    metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
1717    metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
1718    metrics.insert("useful_hit_density".to_string(), useful_hit_density);
1719
1720    let sample = token_gate::TokenGateSample {
1721        label,
1722        id,
1723        timestamp: Some(timestamp),
1724        surface: surface.to_string(),
1725        metrics,
1726    };
1727
1728    println!("{}", serde_json::to_string_pretty(&sample)?);
1729    Ok(())
1730}
1731
1732fn cmd_token_gate_evaluate(
1733    history_path: Option<&Path>,
1734    allowed_regression_percent: f64,
1735    format: &OutputFormat,
1736) -> Result<()> {
1737    let history_path = history_path
1738        .map(PathBuf::from)
1739        .unwrap_or_else(|| {
1740            let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1741            p.push("../../fixtures/token-gate-history.json");
1742            p
1743        });
1744
1745    let raw = std::fs::read_to_string(&history_path)
1746        .with_context(|| format!("failed to read token gate history: {}", history_path.display()))?;
1747    let samples = token_gate::parse_token_history(&raw)?;
1748    let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
1749
1750    if format.json_output {
1751        println!("{}", to_json_schema(&report, format.pretty, format.terse, false, format.schema)?);
1752    } else {
1753        println!("Token Gate Report");
1754        println!("  min_samples: {}", report.min_samples);
1755        println!("  allowed_regression: {:.1}%", report.allowed_regression_percent);
1756        println!("  decision: {:?}", report.decision);
1757        for eval in &report.surface_evaluations {
1758            println!(
1759                "  {} ({} samples): {:?}",
1760                eval.display_name, eval.sample_count, eval.verdict
1761            );
1762            for me in &eval.metric_evaluations {
1763                println!(
1764                    "    {} ({:?}): {}",
1765                    me.metric, me.direction, me.diagnostic
1766                );
1767            }
1768        }
1769        for d in &report.diagnostics {
1770            println!("  ! {}", d);
1771        }
1772    }
1773    Ok(())
1774}
1775
1776fn iso_timestamp_now() -> String {
1777    let dur = SystemTime::now()
1778        .duration_since(UNIX_EPOCH)
1779        .unwrap_or_default();
1780    let total_secs = dur.as_secs();
1781    let days_since_epoch = total_secs / 86400;
1782    let (year, month, day) = days_to_ymd(days_since_epoch);
1783    let time_of_day = total_secs % 86400;
1784    let hour = (time_of_day / 3600) as u8;
1785    let minute = ((time_of_day % 3600) / 60) as u8;
1786    let second = (time_of_day % 60) as u8;
1787    format!(
1788        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1789        year, month, day, hour, minute, second
1790    )
1791}
1792
1793fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
1794    let mut year = 1970u64;
1795    loop {
1796        let days_in_year = if is_leap(year) { 366 } else { 365 };
1797        if days < days_in_year {
1798            break;
1799        }
1800        days -= days_in_year;
1801        year += 1;
1802    }
1803    let leap = is_leap(year);
1804    let month_days: [u8; 12] = if leap {
1805        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1806    } else {
1807        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1808    };
1809    let mut month: u8 = 1;
1810    for &md in &month_days {
1811        if days < md as u64 {
1812            break;
1813        }
1814        days -= md as u64;
1815        month += 1;
1816    }
1817    let day = days as u8 + 1;
1818    (year, month, day)
1819}
1820
1821fn is_leap(year: u64) -> bool {
1822    year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
1823}
1824
1825fn persist_transcript_artifact(
1826    root: &Path,
1827    prefix: &str,
1828    suffix: &str,
1829    key: &str,
1830    body: &str,
1831    expand: String,
1832) -> Result<TranscriptArtifactRef> {
1833    let handle = stable_handle(prefix, key);
1834    let artifacts_dir = root.join(".tsift/artifacts");
1835    fs::create_dir_all(&artifacts_dir).with_context(|| {
1836        format!(
1837            "creating transcript artifacts dir: {}",
1838            artifacts_dir.display()
1839        )
1840    })?;
1841    let file_name = format!("{handle}.{suffix}");
1842    let artifact_path = artifacts_dir.join(file_name);
1843    fs::write(&artifact_path, body)
1844        .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
1845    let rel_path = relativize_pathbuf(&artifact_path, root);
1846    Ok(TranscriptArtifactRef {
1847        handle,
1848        path: rel_path.display().to_string(),
1849        bytes: body.len(),
1850        lines: body.lines().count(),
1851        expand,
1852    })
1853}
1854
1855fn terse_key(key: &str) -> &str {
1856    match key {
1857        "name" => "n",
1858        "kind" => "k",
1859        "file" => "f",
1860        "line" => "l",
1861        "path" => "p",
1862        "from" => "fr",
1863        "type" => "ty",
1864        "text" => "tx",
1865        "new" => "nw",
1866        "run" => "r",
1867        "use" => "u",
1868        "score" => "sc",
1869        "language" => "la",
1870        "status" => "st",
1871        "state" => "stt",
1872        "error" => "err",
1873        "errors" => "ers",
1874        "hops" => "hp",
1875        "tags" => "tg",
1876        "model" => "ml",
1877        "skill" => "sk",
1878        "count" => "ct",
1879        "total" => "tot",
1880        "column" => "col",
1881        "description" => "dsc",
1882        "end_line" => "el",
1883        "signature" => "sig",
1884        "parent_module" => "pm",
1885        "visibility" => "vis",
1886        "match_type" => "mt",
1887        "caller_file" => "cf",
1888        "caller_name" => "cn",
1889        "caller_line" => "cl",
1890        "callee_name" => "en",
1891        "call_site_line" => "csl",
1892        "members" => "m",
1893        "refs" => "refs",
1894        "role" => "rl",
1895        "peer" => "pr",
1896        "modularity" => "q",
1897        "modularity_contribution" => "mc",
1898        "iterations" => "it",
1899        "node_count" => "nc",
1900        "edge_count" => "ec",
1901        "community_count" => "cc",
1902        "communities" => "cms",
1903        "community" => "cm",
1904        "community_diagnostics" => "cd",
1905        "cache_hit" => "cah",
1906        "tagpath_state" => "tps",
1907        "tagpath_stale_reason" => "tsr",
1908        "annotated_community_count" => "acc",
1909        "annotated_member_count" => "amc",
1910        "ambiguous_member_count" => "ambc",
1911        "ambiguous_members" => "amb",
1912        "candidate_count" => "cand",
1913        "tagpath_candidate_count" => "tcand",
1914        "evidence" => "ev",
1915        "chosen_file" => "chf",
1916        "symbol" => "s",
1917        "symbols" => "sy",
1918        "definitions" => "df",
1919        "callers" => "crs",
1920        "callees" => "ces",
1921        "total_tracked" => "tt",
1922        "modified" => "md",
1923        "deleted" => "dl",
1924        "unchanged" => "uc",
1925        "changes" => "ch",
1926        "prune_stats" => "ps",
1927        "hits" => "h",
1928        "rank" => "rk",
1929        "snippet" => "sn",
1930        "confidence" => "co",
1931        "index" => "ix",
1932        "summaries" => "sms",
1933        "recommendations" => "rec",
1934        "total_files" => "tf",
1935        "stale_files" => "sf",
1936        "last_indexed_secs_ago" => "age",
1937        "cached_files" => "caf",
1938        "total_indexed_files" => "tif",
1939        "coverage_pct" => "cov",
1940        "symbol_name" => "syn",
1941        "file_path" => "fp",
1942        "content_hash" => "hsh",
1943        "summary" => "sum",
1944        "tool" => "tl",
1945        "view" => "vw",
1946        "truncated" => "tr",
1947        "follow_up" => "fu",
1948        "report" => "rp",
1949        "metrics" => "ms",
1950        "label" => "lb",
1951        "value" => "v",
1952        "command" => "cmd",
1953        "exit_code" => "xc",
1954        "success" => "ok",
1955        "artifact" => "art",
1956        "digest" => "dg",
1957        "bytes" => "bt",
1958        "lines" => "lns",
1959        "expand" => "xp",
1960        "entities" => "ent",
1961        "relationships" => "rel",
1962        "concept_labels" => "cls",
1963        "extracted_at" => "at",
1964        "tokens_input" => "ti",
1965        "tokens_output" => "tout",
1966        "total_summaries" => "ts",
1967        "stale_count" => "stc",
1968        "total_tokens_input" => "tti",
1969        "total_tokens_output" => "tto",
1970        "estimated_tokens_saved" => "ets",
1971        "files_processed" => "fps",
1972        "symbols_extracted" => "se",
1973        "skills_dir" => "sd",
1974        "healthy" => "ok",
1975        "broken" => "brk",
1976        "skills" => "sks",
1977        "manifest_diffs" => "mdf",
1978        "similar_pairs" => "sim",
1979        "usage" => "usg",
1980        "cleanup" => "cln",
1981        "has_skill_md" => "hsm",
1982        "is_symlink" => "isl",
1983        "issues" => "iss",
1984        "invocation_count" => "inv",
1985        "reasons" => "rsn",
1986        "token_estimate" => "te",
1987        "skill_a" => "sa",
1988        "skill_b" => "sb",
1989        "desc_a" => "da",
1990        "desc_b" => "db",
1991        "annotations" => "ann",
1992        "entity" => "ety",
1993        "suggestion" => "sug",
1994        "columns" => "cols",
1995        "row_count" => "rc",
1996        "notnull" => "nn",
1997        "default_value" => "dv",
1998        "replace_all" => "ra",
1999        other => other,
2000    }
2001}
2002
2003fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2004    match val {
2005        serde_json::Value::Object(map) => {
2006            let mut new_map = serde_json::Map::new();
2007            for (k, v) in map {
2008                new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2009            }
2010            serde_json::Value::Object(new_map)
2011        }
2012        serde_json::Value::Array(arr) => {
2013            serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2014        }
2015        other => other,
2016    }
2017}
2018
2019fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2020    match val {
2021        serde_json::Value::Object(mut map) => {
2022            let is_graph_node =
2023                map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2024            let is_graph_edge =
2025                map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2026            if is_graph_node || is_graph_edge {
2027                map.remove("properties");
2028                map.remove("provenance");
2029                map.remove("freshness");
2030            }
2031            if is_graph_edge
2032                && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2033                    *s = abbreviate_edge_kind(s).to_string();
2034                }
2035            let is_coverage = map.contains_key("mode")
2036                && (map.contains_key("total_sector_count")
2037                    || map.contains_key("dirty_sector_count"));
2038            if is_coverage {
2039                map.remove("active_rebuild");
2040                map.remove("completed_dirty_sector_count");
2041                map.remove("mounted_sector_count");
2042                map.remove("rebuilding_sector_count");
2043                map.remove("resumed_sector_count");
2044                map.remove("reused_sector_count");
2045            }
2046            if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2047                *s = truncate_for_ultra_terse(s, 80);
2048            }
2049            if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2050                *s = truncate_for_ultra_terse(s, 80);
2051            }
2052            let new_map: serde_json::Map<String, serde_json::Value> = map
2053                .into_iter()
2054                .map(|(k, v)| (k, ultra_terse_transform(v)))
2055                .collect();
2056            serde_json::Value::Object(new_map)
2057        }
2058        serde_json::Value::Array(arr) => {
2059            serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2060        }
2061        other => other,
2062    }
2063}
2064
2065fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2066    match val {
2067        serde_json::Value::Object(mut map) => {
2068            let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2069                nodes.as_array().map(|arr| {
2070                    arr.iter()
2071                        .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2072                        .collect()
2073                })
2074            });
2075            if let Some(ref ids) = node_ids {
2076                let id_map: std::collections::HashMap<&str, usize> = ids
2077                    .iter()
2078                    .enumerate()
2079                    .map(|(i, id)| (id.as_str(), i))
2080                    .collect();
2081                if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2082                    for edge in edges.iter_mut() {
2083                        if let serde_json::Value::Object(edge_map) = edge {
2084                            if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id") {
2085                                if let Some(&idx) = id_map.get(fid.as_str()) {
2086                                    edge_map.insert("from".to_string(), serde_json::Value::Number(idx.into()));
2087                                } else {
2088                                    edge_map.insert("from_id".to_string(), serde_json::Value::String(fid));
2089                                }
2090                            }
2091                            if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2092                                if let Some(&idx) = id_map.get(tid.as_str()) {
2093                                    edge_map.insert("to".to_string(), serde_json::Value::Number(idx.into()));
2094                                } else {
2095                                    edge_map.insert("to_id".to_string(), serde_json::Value::String(tid));
2096                                }
2097                            }
2098                        }
2099                    }
2100                }
2101            }
2102            let new_map: serde_json::Map<String, serde_json::Value> = map
2103                .into_iter()
2104                .map(|(k, v)| (k, edge_index_transform(v)))
2105                .collect();
2106            serde_json::Value::Object(new_map)
2107        }
2108        serde_json::Value::Array(arr) => {
2109            serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2110        }
2111        other => other,
2112    }
2113}
2114
2115fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2116    if s.len() <= max_len {
2117        s.to_string()
2118    } else {
2119        let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2120        format!("{truncated}...")
2121    }
2122}
2123
2124fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2125    let mut keys = HashSet::new();
2126    collect_terse_keys(val, &mut keys);
2127    let mut schema = serde_json::Map::new();
2128    for (long, short) in TERSE_PAIRS {
2129        if keys.contains(*short) {
2130            schema.insert(
2131                short.to_string(),
2132                serde_json::Value::String(long.to_string()),
2133            );
2134        }
2135    }
2136    serde_json::Value::Object(schema)
2137}
2138
2139fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2140    match val {
2141        serde_json::Value::Object(map) => {
2142            for (k, v) in map {
2143                keys.insert(k.clone());
2144                collect_terse_keys(v, keys);
2145            }
2146        }
2147        serde_json::Value::Array(arr) => {
2148            for v in arr {
2149                collect_terse_keys(v, keys);
2150            }
2151        }
2152        _ => {}
2153    }
2154}
2155
2156fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2157    match val {
2158        serde_json::Value::Array(arr) if arr.len() >= 2 => {
2159            if let Some(cols) = homogeneous_keys(&arr) {
2160                let rows: Vec<serde_json::Value> = arr
2161                    .into_iter()
2162                    .map(|item| {
2163                        if let serde_json::Value::Object(map) = item {
2164                            let vals: Vec<serde_json::Value> = cols
2165                                .iter()
2166                                .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2167                                .collect();
2168                            serde_json::Value::Array(vals)
2169                        } else {
2170                            item
2171                        }
2172                    })
2173                    .collect();
2174                let col_vals: Vec<serde_json::Value> =
2175                    cols.into_iter().map(serde_json::Value::String).collect();
2176                serde_json::json!({"_c": col_vals, "_r": rows})
2177            } else {
2178                serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2179            }
2180        }
2181        serde_json::Value::Array(arr) => {
2182            serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2183        }
2184        serde_json::Value::Object(map) => {
2185            let new_map: serde_json::Map<String, serde_json::Value> = map
2186                .into_iter()
2187                .map(|(k, v)| (k, schema_transform(v)))
2188                .collect();
2189            serde_json::Value::Object(new_map)
2190        }
2191        other => other,
2192    }
2193}
2194
2195fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2196    let first = arr.first()?.as_object()?;
2197    let keys: Vec<String> = first.keys().cloned().collect();
2198    for item in &arr[1..] {
2199        let obj = item.as_object()?;
2200        if obj.len() != keys.len() {
2201            return None;
2202        }
2203        for k in &keys {
2204            if !obj.contains_key(k) {
2205                return None;
2206            }
2207        }
2208    }
2209    Some(keys)
2210}
2211
2212const TERSE_PAIRS: &[(&str, &str)] = &[
2213    ("name", "n"),
2214    ("kind", "k"),
2215    ("file", "f"),
2216    ("line", "l"),
2217    ("path", "p"),
2218    ("from", "fr"),
2219    ("type", "ty"),
2220    ("text", "tx"),
2221    ("new", "nw"),
2222    ("run", "r"),
2223    ("use", "u"),
2224    ("score", "sc"),
2225    ("language", "la"),
2226    ("status", "st"),
2227    ("state", "stt"),
2228    ("error", "err"),
2229    ("errors", "ers"),
2230    ("hops", "hp"),
2231    ("tags", "tg"),
2232    ("model", "ml"),
2233    ("skill", "sk"),
2234    ("count", "ct"),
2235    ("total", "tot"),
2236    ("column", "col"),
2237    ("description", "dsc"),
2238    ("end_line", "el"),
2239    ("signature", "sig"),
2240    ("parent_module", "pm"),
2241    ("visibility", "vis"),
2242    ("match_type", "mt"),
2243    ("caller_file", "cf"),
2244    ("caller_name", "cn"),
2245    ("caller_line", "cl"),
2246    ("callee_name", "en"),
2247    ("call_site_line", "csl"),
2248    ("members", "m"),
2249    ("refs", "refs"),
2250    ("role", "rl"),
2251    ("peer", "pr"),
2252    ("modularity", "q"),
2253    ("modularity_contribution", "mc"),
2254    ("iterations", "it"),
2255    ("node_count", "nc"),
2256    ("edge_count", "ec"),
2257    ("community_count", "cc"),
2258    ("communities", "cms"),
2259    ("community", "cm"),
2260    ("community_diagnostics", "cd"),
2261    ("cache_hit", "cah"),
2262    ("tagpath_state", "tps"),
2263    ("tagpath_stale_reason", "tsr"),
2264    ("annotated_community_count", "acc"),
2265    ("annotated_member_count", "amc"),
2266    ("ambiguous_member_count", "ambc"),
2267    ("ambiguous_members", "amb"),
2268    ("candidate_count", "cand"),
2269    ("tagpath_candidate_count", "tcand"),
2270    ("evidence", "ev"),
2271    ("chosen_file", "chf"),
2272    ("symbol", "s"),
2273    ("symbols", "sy"),
2274    ("definitions", "df"),
2275    ("callers", "crs"),
2276    ("callees", "ces"),
2277    ("total_tracked", "tt"),
2278    ("modified", "md"),
2279    ("deleted", "dl"),
2280    ("unchanged", "uc"),
2281    ("changes", "ch"),
2282    ("prune_stats", "ps"),
2283    ("hits", "h"),
2284    ("rank", "rk"),
2285    ("snippet", "sn"),
2286    ("confidence", "co"),
2287    ("index", "ix"),
2288    ("summaries", "sms"),
2289    ("recommendations", "rec"),
2290    ("total_files", "tf"),
2291    ("stale_files", "sf"),
2292    ("last_indexed_secs_ago", "age"),
2293    ("cached_files", "caf"),
2294    ("total_indexed_files", "tif"),
2295    ("coverage_pct", "cov"),
2296    ("symbol_name", "syn"),
2297    ("file_path", "fp"),
2298    ("content_hash", "hsh"),
2299    ("summary", "sum"),
2300    ("tool", "tl"),
2301    ("view", "vw"),
2302    ("truncated", "tr"),
2303    ("follow_up", "fu"),
2304    ("report", "rp"),
2305    ("metrics", "ms"),
2306    ("label", "lb"),
2307    ("value", "v"),
2308    ("command", "cmd"),
2309    ("exit_code", "xc"),
2310    ("success", "ok"),
2311    ("artifact", "art"),
2312    ("digest", "dg"),
2313    ("bytes", "bt"),
2314    ("lines", "lns"),
2315    ("expand", "xp"),
2316    ("entities", "ent"),
2317    ("relationships", "rel"),
2318    ("concept_labels", "cls"),
2319    ("extracted_at", "at"),
2320    ("tokens_input", "ti"),
2321    ("tokens_output", "tout"),
2322    ("total_summaries", "ts"),
2323    ("stale_count", "stc"),
2324    ("total_tokens_input", "tti"),
2325    ("total_tokens_output", "tto"),
2326    ("estimated_tokens_saved", "ets"),
2327    ("files_processed", "fps"),
2328    ("symbols_extracted", "se"),
2329    ("skills_dir", "sd"),
2330    ("healthy", "ok"),
2331    ("broken", "brk"),
2332    ("skills", "sks"),
2333    ("manifest_diffs", "mdf"),
2334    ("similar_pairs", "sim"),
2335    ("usage", "usg"),
2336    ("cleanup", "cln"),
2337    ("has_skill_md", "hsm"),
2338    ("is_symlink", "isl"),
2339    ("issues", "iss"),
2340    ("invocation_count", "inv"),
2341    ("reasons", "rsn"),
2342    ("token_estimate", "te"),
2343    ("skill_a", "sa"),
2344    ("skill_b", "sb"),
2345    ("desc_a", "da"),
2346    ("desc_b", "db"),
2347    ("annotations", "ann"),
2348    ("entity", "ety"),
2349    ("suggestion", "sug"),
2350    ("columns", "cols"),
2351    ("row_count", "rc"),
2352    ("notnull", "nn"),
2353    ("default_value", "dv"),
2354    ("replace_all", "ra"),
2355];
2356
2357pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
2358    let root_str = root.to_string_lossy();
2359    let prefix = format!("{}/", root_str.trim_end_matches('/'));
2360    path.strip_prefix(&prefix).unwrap_or(path).to_string()
2361}
2362
2363fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
2364    let canonical = path
2365        .canonicalize()
2366        .with_context(|| format!("canonicalizing {}", path.display()))?;
2367    let start = if canonical.is_dir() {
2368        canonical.clone()
2369    } else {
2370        canonical
2371            .parent()
2372            .map(Path::to_path_buf)
2373            .unwrap_or_else(|| canonical.clone())
2374    };
2375
2376    for ancestor in start.ancestors() {
2377        if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
2378            return Ok(ancestor.to_path_buf());
2379        }
2380    }
2381
2382    Ok(start)
2383}
2384
2385pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
2386    path.strip_prefix(root)
2387        .map(|p| p.to_path_buf())
2388        .unwrap_or_else(|_| path.to_path_buf())
2389}
2390
2391pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
2392    for edge in edges {
2393        edge.caller_file = relativize(&edge.caller_file, root);
2394    }
2395}
2396
2397pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
2398    for sym in symbols {
2399        sym.file = relativize(&sym.file, root);
2400    }
2401}
2402
2403pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
2404    for hit in hits {
2405        hit.file = relativize(&hit.file, root);
2406    }
2407}
2408
2409
2410/// Which endpoint of a `StoredEdge` is the row's primary symbol — caller
2411/// (caller list) or callee (callee list).
2412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2413pub enum EdgeSide {
2414    Caller,
2415    Callee,
2416}
2417
2418const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
2419
2420pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
2421    let root_str = root.to_string_lossy();
2422    let prefix = format!("{}/", root_str.trim_end_matches('/'));
2423    relativize_json_inner(val, &prefix);
2424}
2425
2426fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
2427    match val {
2428        serde_json::Value::Array(arr) => {
2429            for v in arr {
2430                relativize_json_inner(v, prefix);
2431            }
2432        }
2433        serde_json::Value::Object(map) => {
2434            for (k, v) in map.iter_mut() {
2435                if JSON_PATH_KEYS.contains(&k.as_str())
2436                    && let serde_json::Value::String(s) = v
2437                    && let Some(rest) = s.strip_prefix(prefix)
2438                {
2439                    *s = rest.to_string();
2440                }
2441                relativize_json_inner(v, prefix);
2442            }
2443        }
2444        _ => {}
2445    }
2446}
2447
2448pub(crate) fn format_score(score: f64, compact: bool) -> String {
2449    if compact {
2450        format!("{score:.2}")
2451    } else {
2452        format!("{score:.4}")
2453    }
2454}
2455
2456pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
2457    let trimmed = input.trim();
2458    let count = trimmed.chars().count();
2459    if count <= max_chars {
2460        return trimmed.to_string();
2461    }
2462    let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
2463    format!("{prefix}...")
2464}
2465
2466pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
2467    snippet
2468        .lines()
2469        .find(|line| !line.trim().is_empty())
2470        .map(|line| truncate_for_compact(line, 100))
2471}
2472
2473pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
2474    let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
2475    if names.len() <= limit {
2476        return names.join(", ");
2477    }
2478    format!(
2479        "{} (+{} more)",
2480        names[..limit].join(", "),
2481        names.len() - limit
2482    )
2483}
2484
2485pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
2486    let mut hasher = blake3::Hasher::new();
2487    hasher.update(prefix.as_bytes());
2488    hasher.update(&[0]);
2489    hasher.update(key.as_bytes());
2490    let hex = hasher.finalize().to_hex();
2491    format!("{prefix}-{}", &hex[..10])
2492}
2493
2494#[derive(Clone, Debug, PartialEq, Eq)]
2495struct CanonicalTagFamily {
2496    canonical: String,
2497    tag_alias: String,
2498}
2499
2500fn canonical_family_from_tagpath_family(
2501    family: tagpath_family::TagFamily,
2502) -> Option<CanonicalTagFamily> {
2503    let tag_alias = if family.dimensions.is_empty() {
2504        family.tags.join("/")
2505    } else {
2506        family
2507            .dimensions
2508            .iter()
2509            .filter(|dimension| !dimension.tags.is_empty())
2510            .map(|dimension| dimension.tags.join("."))
2511            .collect::<Vec<_>>()
2512            .join("/")
2513    };
2514
2515    if tag_alias.is_empty() {
2516        None
2517    } else {
2518        Some(CanonicalTagFamily {
2519            canonical: family.canonical,
2520            tag_alias,
2521        })
2522    }
2523}
2524
2525fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
2526    let trimmed = name.trim();
2527    if trimmed.is_empty() {
2528        return None;
2529    }
2530
2531    canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
2532}
2533
2534fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
2535    let canonical = tags
2536        .split(',')
2537        .map(str::trim)
2538        .filter(|tag| !tag.is_empty())
2539        .collect::<Vec<_>>()
2540        .join("_");
2541    if canonical.is_empty() {
2542        None
2543    } else {
2544        canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
2545    }
2546}
2547
2548pub(crate) fn canonical_tag_family_from_symbol(name: &str, tags: Option<&str>) -> Option<CanonicalTagFamily> {
2549    tags.and_then(canonical_tag_family_from_tags)
2550        .or_else(|| canonical_tag_family_from_name(name))
2551}
2552
2553fn tag_alias_from_name(name: &str) -> Option<String> {
2554    canonical_tag_family_from_name(name).map(|family| family.tag_alias)
2555}
2556
2557fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
2558    canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
2559}
2560
2561pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
2562    let query = tag_alias
2563        .split(['/', '.'])
2564        .map(str::trim)
2565        .filter(|part| !part.is_empty())
2566        .collect::<Vec<_>>()
2567        .join(" ");
2568    if query.is_empty() { None } else { Some(query) }
2569}
2570
2571#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2572struct CompactOntologyRefPreview {
2573    handle: String,
2574    tag: String,
2575    path: String,
2576    #[serde(skip_serializing_if = "Option::is_none")]
2577    title: Option<String>,
2578    #[serde(skip_serializing_if = "Option::is_none")]
2579    domain: Option<String>,
2580}
2581
2582#[derive(Clone, Debug)]
2583struct TagOntologyPreviewContext {
2584    project_root: PathBuf,
2585    tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
2586}
2587
2588#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
2589struct CompactSymbolRefPreview {
2590    handle: String,
2591    name: String,
2592    #[serde(skip_serializing_if = "Option::is_none")]
2593    tag_alias: Option<String>,
2594    #[serde(skip_serializing_if = "Vec::is_empty", default)]
2595    ontology_refs: Vec<CompactOntologyRefPreview>,
2596}
2597
2598fn build_compact_symbol_ref(
2599    prefix: &str,
2600    key: &str,
2601    name: &str,
2602    tags: Option<&str>,
2603    max_bytes: usize,
2604) -> CompactSymbolRefPreview {
2605    build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
2606}
2607
2608fn build_compact_symbol_ref_with_ontology(
2609    prefix: &str,
2610    key: &str,
2611    name: &str,
2612    tags: Option<&str>,
2613    max_bytes: usize,
2614    ontology: Option<&TagOntologyPreviewContext>,
2615) -> CompactSymbolRefPreview {
2616    let tag_alias = tag_alias_from_tags(name, tags);
2617    let ontology_refs = tag_alias
2618        .as_deref()
2619        .map(|alias| ontology_refs_for_alias(ontology, alias))
2620        .unwrap_or_default();
2621    CompactSymbolRefPreview {
2622        handle: stable_handle(prefix, key),
2623        name: truncate_for_budget(name, max_bytes),
2624        tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
2625        ontology_refs,
2626    }
2627}
2628
2629fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
2630    let report = tagpath_ontology::load_project(root).ok()?;
2631    if report.tags.is_empty() {
2632        return None;
2633    }
2634    Some(TagOntologyPreviewContext {
2635        project_root: report.project_path,
2636        tags: report
2637            .tags
2638            .into_iter()
2639            .map(|tag| (tag.tag.clone(), tag))
2640            .collect(),
2641    })
2642}
2643
2644fn ontology_refs_for_alias(
2645    ontology: Option<&TagOntologyPreviewContext>,
2646    alias: &str,
2647) -> Vec<CompactOntologyRefPreview> {
2648    let Some(ontology) = ontology else {
2649        return Vec::new();
2650    };
2651    let mut seen = BTreeSet::new();
2652    alias
2653        .split('/')
2654        .flat_map(|part| part.split('.'))
2655        .map(str::trim)
2656        .filter(|tag| !tag.is_empty())
2657        .filter_map(|tag| {
2658            let key = tag.to_ascii_lowercase();
2659            if !seen.insert(key.clone()) {
2660                return None;
2661            }
2662            let ontology_tag = ontology.tags.get(&key)?;
2663            let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
2664            Some(CompactOntologyRefPreview {
2665                handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
2666                tag: ontology_tag.tag.clone(),
2667                path,
2668                title: ontology_tag.title.clone(),
2669                domain: ontology_tag.domain.clone(),
2670            })
2671        })
2672        .collect()
2673}
2674
2675fn relativize_ontology_path(path: &Path, root: &Path) -> String {
2676    path.strip_prefix(root)
2677        .unwrap_or(path)
2678        .to_string_lossy()
2679        .replace('\\', "/")
2680}
2681
2682fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
2683    match tag_alias {
2684        Some(alias) => format!("{handle} {name} tag:{alias}"),
2685        None => format!("{handle} {name}"),
2686    }
2687}
2688
2689fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
2690    match summary.tag_alias.as_deref() {
2691        Some(alias) => format!(
2692            "{} {} tag:{} expand:{}",
2693            summary.handle, summary.symbol, alias, summary.expand
2694        ),
2695        None => format!(
2696            "{} {} expand:{}",
2697            summary.handle, summary.symbol, summary.expand
2698        ),
2699    }
2700}
2701
2702fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
2703    match symbol.tag_alias.as_deref() {
2704        Some(alias) => format!("{}@{}", symbol.handle, alias),
2705        None => format!("{}@{}", symbol.handle, symbol.name),
2706    }
2707}
2708
2709pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
2710    let trimmed = input.trim();
2711    if trimmed.len() <= max_bytes {
2712        return trimmed.to_string();
2713    }
2714    if max_bytes <= 3 {
2715        return ".".repeat(max_bytes);
2716    }
2717
2718    let mut end = 0usize;
2719    for (idx, ch) in trimmed.char_indices() {
2720        let next = idx + ch.len_utf8();
2721        if next > max_bytes.saturating_sub(3) {
2722            break;
2723        }
2724        end = next;
2725    }
2726
2727    if end == 0 {
2728        "...".to_string()
2729    } else {
2730        format!("{}...", &trimmed[..end])
2731    }
2732}
2733
2734struct TokenCappedPreview {
2735    preview: Vec<SourceLinePreview>,
2736    capped_end: usize,
2737    was_capped: bool,
2738}
2739
2740fn build_token_capped_preview(
2741    all_lines: &[&str],
2742    start: usize,
2743    end: usize,
2744    max_bytes: usize,
2745    token_cap: usize,
2746) -> TokenCappedPreview {
2747    let mut preview = Vec::new();
2748    let mut accumulated_tokens = 0usize;
2749    let mut capped_end = end;
2750    let mut was_capped = false;
2751
2752    for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
2753        let truncated = truncate_for_budget(line, max_bytes);
2754        let line_tokens = estimated_tokens_from_bytes(truncated.len());
2755        if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
2756            capped_end = start + idx - 1;
2757            was_capped = true;
2758            break;
2759        }
2760        accumulated_tokens += line_tokens;
2761        preview.push(SourceLinePreview {
2762            line: start + idx,
2763            text: truncated,
2764        });
2765    }
2766
2767    TokenCappedPreview {
2768        preview,
2769        capped_end,
2770        was_capped,
2771    }
2772}
2773
2774pub(crate) fn abbreviate_kind(kind: &str) -> &str {
2775    match kind {
2776        "function" => "fn",
2777        "method" => "meth",
2778        "module" | "mod" => "mod",
2779        "struct" => "struct",
2780        "trait" => "trait",
2781        "impl" => "impl",
2782        "class" => "cls",
2783        "interface" => "iface",
2784        "type_alias" => "type",
2785        "data_class" => "data_cls",
2786        "sealed_class" => "sealed_cls",
2787        "enum_class" => "enum_cls",
2788        "companion_object" => "comp_obj",
2789        "object" => "obj",
2790        "heading" => "h",
2791        "code_block" => "code",
2792        "alias" => "alias",
2793        other => other,
2794    }
2795}
2796
2797pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
2798    match kind {
2799        "calls" => "c",
2800        "defines" => "d",
2801        "contains" => "ct",
2802        "imports" => "i",
2803        "mentions" => "m",
2804        "mentions_concept" => "mc",
2805        "mentions_entity" => "me",
2806        "semantic_relation" => "sr",
2807        "belongs_to" => "bt",
2808        "scopes_context" => "sctx",
2809        "scopes_source" => "ssrc",
2810        "requests_context" => "rctx",
2811        "explains_result" => "er",
2812        "tagged_concept" => "tc",
2813        "tagged_entity" => "te",
2814        "related_concept" => "relc",
2815        "handled_by" => "hb",
2816        "defines_route" => "dr",
2817        "handles_route" => "hr",
2818        "targets" => "tgt",
2819        "has_vector_handle" => "hv",
2820        "parent" => "p",
2821        "child" => "ch",
2822        "uses" => "u",
2823        "projects_source" => "psrc",
2824        "records_memory_source" => "rms",
2825        "records_memory_event" => "rme",
2826        "has_ast_span" => "ha",
2827        "represents_symbol" => "rs",
2828        "contains_embedded_symbol" => "ces",
2829        "embedded_in_fence" => "ef",
2830        "contains_markdown_block" => "cmb",
2831        "contains_embedded_code" => "cec",
2832        "enclosing_module" => "em",
2833        "enclosing_section" => "es",
2834        "previous_sibling" => "psib",
2835        "next_sibling" => "nsib",
2836        "explicit_depends_on" => "edo",
2837        "worker_result_follow_up" => "wrf",
2838        "shared_resource" => "shr",
2839        "community_member" => "cm",
2840        other => other,
2841    }
2842}
2843
2844pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
2845    match mt {
2846        "exact_name" => "exact",
2847        "all_tags" => "all_tags",
2848        "partial_tags" => "partial",
2849        other => other,
2850    }
2851}
2852
2853pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
2854    path.iter()
2855        .map(|n| n.name.as_str())
2856        .collect::<Vec<_>>()
2857        .join(" -> ")
2858}
2859
2860const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
2861
2862struct SearchHitGroup {
2863    path: String,
2864    first_rank: usize,
2865    top_score: f64,
2866    confidence: String,
2867    hits: usize,
2868    samples: Vec<String>,
2869}
2870
2871fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
2872    let snippet = compact_snippet(&hit.snippet)?;
2873    Some(match hit.location.as_deref() {
2874        Some(location) => format!("{location}: {snippet}"),
2875        None => snippet,
2876    })
2877}
2878
2879pub(crate) fn group_search_hits(
2880    hits: &[sift::SearchHit],
2881    root: &Path,
2882    absolute: bool,
2883) -> Vec<SearchHitGroup> {
2884    let mut positions = BTreeMap::new();
2885    let mut groups = Vec::new();
2886    for hit in hits {
2887        let path = if absolute {
2888            hit.path.clone()
2889        } else {
2890            relativize(&hit.path, root)
2891        };
2892        let entry = positions.entry(path.clone()).or_insert_with(|| {
2893            groups.push(SearchHitGroup {
2894                path: path.clone(),
2895                first_rank: hit.rank,
2896                top_score: hit.score,
2897                confidence: format!("{:?}", hit.confidence),
2898                hits: 0,
2899                samples: Vec::new(),
2900            });
2901            groups.len() - 1
2902        });
2903        let group = &mut groups[*entry];
2904        group.hits += 1;
2905        if hit.rank < group.first_rank {
2906            group.first_rank = hit.rank;
2907        }
2908        if hit.score > group.top_score {
2909            group.top_score = hit.score;
2910        }
2911        if let Some(sample) = format_search_sample(hit)
2912            && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
2913            && !group.samples.contains(&sample)
2914        {
2915            group.samples.push(sample);
2916        }
2917    }
2918    groups.sort_by_key(|group| group.first_rank);
2919    groups
2920}
2921
2922pub(crate) fn should_collapse_search_hits(
2923    hits: &[sift::SearchHit],
2924    root: &Path,
2925    absolute: bool,
2926) -> bool {
2927    let groups = group_search_hits(hits, root, absolute);
2928    let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
2929    max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
2930}
2931
2932pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
2933    let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
2934    for edge in edges {
2935        let key = edge.caller_file.as_str();
2936        let name = if use_callers {
2937            edge.caller_name.as_str()
2938        } else {
2939            edge.callee_name.as_str()
2940        };
2941        let names = grouped.entry(key).or_default();
2942        if !names.contains(&name) {
2943            names.push(name);
2944        }
2945    }
2946
2947    grouped
2948        .into_iter()
2949        .map(|(file, names)| format!("  {} ({}): {}", file, names.len(), names.join(", ")))
2950        .collect()
2951}
2952
2953pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
2954    let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
2955    for edge in edges {
2956        *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
2957    }
2958    let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
2959    max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
2960}
2961
2962
2963fn resolve_query_index_target(
2964    root: &Path,
2965    path_hint: &Path,
2966    scope: Option<&str>,
2967) -> Result<SearchIndexTarget> {
2968    let cfg = config::Config::load(root)?;
2969    if let Some(scope_name) = scope {
2970        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
2971            return Ok(SearchIndexTarget {
2972                label: format!("submodule `{}` index", scope.id),
2973                db_path: cfg.db_path_for(root, &scope.id),
2974                source_root: scope.source_root.clone(),
2975                scope_name: Some(scope.id.clone()),
2976                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
2977            });
2978        }
2979        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
2980            return Ok(cargo_package_index_target(root, package));
2981        }
2982        config::Config::resolve_submodule(root, scope_name)?;
2983    }
2984
2985    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
2986        return Ok(SearchIndexTarget {
2987            label: format!("submodule `{}` index", scope.id),
2988            db_path: cfg.db_path_for(root, &scope.id),
2989            source_root: scope.source_root.clone(),
2990            scope_name: Some(scope.id.clone()),
2991            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
2992        });
2993    }
2994
2995    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
2996        return Ok(cargo_package_index_target(root, package));
2997    }
2998
2999    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3000        return Ok(SearchIndexTarget {
3001            label: format!("submodule `{}` index", scope.id),
3002            db_path: cfg.db_path_for(root, &scope.id),
3003            source_root: scope.source_root.clone(),
3004            scope_name: Some(scope.id.clone()),
3005            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3006        });
3007    }
3008
3009    let db_path = root.join(".tsift/index.db");
3010    if db_path.exists() {
3011        return Ok(SearchIndexTarget {
3012            label: "index".to_string(),
3013            db_path,
3014            source_root: root.to_path_buf(),
3015            scope_name: None,
3016            reindex_cmd: format!("tsift index {}", root.display()),
3017        });
3018    }
3019
3020    let scopes = config::Config::submodule_dirs(root)?;
3021    if scopes.is_empty() {
3022        return Ok(SearchIndexTarget {
3023            label: "index".to_string(),
3024            db_path,
3025            source_root: root.to_path_buf(),
3026            scope_name: None,
3027            reindex_cmd: format!("tsift index {}", root.display()),
3028        });
3029    }
3030
3031    let available_scopes = scopes
3032        .iter()
3033        .map(|scope| scope.id.as_str())
3034        .collect::<Vec<_>>()
3035        .join(", ");
3036    let indexed_scopes = scopes
3037        .iter()
3038        .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3039        .map(|scope| scope.id.as_str())
3040        .collect::<Vec<_>>();
3041    let indexed_label = if indexed_scopes.is_empty() {
3042        "none".to_string()
3043    } else {
3044        indexed_scopes.join(", ")
3045    };
3046
3047    bail!(
3048        "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: {}.",
3049        root.display(),
3050        db_path.display(),
3051        available_scopes,
3052        indexed_label
3053    );
3054}
3055
3056pub(crate) fn resolve_query_db_path(root: &Path, path_hint: &Path, scope: Option<&str>) -> Result<PathBuf> {
3057    Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3058}
3059
3060fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3061    let state = inspect_search_index(target)?;
3062    let Some(reason) = index_reason_for_state(state) else {
3063        return Ok(());
3064    };
3065
3066    match apply_search_index_update(root, target) {
3067        Ok(_) => {
3068            index::inspect_scope_invalidate_all();
3069            Ok(())
3070        }
3071        Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3072            eprintln!(
3073                "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3074                 Continuing with the current read-only index snapshot; graph results may lag. \
3075                 Retry `{}` after the active writer finishes for fresh graph results.",
3076                index_reason_detail(target, reason),
3077                target.reindex_cmd
3078            );
3079            Ok(())
3080        }
3081        Err(err) => Err(err),
3082    }
3083}
3084
3085pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3086    let root = lint::resolve_project_root_or_canonical_path(path)?;
3087    let target = resolve_query_index_target(&root, path, scope)?;
3088    ensure_query_index_current(&root, &target)?;
3089    let db_path = target.db_path;
3090    if !db_path.exists() {
3091        bail!(
3092            "no index found at {}. Run `tsift index` first.",
3093            db_path.display()
3094        );
3095    }
3096    index::IndexDb::open_read_only_resilient(&db_path)
3097}
3098
3099pub(crate) fn query_tagpath_root(
3100    root: &std::path::Path,
3101    path_hint: &std::path::Path,
3102    scope: Option<&str>,
3103) -> Result<PathBuf> {
3104    if let Some(scope_name) = scope {
3105        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3106            return Ok(scope.source_root);
3107        }
3108        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3109            return Ok(package.package_root);
3110        }
3111        config::Config::resolve_submodule(root, scope_name)?;
3112    }
3113    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3114        return Ok(scope.source_root);
3115    }
3116    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3117        return Ok(package.package_root);
3118    }
3119    Ok(root.to_path_buf())
3120}
3121
3122#[derive(Clone, Debug, Serialize, PartialEq)]
3123struct TraversalNode {
3124    handle: String,
3125    kind: String,
3126    label: String,
3127    #[serde(skip_serializing_if = "Option::is_none")]
3128    ref_id: Option<String>,
3129    #[serde(skip_serializing_if = "Option::is_none")]
3130    path: Option<String>,
3131    #[serde(skip_serializing_if = "Option::is_none")]
3132    line: Option<i64>,
3133    #[serde(skip_serializing_if = "Option::is_none")]
3134    detail: Option<String>,
3135    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3136    properties: BTreeMap<String, String>,
3137    expand: String,
3138}
3139
3140#[derive(Clone, Debug, Serialize, PartialEq)]
3141struct TraversalEdge {
3142    from: String,
3143    to: String,
3144    relation: String,
3145    #[serde(skip_serializing_if = "Option::is_none")]
3146    label: Option<String>,
3147    weight: usize,
3148}
3149
3150#[derive(Clone, Debug, Default)]
3151struct TraversalGraphBuild {
3152    nodes: BTreeMap<String, TraversalNode>,
3153    edges: Vec<TraversalEdge>,
3154    edge_keys: BTreeSet<(String, String, String)>,
3155    warnings: Vec<String>,
3156}
3157
3158pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3159const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3160const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3161const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3162const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3163    "context-pack-graph-orchestration-v1";
3164const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3165const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3166const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3167const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3168const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3169const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3170const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3171
3172#[derive(Debug, Serialize, PartialEq)]
3173struct TraversalTotals {
3174    nodes: usize,
3175    edges: usize,
3176}
3177
3178#[derive(Debug, Serialize, PartialEq)]
3179struct TraversalPathReport {
3180    from: TraversalNode,
3181    to: TraversalNode,
3182    hops: usize,
3183    nodes: Vec<TraversalNode>,
3184    edges: Vec<TraversalEdge>,
3185}
3186
3187#[derive(Debug, Serialize, PartialEq)]
3188struct TraversalRecommendation {
3189    handle: String,
3190    kind: String,
3191    label: String,
3192    reason: String,
3193    score: usize,
3194    expand: String,
3195}
3196
3197#[derive(Debug, Serialize, PartialEq)]
3198struct TraversalReport {
3199    root: String,
3200    #[serde(skip_serializing_if = "Option::is_none")]
3201    scope: Option<String>,
3202    mode: String,
3203    totals: TraversalTotals,
3204    #[serde(skip_serializing_if = "Option::is_none")]
3205    query: Option<String>,
3206    #[serde(skip_serializing_if = "Option::is_none")]
3207    target: Option<String>,
3208    nodes: Vec<TraversalNode>,
3209    edges: Vec<TraversalEdge>,
3210    #[serde(skip_serializing_if = "Option::is_none")]
3211    shortest_path: Option<TraversalPathReport>,
3212    recommendations: Vec<TraversalRecommendation>,
3213    exploration: ExplorationPacket,
3214    truncated: bool,
3215    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3216    warnings: Vec<String>,
3217}
3218
3219#[derive(Debug, Serialize, PartialEq)]
3220struct SemanticRelatedReport {
3221    root: String,
3222    #[serde(skip_serializing_if = "Option::is_none")]
3223    scope: Option<String>,
3224    query: String,
3225    embedding_model: String,
3226    count: usize,
3227    items: Vec<SemanticRelatedItem>,
3228    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3229    warnings: Vec<String>,
3230}
3231
3232#[derive(Clone, Debug, Serialize, PartialEq)]
3233struct SemanticRelatedItem {
3234    handle: String,
3235    kind: String,
3236    label: String,
3237    score: f64,
3238    #[serde(skip_serializing_if = "Option::is_none")]
3239    file_path: Option<String>,
3240    #[serde(skip_serializing_if = "Option::is_none")]
3241    source_symbol: Option<String>,
3242    #[serde(skip_serializing_if = "Option::is_none")]
3243    detail: Option<String>,
3244    expand: String,
3245}
3246
3247#[derive(Clone)]
3248struct TraversalSymbolIndexEntry {
3249    handle: String,
3250    node: TraversalNode,
3251    tokens: BTreeSet<String>,
3252}
3253
3254#[derive(Clone)]
3255struct TraversalFileIndexEntry {
3256    handle: String,
3257    node: TraversalNode,
3258    tokens: BTreeSet<String>,
3259}
3260
3261#[derive(Clone)]
3262struct TraversalRouteIndexEntry {
3263    handle: String,
3264    node: TraversalNode,
3265    tokens: BTreeSet<String>,
3266}
3267
3268#[derive(Clone)]
3269struct TraversalAstSpanIndexEntry {
3270    handle: String,
3271    symbol_handle: String,
3272    file_handle: Option<String>,
3273    file: String,
3274    name: String,
3275    kind: String,
3276    language: String,
3277    node_kind: String,
3278    start_byte: usize,
3279    end_byte: usize,
3280    parent_module: Option<String>,
3281    markdown: Option<MarkdownSpanMetadata>,
3282}
3283
3284#[derive(Clone)]
3285struct TraversalMultiplicityIndexEntry {
3286    handle: String,
3287    node: TraversalNode,
3288    tokens: BTreeSet<String>,
3289}
3290
3291struct TraversalCodeLookup<'a> {
3292    symbols: &'a [TraversalSymbolIndexEntry],
3293    files: &'a [TraversalFileIndexEntry],
3294    routes: &'a [TraversalRouteIndexEntry],
3295    multiplicities: &'a [TraversalMultiplicityIndexEntry],
3296    symbol_index: HashMap<String, Vec<usize>>,
3297    file_index: HashMap<String, Vec<usize>>,
3298    route_index: HashMap<String, Vec<usize>>,
3299    multiplicity_index: HashMap<String, Vec<usize>>,
3300    file_path_index: HashMap<String, String>,
3301}
3302
3303#[derive(Clone, Debug, Serialize, PartialEq)]
3304struct ExplorationBudget {
3305    project_size: String,
3306    max_source_windows: usize,
3307    lines_per_window: usize,
3308    relationship_limit: usize,
3309}
3310
3311#[derive(Clone, Debug, Serialize, PartialEq)]
3312struct ExplorationRelation {
3313    from: String,
3314    relation: String,
3315    to: String,
3316    #[serde(skip_serializing_if = "Option::is_none")]
3317    label: Option<String>,
3318}
3319
3320#[derive(Clone, Debug, Serialize, PartialEq)]
3321struct ExplorationSourceWindow {
3322    handle: String,
3323    file: String,
3324    start: usize,
3325    end: usize,
3326    reason: String,
3327    expand: String,
3328}
3329
3330#[derive(Clone, Debug, Serialize, PartialEq)]
3331struct ExplorationWorkerContext {
3332    handle: String,
3333    target: String,
3334    summary: String,
3335    expand: String,
3336}
3337
3338#[derive(Clone, Debug, Serialize, PartialEq)]
3339struct ExplorationPacket {
3340    budget: ExplorationBudget,
3341    relationship_map: Vec<ExplorationRelation>,
3342    source_windows: Vec<ExplorationSourceWindow>,
3343    #[serde(skip_serializing_if = "Vec::is_empty", default)]
3344    worker_context: Vec<ExplorationWorkerContext>,
3345    no_reread_guidance: String,
3346}
3347
3348impl TraversalGraphBuild {
3349    fn add_node(&mut self, node: TraversalNode) {
3350        self.nodes.entry(node.handle.clone()).or_insert(node);
3351    }
3352
3353    fn add_edge(
3354        &mut self,
3355        from: &str,
3356        to: &str,
3357        relation: &str,
3358        label: Option<String>,
3359        weight: usize,
3360    ) {
3361        if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
3362            return;
3363        }
3364        let key = (from.to_string(), to.to_string(), relation.to_string());
3365        if self.edge_keys.insert(key) {
3366            self.edges.push(TraversalEdge {
3367                from: from.to_string(),
3368                to: to.to_string(),
3369                relation: relation.to_string(),
3370                label,
3371                weight,
3372            });
3373        }
3374    }
3375}
3376
3377pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
3378    match scope {
3379        Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
3380        None => root.join(".tsift/graph.db"),
3381    }
3382}
3383
3384fn graph_projection_meta_id(scope: Option<&str>) -> String {
3385    format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
3386}
3387
3388pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
3389    let bytes = serde_json::to_vec(value)?;
3390    Ok(blake3::hash(&bytes).to_hex().to_string())
3391}
3392
3393fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
3394    let mut hashable = node.clone();
3395    hashable.freshness = None;
3396    node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3397    Ok(node)
3398}
3399
3400fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
3401    let mut hashable = edge.clone();
3402    hashable.freshness = None;
3403    edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
3404    Ok(edge)
3405}
3406
3407const SEMANTIC_EMBEDDING_DIM: usize = 32;
3408const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
3409
3410fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
3411    match kind {
3412        SemanticRelatedKind::Concept => "concept",
3413        SemanticRelatedKind::Entity => "entity",
3414        SemanticRelatedKind::All => "all",
3415    }
3416}
3417
3418fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
3419    format!(
3420        "tsift semantic {} --path {} --kind {} --limit 10",
3421        shell_quote(query),
3422        shell_quote(root.to_string_lossy().as_ref()),
3423        semantic_related_kind_name(kind)
3424    )
3425}
3426
3427fn semantic_embedding(input: &str) -> Vec<f64> {
3428    let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
3429    let mut tokens = traversal_tokens(input);
3430    if tokens.is_empty() {
3431        let trimmed = input.trim().to_ascii_lowercase();
3432        if !trimmed.is_empty() {
3433            tokens.insert(trimmed);
3434        }
3435    }
3436
3437    for token in tokens {
3438        let hash = blake3::hash(token.as_bytes());
3439        let bytes = hash.as_bytes();
3440        let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
3441        let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
3442        vector[idx] += sign;
3443    }
3444
3445    let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
3446    if norm > 0.0 {
3447        for value in &mut vector {
3448            *value /= norm;
3449        }
3450    }
3451    vector
3452}
3453
3454fn semantic_embedding_property(input: &str) -> String {
3455    semantic_embedding(input)
3456        .iter()
3457        .map(|value| format!("{value:.6}"))
3458        .collect::<Vec<_>>()
3459        .join(",")
3460}
3461
3462fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
3463    let parsed = value
3464        .split(',')
3465        .map(str::trim)
3466        .map(str::parse::<f64>)
3467        .collect::<std::result::Result<Vec<_>, _>>()
3468        .ok()?;
3469    (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
3470}
3471
3472fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
3473    if left.len() != right.len() {
3474        return 0.0;
3475    }
3476    left.iter()
3477        .zip(right.iter())
3478        .map(|(left, right)| left * right)
3479        .sum::<f64>()
3480}
3481
3482fn semantic_entity_handle(name: &str, kind: &str) -> String {
3483    stable_handle(
3484        "gent",
3485        &format!(
3486            "entity:{}:{}",
3487            kind.trim().to_ascii_lowercase(),
3488            name.trim().to_ascii_lowercase()
3489        ),
3490    )
3491}
3492
3493fn semantic_concept_handle(label: &str) -> String {
3494    stable_handle(
3495        "gcon",
3496        &format!("concept:{}", label.trim().to_ascii_lowercase()),
3497    )
3498}
3499
3500fn summary_source_handles(
3501    summary: &summarize::Summary,
3502    file_node_by_path: &BTreeMap<String, String>,
3503    symbol_node_by_file_label: &BTreeMap<(String, String), String>,
3504) -> Vec<String> {
3505    let mut handles = Vec::new();
3506    if let Some(handle) = file_node_by_path.get(&summary.file_path) {
3507        handles.push(handle.clone());
3508    }
3509    if let Some(handle) =
3510        symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
3511        && !handles.iter().any(|existing| existing == handle)
3512    {
3513        handles.push(handle.clone());
3514    }
3515    handles
3516}
3517
3518fn semantic_entity_node(
3519    root: &Path,
3520    summary: &summarize::Summary,
3521    name: &str,
3522    kind: &str,
3523    description: &str,
3524    provenance: &GraphProvenance,
3525) -> SubstrateGraphNode {
3526    let handle = semantic_entity_handle(name, kind);
3527    let detail = if description.trim().is_empty() {
3528        format!("{kind} entity from cached summaries")
3529    } else {
3530        format!("{kind}: {description}")
3531    };
3532    SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
3533        .with_property("handle", handle)
3534        .with_property("ref_id", name.to_string())
3535        .with_property("detail", detail)
3536        .with_property("entity_kind", kind.to_string())
3537        .with_property("description", description.to_string())
3538        .with_property("source_file", summary.file_path.clone())
3539        .with_property("source_symbol", summary.symbol_name.clone())
3540        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3541        .with_property(
3542            "embedding",
3543            semantic_embedding_property(&format!("{name} {kind} {description}")),
3544        )
3545        .with_property(
3546            "expand",
3547            semantic_related_command(root, name, SemanticRelatedKind::Entity),
3548        )
3549        .with_provenance(provenance.clone())
3550}
3551
3552fn semantic_concept_node(
3553    root: &Path,
3554    summary: &summarize::Summary,
3555    label: &str,
3556    provenance: &GraphProvenance,
3557) -> SubstrateGraphNode {
3558    let handle = semantic_concept_handle(label);
3559    SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
3560        .with_property("handle", handle)
3561        .with_property("ref_id", label.to_string())
3562        .with_property("detail", "concept label from cached summaries".to_string())
3563        .with_property("source_file", summary.file_path.clone())
3564        .with_property("source_symbol", summary.symbol_name.clone())
3565        .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
3566        .with_property("embedding", semantic_embedding_property(label))
3567        .with_property(
3568            "expand",
3569            semantic_related_command(root, label, SemanticRelatedKind::Concept),
3570        )
3571        .with_provenance(provenance.clone())
3572}
3573
3574fn insert_semantic_edge(
3575    edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
3576    edge: SubstrateGraphEdge,
3577) {
3578    edge_map
3579        .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
3580        .or_insert(edge);
3581}
3582
3583fn append_summary_semantic_projection_rows(
3584    root: &Path,
3585    graph: &TraversalGraphBuild,
3586    provenance: &GraphProvenance,
3587    nodes: &mut Vec<SubstrateGraphNode>,
3588    edges: &mut Vec<SubstrateGraphEdge>,
3589) -> Result<()> {
3590    let summaries_db = root.join(".tsift/summaries.db");
3591    if !summaries_db.exists() {
3592        return Ok(());
3593    }
3594
3595    let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
3596    let summaries = summary_db.all()?;
3597    if summaries.is_empty() {
3598        return Ok(());
3599    }
3600
3601    let file_node_by_path = graph
3602        .nodes
3603        .values()
3604        .filter(|node| node.kind == "file")
3605        .filter_map(|node| {
3606            node.path
3607                .as_ref()
3608                .map(|path| (path.clone(), node.handle.clone()))
3609        })
3610        .collect::<BTreeMap<_, _>>();
3611    let symbol_node_by_file_label = graph
3612        .nodes
3613        .values()
3614        .filter(|node| node.kind == "symbol")
3615        .filter_map(|node| {
3616            Some((
3617                (node.path.clone()?, node.label.clone()),
3618                node.handle.clone(),
3619            ))
3620        })
3621        .collect::<BTreeMap<_, _>>();
3622
3623    let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
3624    let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
3625
3626    for summary in &summaries {
3627        let source_handles =
3628            summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
3629        let mut entity_ids_by_name = BTreeMap::<String, String>::new();
3630
3631        if let Some(entities) = &summary.entities {
3632            for entity in entities {
3633                let node = semantic_entity_node(
3634                    root,
3635                    summary,
3636                    &entity.name,
3637                    &entity.kind,
3638                    &entity.description,
3639                    provenance,
3640                );
3641                let entity_id = node.id.clone();
3642                entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
3643                semantic_nodes.entry(entity_id.clone()).or_insert(node);
3644
3645                for source_handle in &source_handles {
3646                    insert_semantic_edge(
3647                        &mut semantic_edges,
3648                        SubstrateGraphEdge::new(
3649                            source_handle.clone(),
3650                            entity_id.clone(),
3651                            "mentions_entity",
3652                        )
3653                        .with_property("label", format!("summary entity: {}", entity.name))
3654                        .with_property("source_file", summary.file_path.clone())
3655                        .with_provenance(provenance.clone()),
3656                    );
3657                }
3658            }
3659        }
3660
3661        let mut concept_ids = Vec::new();
3662        if let Some(labels) = &summary.concept_labels {
3663            for label in labels
3664                .iter()
3665                .map(|label| label.trim())
3666                .filter(|label| !label.is_empty())
3667            {
3668                let node = semantic_concept_node(root, summary, label, provenance);
3669                let concept_id = node.id.clone();
3670                semantic_nodes.entry(concept_id.clone()).or_insert(node);
3671                concept_ids.push(concept_id.clone());
3672
3673                for source_handle in &source_handles {
3674                    insert_semantic_edge(
3675                        &mut semantic_edges,
3676                        SubstrateGraphEdge::new(
3677                            source_handle.clone(),
3678                            concept_id.clone(),
3679                            "mentions_concept",
3680                        )
3681                        .with_property("label", format!("summary concept: {label}"))
3682                        .with_property("source_file", summary.file_path.clone())
3683                        .with_provenance(provenance.clone()),
3684                    );
3685                }
3686            }
3687        }
3688
3689        for entity_id in entity_ids_by_name.values() {
3690            for concept_id in &concept_ids {
3691                insert_semantic_edge(
3692                    &mut semantic_edges,
3693                    SubstrateGraphEdge::new(
3694                        entity_id.clone(),
3695                        concept_id.clone(),
3696                        "tagged_concept",
3697                    )
3698                    .with_property("label", "entity concept label".to_string())
3699                    .with_property("source_file", summary.file_path.clone())
3700                    .with_provenance(provenance.clone()),
3701                );
3702            }
3703        }
3704
3705        for idx in 0..concept_ids.len() {
3706            for next_idx in (idx + 1)..concept_ids.len() {
3707                insert_semantic_edge(
3708                    &mut semantic_edges,
3709                    SubstrateGraphEdge::new(
3710                        concept_ids[idx].clone(),
3711                        concept_ids[next_idx].clone(),
3712                        "related_concept",
3713                    )
3714                    .with_property("label", format!("co-occurs in {}", summary.symbol_name))
3715                    .with_property("source_file", summary.file_path.clone())
3716                    .with_provenance(provenance.clone()),
3717                );
3718            }
3719        }
3720
3721        if let Some(relationships) = &summary.relationships {
3722            for relationship in relationships {
3723                let from_id = entity_ids_by_name
3724                    .get(&relationship.from.to_ascii_lowercase())
3725                    .cloned()
3726                    .unwrap_or_else(|| {
3727                        let node = semantic_entity_node(
3728                            root,
3729                            summary,
3730                            &relationship.from,
3731                            "unknown",
3732                            "",
3733                            provenance,
3734                        );
3735                        let id = node.id.clone();
3736                        semantic_nodes.entry(id.clone()).or_insert(node);
3737                        id
3738                    });
3739                let to_id = entity_ids_by_name
3740                    .get(&relationship.to.to_ascii_lowercase())
3741                    .cloned()
3742                    .unwrap_or_else(|| {
3743                        let node = semantic_entity_node(
3744                            root,
3745                            summary,
3746                            &relationship.to,
3747                            "unknown",
3748                            "",
3749                            provenance,
3750                        );
3751                        let id = node.id.clone();
3752                        semantic_nodes.entry(id.clone()).or_insert(node);
3753                        id
3754                    });
3755                insert_semantic_edge(
3756                    &mut semantic_edges,
3757                    SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
3758                        .with_property("relationship_kind", relationship.kind.clone())
3759                        .with_property("label", relationship.kind.clone())
3760                        .with_property("source_file", summary.file_path.clone())
3761                        .with_property("source_symbol", summary.symbol_name.clone())
3762                        .with_provenance(provenance.clone()),
3763                );
3764            }
3765        }
3766    }
3767
3768    for node in semantic_nodes.into_values() {
3769        nodes.push(node_with_content_freshness(node)?);
3770    }
3771    for edge in semantic_edges.into_values() {
3772        edges.push(edge_with_content_freshness(edge)?);
3773    }
3774
3775    Ok(())
3776}
3777
3778fn projection_content_hash(
3779    nodes: &[SubstrateGraphNode],
3780    edges: &[SubstrateGraphEdge],
3781) -> Result<String> {
3782    #[derive(Serialize)]
3783    struct Payload<'a> {
3784        version: &'static str,
3785        nodes: &'a [SubstrateGraphNode],
3786        edges: &'a [SubstrateGraphEdge],
3787    }
3788
3789    content_hash(&Payload {
3790        version: GRAPH_PROJECTION_VERSION,
3791        nodes,
3792        edges,
3793    })
3794}
3795
3796pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
3797    projection
3798        .nodes
3799        .iter()
3800        .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
3801        .and_then(|node| node.properties.get("content_hash").cloned())
3802}
3803
3804fn traversal_projection_from_graph(
3805    root: &Path,
3806    scope: Option<&str>,
3807    graph: &TraversalGraphBuild,
3808) -> Result<GraphProjection> {
3809    let provenance = GraphProvenance::new(
3810        "tsift.traverse",
3811        format!("{}:{}", root.display(), scope.unwrap_or("root")),
3812    );
3813    let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
3814    for node in graph.nodes.values() {
3815        let mut projected =
3816            SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
3817                .with_property("handle", node.handle.clone())
3818                .with_property("expand", node.expand.clone())
3819                .with_provenance(provenance.clone());
3820        if let Some(ref_id) = &node.ref_id {
3821            projected = projected.with_property("ref_id", ref_id.clone());
3822        }
3823        if let Some(path) = &node.path {
3824            projected = projected.with_property("path", path.clone());
3825        }
3826        if let Some(line) = node.line {
3827            projected = projected.with_property("line", line.to_string());
3828        }
3829        if let Some(detail) = &node.detail {
3830            projected = projected.with_property("detail", detail.clone());
3831        }
3832        for (key, value) in &node.properties {
3833            projected = projected.with_property(key.clone(), value.clone());
3834        }
3835        nodes.push(node_with_content_freshness(projected)?);
3836    }
3837
3838    let mut edges = Vec::with_capacity(graph.edges.len());
3839    for edge in &graph.edges {
3840        let mut projected =
3841            SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
3842                .with_property("weight", edge.weight.to_string())
3843                .with_provenance(provenance.clone());
3844        if let Some(label) = &edge.label {
3845            projected = projected.with_property("label", label.clone());
3846        }
3847        edges.push(edge_with_content_freshness(projected)?);
3848    }
3849
3850    append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3851    append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
3852    append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
3853
3854    let projection_hash = projection_content_hash(&nodes, &edges)?;
3855    let meta = SubstrateGraphNode::new(
3856        graph_projection_meta_id(scope),
3857        GRAPH_PROJECTION_META_KIND,
3858        "tsift traversal projection",
3859    )
3860    .with_property("projection_version", GRAPH_PROJECTION_VERSION)
3861    .with_property("content_hash", projection_hash.clone())
3862    .with_property("root", root.to_string_lossy().to_string())
3863    .with_property("scope", scope.unwrap_or("root"))
3864    .with_property("node_count", graph.nodes.len().to_string())
3865    .with_property("edge_count", graph.edges.len().to_string())
3866    .with_provenance(provenance)
3867    .with_freshness(GraphFreshness::content_hash(projection_hash));
3868    nodes.push(meta);
3869
3870    Ok(GraphProjection { nodes, edges })
3871}
3872
3873#[allow(clippy::too_many_arguments)]
3874fn ensure_traversal_source_handle(
3875    root: &Path,
3876    provenance: &GraphProvenance,
3877    file_node_by_path: &BTreeMap<String, String>,
3878    node: &TraversalNode,
3879    budget: &ExplorationBudget,
3880    source_handle_by_node: &mut BTreeMap<String, String>,
3881    seen_windows: &mut BTreeMap<(String, usize, usize), String>,
3882    nodes: &mut Vec<SubstrateGraphNode>,
3883    edges: &mut Vec<SubstrateGraphEdge>,
3884) -> Result<Option<String>> {
3885    if let Some(handle) = source_handle_by_node.get(&node.handle) {
3886        return Ok(Some(handle.clone()));
3887    }
3888    let Some(window) = exploration_source_window_for_node(root, node, budget) else {
3889        return Ok(None);
3890    };
3891    let window_key = (window.file.clone(), window.start, window.end);
3892    let handle = if let Some(handle) = seen_windows.get(&window_key) {
3893        handle.clone()
3894    } else {
3895        let label = format!("{}:{}-{}", window.file, window.start, window.end);
3896        let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
3897            .with_property("handle", window.handle.clone())
3898            .with_property("file", window.file.clone())
3899            .with_property("start", window.start.to_string())
3900            .with_property("end", window.end.to_string())
3901            .with_property("reason", window.reason.clone())
3902            .with_property("expand", window.expand.clone())
3903            .with_provenance(provenance.clone());
3904        nodes.push(node_with_content_freshness(projected)?);
3905
3906        if let Some(file_handle) = file_node_by_path.get(&window.file) {
3907            let edge = SubstrateGraphEdge::new(
3908                window.handle.clone(),
3909                file_handle.clone(),
3910                "expands_source",
3911            )
3912            .with_property("label", window.reason.clone())
3913            .with_provenance(provenance.clone());
3914            edges.push(edge_with_content_freshness(edge)?);
3915        }
3916        if node.kind != "file" {
3917            let edge = SubstrateGraphEdge::new(
3918                window.handle.clone(),
3919                node.handle.clone(),
3920                "anchors_source",
3921            )
3922            .with_property("label", window.reason.clone())
3923            .with_provenance(provenance.clone());
3924            edges.push(edge_with_content_freshness(edge)?);
3925        }
3926        seen_windows.insert(window_key, window.handle.clone());
3927        window.handle
3928    };
3929    source_handle_by_node.insert(node.handle.clone(), handle.clone());
3930    Ok(Some(handle))
3931}
3932
3933fn push_traversal_backlog_target_handles<'a>(
3934    backlog: &TraversalNode,
3935    edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
3936    node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
3937    max_handles: usize,
3938    seen_target_nodes: &mut BTreeSet<String>,
3939    target_node_handles: &mut Vec<String>,
3940) {
3941    for edge in edges_by_from
3942        .get(backlog.handle.as_str())
3943        .into_iter()
3944        .flatten()
3945        .filter(|edge| edge.relation == "mentions")
3946    {
3947        let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
3948            continue;
3949        };
3950        if !matches!(
3951            target_node.kind.as_str(),
3952            "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
3953        ) {
3954            continue;
3955        }
3956        if target_node
3957            .path
3958            .as_deref()
3959            .zip(backlog.path.as_deref())
3960            .is_some_and(|(target_path, backlog_path)| {
3961                target_path == backlog_path && target_path.ends_with(".md")
3962            })
3963        {
3964            continue;
3965        }
3966        if seen_target_nodes.insert(target_node.handle.clone()) {
3967            target_node_handles.push(target_node.handle.clone());
3968        }
3969        if target_node_handles.len() >= max_handles {
3970            break;
3971        }
3972    }
3973}
3974
3975fn append_traversal_context_projection_rows(
3976    root: &Path,
3977    graph: &TraversalGraphBuild,
3978    provenance: &GraphProvenance,
3979    nodes: &mut Vec<SubstrateGraphNode>,
3980    edges: &mut Vec<SubstrateGraphEdge>,
3981) -> Result<()> {
3982    let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
3983    let file_node_by_path = graph
3984        .nodes
3985        .values()
3986        .filter(|node| node.kind == "file")
3987        .filter_map(|node| {
3988            node.path
3989                .as_ref()
3990                .map(|path| (path.clone(), node.handle.clone()))
3991        })
3992        .collect::<BTreeMap<_, _>>();
3993
3994    let node_by_handle = graph
3995        .nodes
3996        .values()
3997        .map(|node| (node.handle.as_str(), node))
3998        .collect::<BTreeMap<_, _>>();
3999    let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4000    for edge in &graph.edges {
4001        edges_by_from
4002            .entry(edge.from.as_str())
4003            .or_default()
4004            .push(edge);
4005    }
4006    for rows in edges_by_from.values_mut() {
4007        rows.sort_by(|left, right| {
4008            right
4009                .weight
4010                .cmp(&left.weight)
4011                .then(left.relation.cmp(&right.relation))
4012                .then(left.to.cmp(&right.to))
4013        });
4014    }
4015
4016    let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4017    let mut source_handle_by_node = BTreeMap::<String, String>::new();
4018
4019    let mut code_context_count = 0usize;
4020    let code_context_limit = budget.relationship_limit.min(8);
4021    for node in graph.nodes.values() {
4022        if !matches!(
4023            node.kind.as_str(),
4024            "backlog" | "job_packet" | "worker_result"
4025        ) {
4026            continue;
4027        }
4028        let mut target_node_handles = Vec::new();
4029        let mut fallback_target_handles = Vec::new();
4030        let mut seen_target_nodes = BTreeSet::new();
4031        if node.kind == "backlog" || node.kind == "worker_result" {
4032            push_traversal_backlog_target_handles(
4033                node,
4034                &edges_by_from,
4035                &node_by_handle,
4036                budget.max_source_windows,
4037                &mut seen_target_nodes,
4038                &mut target_node_handles,
4039            );
4040            fallback_target_handles.push(node.handle.clone());
4041        } else {
4042            for edge in edges_by_from
4043                .get(node.handle.as_str())
4044                .into_iter()
4045                .flatten()
4046                .filter(|edge| edge.relation == "targets")
4047            {
4048                let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4049                    continue;
4050                };
4051                fallback_target_handles.push(backlog.handle.clone());
4052                push_traversal_backlog_target_handles(
4053                    backlog,
4054                    &edges_by_from,
4055                    &node_by_handle,
4056                    budget.max_source_windows,
4057                    &mut seen_target_nodes,
4058                    &mut target_node_handles,
4059                );
4060                if target_node_handles.len() >= budget.max_source_windows {
4061                    break;
4062                }
4063            }
4064            if fallback_target_handles.is_empty() {
4065                continue;
4066            }
4067        }
4068        let code_context = !target_node_handles.is_empty();
4069        if target_node_handles.is_empty() {
4070            target_node_handles = dedupe_preserve_order(fallback_target_handles);
4071        } else if code_context_count >= code_context_limit {
4072            continue;
4073        }
4074
4075        let mut worker_source_handles = Vec::new();
4076        let mut seen_worker_handles = BTreeSet::new();
4077        for target_handle in target_node_handles {
4078            if worker_source_handles.len() >= budget.max_source_windows {
4079                break;
4080            }
4081            let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4082                continue;
4083            };
4084            let Some(handle) = ensure_traversal_source_handle(
4085                root,
4086                provenance,
4087                &file_node_by_path,
4088                target_node,
4089                &budget,
4090                &mut source_handle_by_node,
4091                &mut seen_windows,
4092                nodes,
4093                edges,
4094            )?
4095            else {
4096                continue;
4097            };
4098            if seen_worker_handles.insert(handle.clone()) {
4099                worker_source_handles.push(handle);
4100            }
4101        }
4102        if worker_source_handles.is_empty() {
4103            continue;
4104        }
4105        let target = node
4106            .path
4107            .clone()
4108            .unwrap_or_else(|| root.to_string_lossy().to_string());
4109        let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4110        let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4111        let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4112            .with_property("handle", handle.clone())
4113            .with_property("target", target.clone())
4114            .with_property("summary", summary)
4115            .with_property(
4116                "source_handle_count",
4117                worker_source_handles.len().to_string(),
4118            )
4119            .with_property(
4120                "expand",
4121                format!(
4122                    "tsift --envelope context-pack {} --budget normal",
4123                    shell_quote(&target)
4124                ),
4125            )
4126            .with_provenance(provenance.clone());
4127        nodes.push(node_with_content_freshness(projected)?);
4128
4129        let request_edge =
4130            SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4131                .with_property("label", "bounded worker context".to_string())
4132                .with_provenance(provenance.clone());
4133        edges.push(edge_with_content_freshness(request_edge)?);
4134
4135        for source_handle in &worker_source_handles {
4136            let scope_edge =
4137                SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4138                    .with_property("label", "bounded worker source window".to_string())
4139                    .with_provenance(provenance.clone());
4140            edges.push(edge_with_content_freshness(scope_edge)?);
4141        }
4142        if code_context {
4143            code_context_count += 1;
4144        }
4145    }
4146
4147    Ok(())
4148}
4149
4150fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4151    let handle = node
4152        .properties
4153        .get("handle")
4154        .cloned()
4155        .unwrap_or_else(|| node.id.clone());
4156    TraversalNode {
4157        expand: node
4158            .properties
4159            .get("expand")
4160            .cloned()
4161            .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4162        handle,
4163        kind: node.kind,
4164        label: node.label,
4165        ref_id: node.properties.get("ref_id").cloned(),
4166        path: node.properties.get("path").cloned(),
4167        line: node
4168            .properties
4169            .get("line")
4170            .and_then(|value| value.parse::<i64>().ok()),
4171        detail: node.properties.get("detail").cloned(),
4172        properties: node.properties,
4173    }
4174}
4175
4176fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4177    let mut graph = TraversalGraphBuild::default();
4178    for node in store.all_nodes()? {
4179        if node.kind == GRAPH_PROJECTION_META_KIND {
4180            continue;
4181        }
4182        graph.add_node(traversal_node_from_graph_node(root, node));
4183    }
4184    for edge in store.all_edges()? {
4185        graph.add_edge(
4186            &edge.from_id,
4187            &edge.to_id,
4188            &edge.kind,
4189            edge.properties.get("label").cloned(),
4190            edge.properties
4191                .get("weight")
4192                .and_then(|value| value.parse::<usize>().ok())
4193                .unwrap_or(1),
4194        );
4195    }
4196    Ok(graph)
4197}
4198
4199pub(crate) fn convex_rows_from_graph_store(
4200    store: &impl GraphStore,
4201) -> Result<ConvexProjectionRows> {
4202    Ok(GraphProjection {
4203        nodes: store.all_nodes()?,
4204        edges: store.all_edges()?,
4205    }
4206    .to_convex_rows())
4207}
4208
4209#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4210struct ConvexRequiredIndex {
4211    table: String,
4212    name: String,
4213    fields: Vec<String>,
4214}
4215
4216#[derive(Clone, Debug, Serialize, PartialEq)]
4217struct ConvexSyncChunk {
4218    operation: String,
4219    chunk: usize,
4220    count: usize,
4221    keys: Vec<String>,
4222    max_attempts: usize,
4223    retry_policy: String,
4224}
4225
4226#[derive(Clone, Debug, Serialize, PartialEq)]
4227struct ConvexTransportSummary {
4228    endpoint_env: String,
4229    endpoint_configured: bool,
4230    auth_token_env: String,
4231    auth_configured: bool,
4232    remote_snapshot: bool,
4233    applied_chunks: usize,
4234}
4235
4236#[derive(Clone, Debug, Serialize, PartialEq)]
4237struct ConvexTransportReceipt {
4238    operation: String,
4239    chunk: usize,
4240    attempt: usize,
4241    status: String,
4242    message: Option<String>,
4243}
4244
4245#[derive(Serialize)]
4246#[serde(rename_all = "camelCase")]
4247struct ConvexTransportRequest<'a> {
4248    operation: &'a str,
4249    chunk: usize,
4250    projection_version: &'a str,
4251    projection_hash: Option<&'a str>,
4252    #[serde(skip_serializing_if = "Option::is_none")]
4253    projection_meta_id: Option<&'a str>,
4254    node_rows: Vec<ConvexNodeRow>,
4255    edge_rows: Vec<ConvexEdgeRow>,
4256    keys: Vec<String>,
4257    #[serde(skip_serializing_if = "Option::is_none")]
4258    cursor: Option<String>,
4259    #[serde(skip_serializing_if = "Option::is_none")]
4260    limit: Option<usize>,
4261}
4262
4263#[derive(Deserialize)]
4264#[serde(rename_all = "camelCase")]
4265struct ConvexTransportResponse {
4266    status: Option<String>,
4267    message: Option<String>,
4268    rows: Option<ConvexProjectionRows>,
4269    #[serde(default)]
4270    meta: Option<ConvexSnapshotMeta>,
4271    #[serde(default)]
4272    page: Option<ConvexSnapshotPage>,
4273}
4274
4275#[derive(Deserialize, Debug, Clone)]
4276#[serde(rename_all = "camelCase")]
4277struct ConvexSnapshotMeta {
4278    // Captured for completeness/debugging; not currently consumed by the
4279    // freshness diff (indexes are already validated against the required set
4280    // via `convex_required_indexes`, and `page_size` is informational only).
4281    #[serde(default)]
4282    #[allow(dead_code)]
4283    indexes: Vec<ConvexRequiredIndex>,
4284    #[serde(default)]
4285    #[allow(dead_code)]
4286    node_count: Option<usize>,
4287    #[serde(default)]
4288    #[allow(dead_code)]
4289    edge_count: Option<usize>,
4290    #[serde(default)]
4291    projection_hash: Option<String>,
4292    #[serde(default)]
4293    #[allow(dead_code)]
4294    page_size: Option<usize>,
4295}
4296
4297/// Paginated snapshot page response. `rows` is either node rows or edge rows
4298/// depending on which operation was called; we deserialize as raw values to
4299/// keep the transport struct shared between both shapes, then narrow per call
4300/// site.
4301#[derive(Deserialize, Debug, Clone)]
4302#[serde(rename_all = "camelCase")]
4303struct ConvexSnapshotPage {
4304    rows: Vec<serde_json::Value>,
4305    #[serde(default)]
4306    next_cursor: Option<String>,
4307}
4308
4309#[derive(Clone, Debug, Serialize, PartialEq)]
4310struct ConvexProjectionFreshness {
4311    status: String,
4312    fail_closed: bool,
4313    local_hash: Option<String>,
4314    snapshot_hash: Option<String>,
4315    missing_nodes: Vec<String>,
4316    stale_nodes: Vec<String>,
4317    missing_edges: Vec<String>,
4318    stale_edges: Vec<String>,
4319    diagnostics: Vec<String>,
4320}
4321
4322const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
4323
4324impl ConvexProjectionFreshness {
4325    fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
4326        Self {
4327            status: "current".to_string(),
4328            fail_closed: false,
4329            local_hash,
4330            snapshot_hash,
4331            missing_nodes: Vec::new(),
4332            stale_nodes: Vec::new(),
4333            missing_edges: Vec::new(),
4334            stale_edges: Vec::new(),
4335            diagnostics: Vec::new(),
4336        }
4337    }
4338}
4339
4340#[derive(Clone, Debug, Serialize, PartialEq)]
4341struct ConvexSyncReport {
4342    root: String,
4343    #[serde(skip_serializing_if = "Option::is_none")]
4344    scope: Option<String>,
4345    graph_db: String,
4346    dry_run: bool,
4347    projection_version: String,
4348    projection_hash: Option<String>,
4349    required_indexes: Vec<ConvexRequiredIndex>,
4350    node_upserts: Vec<ConvexNodeRow>,
4351    edge_upserts: Vec<ConvexEdgeRow>,
4352    node_tombstones: Vec<String>,
4353    edge_tombstones: Vec<String>,
4354    chunks: Vec<ConvexSyncChunk>,
4355    freshness: ConvexProjectionFreshness,
4356    transport: Option<ConvexTransportSummary>,
4357    receipts: Vec<ConvexTransportReceipt>,
4358    diagnostics: Vec<String>,
4359    warnings: Vec<String>,
4360}
4361
4362fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
4363    vec![
4364        ConvexRequiredIndex {
4365            table: "nodes".to_string(),
4366            name: "by_external_id".to_string(),
4367            fields: vec!["externalId".to_string()],
4368        },
4369        ConvexRequiredIndex {
4370            table: "nodes".to_string(),
4371            name: "by_kind".to_string(),
4372            fields: vec!["kind".to_string()],
4373        },
4374        ConvexRequiredIndex {
4375            table: "edges".to_string(),
4376            name: "by_edge_key".to_string(),
4377            fields: vec!["edgeKey".to_string()],
4378        },
4379        ConvexRequiredIndex {
4380            table: "edges".to_string(),
4381            name: "by_from_kind".to_string(),
4382            fields: vec!["fromExternalId".to_string(), "kind".to_string()],
4383        },
4384        ConvexRequiredIndex {
4385            table: "edges".to_string(),
4386            name: "by_to_kind".to_string(),
4387            fields: vec!["toExternalId".to_string(), "kind".to_string()],
4388        },
4389    ]
4390}
4391
4392pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
4393    let content = fs::read_to_string(path)
4394        .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
4395    serde_json::from_str(&content)
4396        .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
4397}
4398
4399fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
4400    let mut diagnostics = Vec::new();
4401    let mut node_counts = BTreeMap::<&str, usize>::new();
4402    for row in &rows.nodes {
4403        *node_counts.entry(row.external_id.as_str()).or_default() += 1;
4404    }
4405    for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
4406        diagnostics.push(format!(
4407            "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
4408        ));
4409    }
4410
4411    let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
4412    let mut edge_counts = BTreeMap::<&str, usize>::new();
4413    for edge in &rows.edges {
4414        *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
4415        if !node_ids.contains(edge.from_external_id.as_str()) {
4416            diagnostics.push(format!(
4417                "Convex snapshot edge {} references missing from node {}",
4418                edge.edge_key, edge.from_external_id
4419            ));
4420        }
4421        if !node_ids.contains(edge.to_external_id.as_str()) {
4422            diagnostics.push(format!(
4423                "Convex snapshot edge {} references missing to node {}",
4424                edge.edge_key, edge.to_external_id
4425            ));
4426        }
4427        let expected_key =
4428            ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
4429        if edge.edge_key != expected_key {
4430            diagnostics.push(format!(
4431                "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
4432                edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
4433            ));
4434        }
4435    }
4436    for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
4437        diagnostics.push(format!(
4438            "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
4439        ));
4440    }
4441    diagnostics
4442}
4443
4444pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
4445    let diagnostics = convex_projection_row_diagnostics(rows);
4446    if diagnostics.is_empty() {
4447        Ok(())
4448    } else {
4449        bail!("{}", diagnostics.join("; "))
4450    }
4451}
4452
4453pub(crate) struct ConvexHttpTransport {
4454    endpoint: String,
4455    auth_token_env: String,
4456    auth_token: Option<String>,
4457}
4458
4459impl ConvexHttpTransport {
4460    fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
4461        let endpoint = endpoint
4462            .map(str::to_string)
4463            .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
4464            .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
4465        let auth_token = env::var(auth_token_env)
4466            .ok()
4467            .filter(|value| !value.trim().is_empty());
4468        Ok(Self {
4469            endpoint,
4470            auth_token_env: auth_token_env.to_string(),
4471            auth_token,
4472        })
4473    }
4474
4475    fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
4476        ConvexTransportSummary {
4477            endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
4478            endpoint_configured: true,
4479            auth_token_env: self.auth_token_env.clone(),
4480            auth_configured: self.auth_token.is_some(),
4481            remote_snapshot,
4482            applied_chunks,
4483        }
4484    }
4485
4486    fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
4487        let mut builder = ureq::post(&self.endpoint);
4488        if let Some(token) = &self.auth_token {
4489            builder = builder.header("Authorization", &format!("Bearer {token}"));
4490        }
4491        builder
4492            .send_json(request)
4493            .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
4494            .body_mut()
4495            .read_json::<ConvexTransportResponse>()
4496            .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
4497    }
4498
4499    /// Fetch a full snapshot of the Convex graph backend.
4500    ///
4501    /// Uses the paginated `snapshot_meta` + `snapshot_nodes_page` +
4502    /// `snapshot_edges_page` triplet so the call works on tables larger than
4503    /// ~5k rows (the single-shot `snapshot` query hits Convex's 15s per-request
4504    /// syscall budget at that scale; see `#convexsnapshotscale`).
4505    ///
4506    /// Falls back to the legacy single-shot `snapshot` operation if the
4507    /// backend doesn't recognize `snapshot_meta` (older deployments that
4508    /// haven't redeployed the new schema).
4509    fn fetch_snapshot(
4510        &self,
4511        projection_version: &str,
4512        scope: Option<&str>,
4513        local_hash: Option<&str>,
4514        local_rows: Option<&ConvexProjectionRows>,
4515    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4516        match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
4517            Ok(rows) => Ok(rows),
4518            Err(err) => {
4519                // Only fall through to the legacy path if the failure looks
4520                // like "operation unknown" (older backend). Any other failure
4521                // (HTTP timeout, deserialization mismatch) should surface so
4522                // the operator sees the real cause.
4523                let msg = format!("{err:#}");
4524                let is_unknown_op = msg.contains("unknown operation")
4525                    || msg.contains("snapshot_meta")
4526                    || msg.contains("404");
4527                if !is_unknown_op {
4528                    return Err(err);
4529                }
4530                self.fetch_snapshot_legacy(projection_version)
4531                    .map(|rows| (rows, Vec::new()))
4532            }
4533        }
4534    }
4535
4536    fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
4537        let response = self.post(&ConvexTransportRequest {
4538            operation: "snapshot",
4539            chunk: 0,
4540            projection_version,
4541            projection_hash: None,
4542            projection_meta_id: None,
4543            node_rows: Vec::new(),
4544            edge_rows: Vec::new(),
4545            keys: Vec::new(),
4546            cursor: None,
4547            limit: None,
4548        })?;
4549        response
4550            .rows
4551            .context("Convex snapshot response did not include rows")
4552    }
4553
4554    fn fetch_snapshot_paginated(
4555        &self,
4556        projection_version: &str,
4557        scope: Option<&str>,
4558        local_hash: Option<&str>,
4559        local_rows: Option<&ConvexProjectionRows>,
4560    ) -> Result<(ConvexProjectionRows, Vec<String>)> {
4561        let projection_meta_id = graph_projection_meta_id(scope);
4562        let meta_response = self.post(&ConvexTransportRequest {
4563            operation: "snapshot_meta",
4564            chunk: 0,
4565            projection_version,
4566            projection_hash: None,
4567            projection_meta_id: Some(&projection_meta_id),
4568            node_rows: Vec::new(),
4569            edge_rows: Vec::new(),
4570            keys: Vec::new(),
4571            cursor: None,
4572            limit: None,
4573        })?;
4574        if matches!(meta_response.status.as_deref(), Some("error")) {
4575            anyhow::bail!(
4576                "Convex snapshot_meta returned error: {}",
4577                meta_response.message.unwrap_or_default()
4578            );
4579        }
4580        let meta = meta_response
4581            .meta
4582            .context("Convex snapshot_meta response did not include meta")?;
4583        if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
4584            (meta.projection_hash.as_deref(), local_hash, local_rows)
4585            && remote_hash == local_hash
4586        {
4587            return Ok((
4588                local_rows.clone(),
4589                vec![
4590                    "remote projection hash matched local graph; skipped full row-page snapshot diff"
4591                        .to_string(),
4592                ],
4593            ));
4594        }
4595
4596        let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
4597        let mut node_cursor: Option<String> = None;
4598        loop {
4599            let response = self.post(&ConvexTransportRequest {
4600                operation: "snapshot_nodes_page",
4601                chunk: 0,
4602                projection_version,
4603                projection_hash: None,
4604                projection_meta_id: None,
4605                node_rows: Vec::new(),
4606                edge_rows: Vec::new(),
4607                keys: Vec::new(),
4608                cursor: node_cursor.clone(),
4609                limit: None,
4610            })?;
4611            let page = response
4612                .page
4613                .context("Convex snapshot_nodes_page response did not include page")?;
4614            for raw in page.rows {
4615                let row: ConvexNodeRow =
4616                    serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
4617                nodes.push(row);
4618            }
4619            match page.next_cursor {
4620                Some(next) => node_cursor = Some(next),
4621                None => break,
4622            }
4623        }
4624
4625        let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
4626        let mut edge_cursor: Option<String> = None;
4627        loop {
4628            let response = self.post(&ConvexTransportRequest {
4629                operation: "snapshot_edges_page",
4630                chunk: 0,
4631                projection_version,
4632                projection_hash: None,
4633                projection_meta_id: None,
4634                node_rows: Vec::new(),
4635                edge_rows: Vec::new(),
4636                keys: Vec::new(),
4637                cursor: edge_cursor.clone(),
4638                limit: None,
4639            })?;
4640            let page = response
4641                .page
4642                .context("Convex snapshot_edges_page response did not include page")?;
4643            for raw in page.rows {
4644                let row: ConvexEdgeRow =
4645                    serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
4646                edges.push(row);
4647            }
4648            match page.next_cursor {
4649                Some(next) => edge_cursor = Some(next),
4650                None => break,
4651            }
4652        }
4653
4654        Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
4655    }
4656
4657    fn apply_chunk(
4658        &self,
4659        report: &ConvexSyncReport,
4660        chunk: &ConvexSyncChunk,
4661    ) -> Result<ConvexTransportReceipt> {
4662        let node_rows = if chunk.operation == "upsert_nodes" {
4663            report
4664                .node_upserts
4665                .iter()
4666                .filter(|row| chunk.keys.contains(&row.external_id))
4667                .cloned()
4668                .collect()
4669        } else {
4670            Vec::new()
4671        };
4672        let edge_rows = if chunk.operation == "upsert_edges" {
4673            report
4674                .edge_upserts
4675                .iter()
4676                .filter(|row| chunk.keys.contains(&row.edge_key))
4677                .cloned()
4678                .collect()
4679        } else {
4680            Vec::new()
4681        };
4682        let request = ConvexTransportRequest {
4683            operation: &chunk.operation,
4684            chunk: chunk.chunk,
4685            projection_version: &report.projection_version,
4686            projection_hash: report.projection_hash.as_deref(),
4687            projection_meta_id: None,
4688            node_rows,
4689            edge_rows,
4690            keys: chunk.keys.clone(),
4691            cursor: None,
4692            limit: None,
4693        };
4694        let mut last_error = None;
4695        for attempt in 1..=chunk.max_attempts {
4696            match self.post(&request) {
4697                Ok(response) => {
4698                    return Ok(ConvexTransportReceipt {
4699                        operation: chunk.operation.clone(),
4700                        chunk: chunk.chunk,
4701                        attempt,
4702                        status: response.status.unwrap_or_else(|| "ok".to_string()),
4703                        message: response.message,
4704                    });
4705                }
4706                Err(err) => {
4707                    last_error = Some(err);
4708                    if attempt < chunk.max_attempts {
4709                        std::thread::sleep(Duration::from_millis(100 * attempt as u64));
4710                    }
4711                }
4712            }
4713        }
4714        Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
4715            .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
4716    }
4717}
4718
4719fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
4720    let meta_id = graph_projection_meta_id(scope);
4721    rows.nodes
4722        .iter()
4723        .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
4724        .and_then(|row| row.properties.get("content_hash").cloned())
4725}
4726
4727fn convex_projection_freshness(
4728    local: &ConvexProjectionRows,
4729    snapshot: Option<&ConvexProjectionRows>,
4730    scope: Option<&str>,
4731) -> ConvexProjectionFreshness {
4732    let local_hash = convex_projection_hash(local, scope);
4733    let Some(snapshot) = snapshot else {
4734        return ConvexProjectionFreshness {
4735            status: "unchecked".to_string(),
4736            fail_closed: false,
4737            local_hash,
4738            snapshot_hash: None,
4739            missing_nodes: Vec::new(),
4740            stale_nodes: Vec::new(),
4741            missing_edges: Vec::new(),
4742            stale_edges: Vec::new(),
4743            diagnostics: vec![
4744                "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
4745            ],
4746        };
4747    };
4748
4749    let snapshot_hash = convex_projection_hash(snapshot, scope);
4750    let snapshot_nodes = snapshot
4751        .nodes
4752        .iter()
4753        .map(|row| (row.external_id.as_str(), row))
4754        .collect::<BTreeMap<_, _>>();
4755    let snapshot_edges = snapshot
4756        .edges
4757        .iter()
4758        .map(|row| (row.edge_key.as_str(), row))
4759        .collect::<BTreeMap<_, _>>();
4760
4761    let mut missing_nodes = Vec::new();
4762    let mut stale_nodes = Vec::new();
4763    for row in &local.nodes {
4764        match snapshot_nodes.get(row.external_id.as_str()) {
4765            Some(snapshot_row) if *snapshot_row == row => {}
4766            Some(_) => stale_nodes.push(row.external_id.clone()),
4767            None => missing_nodes.push(row.external_id.clone()),
4768        }
4769    }
4770
4771    let mut missing_edges = Vec::new();
4772    let mut stale_edges = Vec::new();
4773    for row in &local.edges {
4774        match snapshot_edges.get(row.edge_key.as_str()) {
4775            Some(snapshot_row) if *snapshot_row == row => {}
4776            Some(_) => stale_edges.push(row.edge_key.clone()),
4777            None => missing_edges.push(row.edge_key.clone()),
4778        }
4779    }
4780
4781    let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
4782    let rows_current = missing_nodes.is_empty()
4783        && stale_nodes.is_empty()
4784        && missing_edges.is_empty()
4785        && stale_edges.is_empty();
4786    if hash_current && rows_current {
4787        return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
4788    }
4789
4790    let mut diagnostics = Vec::new();
4791    if local_hash != snapshot_hash {
4792        diagnostics.push(format!(
4793            "projection hash mismatch: local={} snapshot={}",
4794            local_hash.as_deref().unwrap_or("missing"),
4795            snapshot_hash.as_deref().unwrap_or("missing")
4796        ));
4797    }
4798    if !missing_nodes.is_empty() || !missing_edges.is_empty() {
4799        diagnostics.push(format!(
4800            "Convex snapshot is missing {} node(s) and {} edge(s)",
4801            missing_nodes.len(),
4802            missing_edges.len()
4803        ));
4804    }
4805    if !stale_nodes.is_empty() || !stale_edges.is_empty() {
4806        diagnostics.push(format!(
4807            "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
4808            stale_nodes.len(),
4809            stale_edges.len()
4810        ));
4811    }
4812
4813    ConvexProjectionFreshness {
4814        status: "stale".to_string(),
4815        fail_closed: true,
4816        local_hash,
4817        snapshot_hash,
4818        missing_nodes,
4819        stale_nodes,
4820        missing_edges,
4821        stale_edges,
4822        diagnostics,
4823    }
4824}
4825
4826pub(crate) fn verify_convex_projection_snapshot(
4827    root: &Path,
4828    scope: Option<&str>,
4829    snapshot_path: &Path,
4830) -> Result<()> {
4831    let graph_db = graph_substrate_db_path(root, scope);
4832    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4833    let local = convex_rows_from_graph_store(&store)?;
4834    let snapshot = load_convex_projection_rows(snapshot_path)?;
4835    validate_convex_projection_rows(&snapshot)?;
4836    let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
4837    if freshness.fail_closed {
4838        bail!(
4839            "Convex graph projection is not current for {}: {}",
4840            root.display(),
4841            freshness.diagnostics.join("; ")
4842        );
4843    }
4844    Ok(())
4845}
4846
4847fn convex_rows_diff(
4848    local: &ConvexProjectionRows,
4849    snapshot: Option<&ConvexProjectionRows>,
4850) -> (
4851    Vec<ConvexNodeRow>,
4852    Vec<ConvexEdgeRow>,
4853    Vec<String>,
4854    Vec<String>,
4855) {
4856    let Some(snapshot) = snapshot else {
4857        return (
4858            local.nodes.clone(),
4859            local.edges.clone(),
4860            Vec::new(),
4861            Vec::new(),
4862        );
4863    };
4864    let local_nodes = local
4865        .nodes
4866        .iter()
4867        .map(|row| (row.external_id.as_str(), row))
4868        .collect::<BTreeMap<_, _>>();
4869    let local_edges = local
4870        .edges
4871        .iter()
4872        .map(|row| (row.edge_key.as_str(), row))
4873        .collect::<BTreeMap<_, _>>();
4874    let snapshot_nodes = snapshot
4875        .nodes
4876        .iter()
4877        .map(|row| (row.external_id.as_str(), row))
4878        .collect::<BTreeMap<_, _>>();
4879    let snapshot_edges = snapshot
4880        .edges
4881        .iter()
4882        .map(|row| (row.edge_key.as_str(), row))
4883        .collect::<BTreeMap<_, _>>();
4884
4885    let node_upserts = local
4886        .nodes
4887        .iter()
4888        .filter(|row| {
4889            snapshot_nodes
4890                .get(row.external_id.as_str())
4891                .is_none_or(|snapshot_row| *snapshot_row != *row)
4892        })
4893        .cloned()
4894        .collect::<Vec<_>>();
4895    let edge_upserts = local
4896        .edges
4897        .iter()
4898        .filter(|row| {
4899            snapshot_edges
4900                .get(row.edge_key.as_str())
4901                .is_none_or(|snapshot_row| *snapshot_row != *row)
4902        })
4903        .cloned()
4904        .collect::<Vec<_>>();
4905    let node_tombstones = snapshot
4906        .nodes
4907        .iter()
4908        .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
4909        .map(|row| row.external_id.clone())
4910        .collect::<Vec<_>>();
4911    let edge_tombstones = snapshot
4912        .edges
4913        .iter()
4914        .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
4915        .map(|row| row.edge_key.clone())
4916        .collect::<Vec<_>>();
4917
4918    (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
4919}
4920
4921fn push_sync_chunks(
4922    chunks: &mut Vec<ConvexSyncChunk>,
4923    operation: &str,
4924    keys: Vec<String>,
4925    size: usize,
4926) {
4927    if keys.is_empty() {
4928        return;
4929    }
4930    for (idx, chunk) in keys.chunks(size).enumerate() {
4931        chunks.push(ConvexSyncChunk {
4932            operation: operation.to_string(),
4933            chunk: idx + 1,
4934            count: chunk.len(),
4935            keys: chunk.to_vec(),
4936            max_attempts: 3,
4937            retry_policy:
4938                "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
4939                    .to_string(),
4940        });
4941    }
4942}
4943
4944pub(crate) fn build_convex_sync_report_with_snapshot(
4945    path: &Path,
4946    scope: Option<&str>,
4947    snapshot: Option<ConvexProjectionRows>,
4948    chunk_size: usize,
4949    dry_run: bool,
4950) -> Result<ConvexSyncReport> {
4951    if chunk_size == 0 {
4952        bail!("--chunk-size must be greater than zero");
4953    }
4954    let root = lint::resolve_project_root_or_canonical_path(path)?;
4955    let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
4956    let graph_db = graph_substrate_db_path(&root, scope);
4957    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
4958    let local = convex_rows_from_graph_store(&store)?;
4959    let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
4960    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
4961        convex_rows_diff(&local, snapshot.as_ref());
4962
4963    let mut chunks = Vec::new();
4964    push_sync_chunks(
4965        &mut chunks,
4966        "delete_edges",
4967        edge_tombstones.clone(),
4968        chunk_size,
4969    );
4970    push_sync_chunks(
4971        &mut chunks,
4972        "upsert_nodes",
4973        node_upserts
4974            .iter()
4975            .map(|row| row.external_id.clone())
4976            .collect(),
4977        chunk_size,
4978    );
4979    push_sync_chunks(
4980        &mut chunks,
4981        "upsert_edges",
4982        edge_upserts
4983            .iter()
4984            .map(|row| row.edge_key.clone())
4985            .collect(),
4986        chunk_size,
4987    );
4988    push_sync_chunks(
4989        &mut chunks,
4990        "delete_nodes",
4991        node_tombstones.clone(),
4992        chunk_size,
4993    );
4994
4995    let mut diagnostics = vec![
4996        "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
4997            .to_string(),
4998    ];
4999    if dry_run {
5000        diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5001    }
5002    if freshness.fail_closed {
5003        diagnostics.push(
5004            "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5005                .to_string(),
5006        );
5007    }
5008
5009    Ok(ConvexSyncReport {
5010        root: root.to_string_lossy().to_string(),
5011        scope: scope.map(str::to_string),
5012        graph_db: graph_db.to_string_lossy().to_string(),
5013        dry_run,
5014        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5015        projection_hash: convex_projection_hash(&local, scope),
5016        required_indexes: convex_required_indexes(),
5017        node_upserts,
5018        edge_upserts,
5019        node_tombstones,
5020        edge_tombstones,
5021        chunks,
5022        freshness,
5023        transport: None,
5024        receipts: Vec::new(),
5025        diagnostics,
5026        warnings: graph.warnings,
5027    })
5028}
5029
5030#[cfg(test)]
5031fn build_convex_sync_report(
5032    path: &Path,
5033    scope: Option<&str>,
5034    snapshot_path: Option<&Path>,
5035    chunk_size: usize,
5036) -> Result<ConvexSyncReport> {
5037    let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5038    build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5039}
5040
5041pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5042    if compact {
5043        println!(
5044            "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5045            report.node_upserts.len(),
5046            report.node_tombstones.len(),
5047            report.edge_upserts.len(),
5048            report.edge_tombstones.len(),
5049            report.chunks.len(),
5050            report.freshness.status
5051        );
5052        return;
5053    }
5054
5055    println!(
5056        "Convex graph sync {}",
5057        if report.dry_run { "dry-run" } else { "apply" }
5058    );
5059    println!("root: {}", report.root);
5060    println!("graph_db: {}", report.graph_db);
5061    println!(
5062        "upserts: {} node(s), {} edge(s)",
5063        report.node_upserts.len(),
5064        report.edge_upserts.len()
5065    );
5066    println!(
5067        "tombstones: {} node(s), {} edge(s)",
5068        report.node_tombstones.len(),
5069        report.edge_tombstones.len()
5070    );
5071    println!("chunks: {}", report.chunks.len());
5072    println!("freshness: {}", report.freshness.status);
5073    if let Some(transport) = &report.transport {
5074        println!(
5075            "transport: endpoint_env={} auth_env={} applied_chunks={}",
5076            transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5077        );
5078    }
5079    for receipt in &report.receipts {
5080        println!(
5081            "receipt: {} chunk {} attempt {} {}",
5082            receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5083        );
5084    }
5085    for diagnostic in report
5086        .diagnostics
5087        .iter()
5088        .chain(report.freshness.diagnostics.iter())
5089    {
5090        println!("- {}", diagnostic);
5091    }
5092}
5093
5094pub(crate) struct ConvexSyncOptions<'a> {
5095    path: &'a Path,
5096    scope: Option<&'a str>,
5097    snapshot: Option<&'a Path>,
5098    chunk_size: usize,
5099    remote_snapshot: bool,
5100    apply: bool,
5101    endpoint: Option<&'a str>,
5102    auth_token_env: &'a str,
5103}
5104
5105#[derive(Serialize)]
5106struct GraphDbSchemaField {
5107    name: &'static str,
5108    value_type: &'static str,
5109    description: &'static str,
5110}
5111
5112#[derive(Serialize)]
5113struct GraphDbSchemaOperation {
5114    command: &'static str,
5115    description: &'static str,
5116}
5117
5118#[derive(Serialize)]
5119struct GraphDbSchemaContract {
5120    name: &'static str,
5121    version: &'static str,
5122    description: &'static str,
5123}
5124
5125#[derive(Serialize)]
5126struct GraphDbSchema {
5127    contract_versions: Vec<GraphDbSchemaContract>,
5128    node_fields: Vec<GraphDbSchemaField>,
5129    edge_fields: Vec<GraphDbSchemaField>,
5130    operations: Vec<GraphDbSchemaOperation>,
5131}
5132
5133#[derive(Clone, Serialize, Deserialize)]
5134struct GraphDbFreshnessReport {
5135    status: String,
5136    fail_closed: bool,
5137    projection_version: Option<String>,
5138    content_hash: Option<String>,
5139    source_watermark: Option<String>,
5140    diagnostics: Vec<String>,
5141}
5142
5143#[derive(Clone, Debug, Serialize)]
5144pub(crate) struct GraphEffectivenessReadiness {
5145    pub(crate) status: String,
5146    pub(crate) fail_closed: bool,
5147    pub(crate) reason: String,
5148    pub(crate) diagnostics: Vec<String>,
5149    pub(crate) next_commands: Vec<String>,
5150}
5151
5152#[derive(Clone, Debug, Serialize, PartialEq)]
5153struct GraphDbPropertyFilter {
5154    key: String,
5155    value: String,
5156}
5157
5158#[derive(Clone, Debug, Default)]
5159struct GraphDbQueryOptions {
5160    cursor: Option<String>,
5161    limit: Option<usize>,
5162    property_filters: Vec<GraphDbPropertyFilter>,
5163}
5164
5165#[derive(Clone, Debug, Serialize, PartialEq)]
5166struct GraphDbPageReport {
5167    #[serde(skip_serializing_if = "Option::is_none")]
5168    cursor: Option<String>,
5169    #[serde(skip_serializing_if = "Option::is_none")]
5170    limit: Option<usize>,
5171    #[serde(skip_serializing_if = "Option::is_none")]
5172    next_cursor: Option<String>,
5173    returned_nodes: usize,
5174    returned_edges: usize,
5175    truncated: bool,
5176    property_filters: Vec<GraphDbPropertyFilter>,
5177    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5178    diagnostics: Vec<String>,
5179}
5180
5181type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5182
5183#[derive(Clone, Debug, Serialize)]
5184struct CommunityTruncationSummary {
5185    total_communities: usize,
5186    fully_kept: usize,
5187    partially_pruned: usize,
5188    fully_pruned: usize,
5189    pruned_community_kinds: Vec<String>,
5190    pruned_community_top_labels: Vec<String>,
5191}
5192
5193#[derive(Clone, Debug, Serialize)]
5194struct GraphDbRankedNeighborhoodComparison {
5195    traversal_nodes: usize,
5196    traversal_edges: usize,
5197    pruned_count: usize,
5198    total_discovered: usize,
5199    latency_micros: u128,
5200    overlap_with_unranked_pct: f64,
5201    useful_hit_density_ranked: f64,
5202    useful_hit_density_unranked: f64,
5203    duplicate_name_count_ranked: usize,
5204    duplicate_name_count_unranked: usize,
5205    handle_coverage_ranked_pct: f64,
5206    handle_coverage_unranked_pct: f64,
5207    #[serde(skip_serializing_if = "Option::is_none")]
5208    community_truncation_summary: Option<CommunityTruncationSummary>,
5209    diagnostics: Vec<String>,
5210}
5211
5212#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5213struct GraphDbDroppedByBudget {
5214    item: String,
5215    kind: String,
5216    dropped: usize,
5217    reason: String,
5218}
5219
5220#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5221struct GraphDbOutputBudgetReport {
5222    max_tokens: usize,
5223    estimated_tokens: usize,
5224    selected_nodes: usize,
5225    selected_edges: usize,
5226    candidate_nodes: usize,
5227    candidate_edges: usize,
5228    dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5229    diagnostics: Vec<String>,
5230}
5231
5232#[derive(Clone, Debug, Serialize, PartialEq)]
5233struct GraphDbKnowledgeRetrieval {
5234    mode: String,
5235    query: String,
5236    seed_kind: String,
5237    seed_limit: usize,
5238    seed_count: usize,
5239    depth: usize,
5240    limit: usize,
5241    node_count: usize,
5242    edge_count: usize,
5243    truncated: bool,
5244    traversal: String,
5245    freshness_boundary: String,
5246    privacy_boundary: String,
5247    diagnostics: Vec<String>,
5248}
5249
5250struct GraphDbSemanticSeededSubgraph {
5251    nodes: Vec<SubstrateGraphNode>,
5252    edges: Vec<SubstrateGraphEdge>,
5253    truncated: bool,
5254    diagnostics: Vec<String>,
5255}
5256
5257type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5258
5259#[derive(Serialize)]
5260struct GraphDbReport {
5261    root: String,
5262    #[serde(skip_serializing_if = "Option::is_none")]
5263    scope: Option<String>,
5264    backend: String,
5265    query: String,
5266    freshness: GraphDbFreshnessReport,
5267    #[serde(skip_serializing_if = "Option::is_none")]
5268    readiness: Option<GraphEffectivenessReadiness>,
5269    #[serde(skip_serializing_if = "Option::is_none")]
5270    schema: Option<GraphDbSchema>,
5271    #[serde(skip_serializing_if = "Option::is_none")]
5272    node: Option<SubstrateTerseGraphNode>,
5273    #[serde(skip_serializing_if = "Option::is_none")]
5274    edge: Option<SubstrateTerseGraphEdge>,
5275    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5276    nodes: Vec<SubstrateTerseGraphNode>,
5277    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5278    edges: Vec<SubstrateTerseGraphEdge>,
5279    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5280    ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5281    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5282    semantic_related: Vec<SemanticRelatedItem>,
5283    #[serde(skip_serializing_if = "Option::is_none")]
5284    neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5285    #[serde(skip_serializing_if = "Option::is_none")]
5286    ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5287    #[serde(skip_serializing_if = "Option::is_none")]
5288    knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5289    #[serde(skip_serializing_if = "Option::is_none")]
5290    output_budget: Option<GraphDbOutputBudgetReport>,
5291    #[serde(skip_serializing_if = "Option::is_none")]
5292    path: Option<substrate::GraphPath>,
5293    #[serde(skip_serializing_if = "Option::is_none")]
5294    page: Option<GraphDbPageReport>,
5295    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5296    warnings: Vec<String>,
5297}
5298
5299struct ExperimentalReadOnlyGraphStore {
5300    backend: GraphDbExperimentalBackend,
5301    nodes: BTreeMap<String, SubstrateGraphNode>,
5302    edges: BTreeMap<String, SubstrateGraphEdge>,
5303    node_ids_by_kind: BTreeMap<String, Vec<String>>,
5304    outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5305}
5306
5307impl ExperimentalReadOnlyGraphStore {
5308    fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5309        validate_convex_projection_rows(rows)?;
5310        let nodes = rows
5311            .nodes
5312            .iter()
5313            .map(|row| {
5314                let node = SubstrateGraphNode {
5315                    id: row.external_id.clone(),
5316                    kind: row.kind.clone(),
5317                    label: row.label.clone(),
5318                    properties: row.properties.clone(),
5319                    provenance: row.provenance.clone(),
5320                    freshness: row.freshness.clone(),
5321                };
5322                (node.id.clone(), node)
5323            })
5324            .collect::<BTreeMap<_, _>>();
5325        let edges = rows
5326            .edges
5327            .iter()
5328            .map(|row| {
5329                let edge = SubstrateGraphEdge {
5330                    id: row.edge_key.clone(),
5331                    from_id: row.from_external_id.clone(),
5332                    to_id: row.to_external_id.clone(),
5333                    kind: row.kind.clone(),
5334                    properties: row.properties.clone(),
5335                    provenance: row.provenance.clone(),
5336                    freshness: row.freshness.clone(),
5337                };
5338                (graph_db_edge_key(&edge), edge)
5339            })
5340            .collect::<BTreeMap<_, _>>();
5341        let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
5342        for node in nodes.values() {
5343            node_ids_by_kind
5344                .entry(node.kind.clone())
5345                .or_default()
5346                .push(node.id.clone());
5347        }
5348        for ids in node_ids_by_kind.values_mut() {
5349            ids.sort();
5350        }
5351        let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
5352        for edge in edges.values() {
5353            outgoing_edge_keys_by_from
5354                .entry(edge.from_id.clone())
5355                .or_default()
5356                .push(graph_db_edge_key(edge));
5357        }
5358        for edge_keys in outgoing_edge_keys_by_from.values_mut() {
5359            edge_keys.sort_by(|left_key, right_key| {
5360                let left = &edges[left_key];
5361                let right = &edges[right_key];
5362                left.to_id
5363                    .cmp(&right.to_id)
5364                    .then(left.kind.cmp(&right.kind))
5365                    .then(left_key.cmp(right_key))
5366            });
5367        }
5368        Ok(Self {
5369            backend,
5370            nodes,
5371            edges,
5372            node_ids_by_kind,
5373            outgoing_edge_keys_by_from,
5374        })
5375    }
5376}
5377
5378impl GraphStore for ExperimentalReadOnlyGraphStore {
5379    fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
5380        bail!("{} backend-eval adapter is read-only", self.backend.name())
5381    }
5382
5383    fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
5384        bail!("{} backend-eval adapter is read-only", self.backend.name())
5385    }
5386
5387    fn delete_node(&self, _id: &str) -> Result<usize> {
5388        bail!("{} backend-eval adapter is read-only", self.backend.name())
5389    }
5390
5391    fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
5392        bail!("{} backend-eval adapter is read-only", self.backend.name())
5393    }
5394
5395    fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
5396        Ok(self.nodes.get(id).cloned())
5397    }
5398
5399    fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
5400        Ok(self.nodes.values().cloned().collect())
5401    }
5402
5403    fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
5404        let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
5405        edges.sort_by(|left, right| {
5406            left.from_id
5407                .cmp(&right.from_id)
5408                .then(left.kind.cmp(&right.kind))
5409                .then(left.to_id.cmp(&right.to_id))
5410        });
5411        Ok(edges)
5412    }
5413
5414    fn graph_counts(&self) -> Result<(usize, usize)> {
5415        Ok((self.nodes.len(), self.edges.len()))
5416    }
5417
5418    fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
5419        let mut edges = self
5420            .edges
5421            .values()
5422            .filter(|edge| edge.from_id != edge.to_id)
5423            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5424            .cloned()
5425            .collect::<Vec<_>>();
5426        edges.sort_by(|left, right| {
5427            left.from_id
5428                .cmp(&right.from_id)
5429                .then(left.kind.cmp(&right.kind))
5430                .then(left.to_id.cmp(&right.to_id))
5431        });
5432        Ok(edges.into_iter().next())
5433    }
5434
5435    fn sample_edge_with_property(
5436        &self,
5437    ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
5438        Ok(self
5439            .edges
5440            .values()
5441            .filter(|edge| edge.from_id != edge.to_id)
5442            .filter_map(|edge| {
5443                edge.properties.iter().next().map(|(key, value)| {
5444                    (
5445                        edge,
5446                        GraphPropertyFilter {
5447                            key: key.clone(),
5448                            value: value.clone(),
5449                        },
5450                    )
5451                })
5452            })
5453            .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
5454                left_filter
5455                    .key
5456                    .cmp(&right_filter.key)
5457                    .then(left_filter.value.cmp(&right_filter.value))
5458                    .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
5459            })
5460            .map(|(edge, filter)| (edge.clone(), filter)))
5461    }
5462
5463    fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
5464        Ok(self
5465            .node_ids_by_kind
5466            .get(kind)
5467            .into_iter()
5468            .flatten()
5469            .filter_map(|id| self.nodes.get(id).cloned())
5470            .collect())
5471    }
5472
5473    fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
5474        Ok(self
5475            .outgoing_edge_keys_by_from
5476            .get(from_id)
5477            .into_iter()
5478            .flatten()
5479            .filter_map(|key| self.edges.get(key))
5480            .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
5481            .cloned()
5482            .collect())
5483    }
5484
5485    fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
5486        Ok(self
5487            .edges
5488            .values()
5489            .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
5490            .cloned()
5491            .collect())
5492    }
5493
5494    fn shortest_path(
5495        &self,
5496        from_id: &str,
5497        to_id: &str,
5498        kind: Option<&str>,
5499    ) -> Result<Option<substrate::GraphPath>> {
5500        if from_id == to_id {
5501            return Ok(Some(substrate::GraphPath {
5502                nodes: vec![from_id.to_string()],
5503                hops: 0,
5504            }));
5505        }
5506
5507        let mut queue = VecDeque::new();
5508        let mut parent = BTreeMap::<String, String>::new();
5509        parent.insert(from_id.to_string(), String::new());
5510        queue.push_back(from_id.to_string());
5511
5512        while let Some(current) = queue.pop_front() {
5513            for edge in self.outgoing_edges(&current, kind)? {
5514                if parent.contains_key(&edge.to_id) {
5515                    continue;
5516                }
5517                parent.insert(edge.to_id.clone(), current.clone());
5518                if edge.to_id == to_id {
5519                    let mut nodes = vec![to_id.to_string()];
5520                    let mut cursor = to_id;
5521                    while let Some(previous) = parent.get(cursor) {
5522                        if previous.is_empty() {
5523                            break;
5524                        }
5525                        nodes.push(previous.clone());
5526                        cursor = previous;
5527                    }
5528                    nodes.reverse();
5529                    return Ok(Some(substrate::GraphPath {
5530                        hops: nodes.len().saturating_sub(1),
5531                        nodes,
5532                    }));
5533                }
5534                queue.push_back(edge.to_id);
5535            }
5536        }
5537
5538        Ok(None)
5539    }
5540
5541    fn reachable_nodes_by_kinds(
5542        &self,
5543        from_id: &str,
5544        kinds: &[&str],
5545        depth: usize,
5546        limit: usize,
5547    ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
5548        let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
5549        let mut rows = requested
5550            .iter()
5551            .map(|kind| {
5552                (
5553                    (*kind).to_string(),
5554                    BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
5555                )
5556            })
5557            .collect::<BTreeMap<_, _>>();
5558        if requested.is_empty() {
5559            return Ok(BTreeMap::new());
5560        }
5561
5562        let mut seen = BTreeSet::from([from_id.to_string()]);
5563        let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
5564        while let Some((current, path)) = queue.pop_front() {
5565            let current_depth = path.len().saturating_sub(1);
5566            if current_depth >= depth {
5567                continue;
5568            }
5569            for edge in self.outgoing_edges(&current, None)? {
5570                if !seen.insert(edge.to_id.clone()) {
5571                    continue;
5572                }
5573                let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
5574                    continue;
5575                };
5576                let mut next_path = path.clone();
5577                next_path.push(edge.to_id.clone());
5578                let graph_path = substrate::GraphPath {
5579                    hops: next_path.len().saturating_sub(1),
5580                    nodes: next_path.clone(),
5581                };
5582                if requested.contains(node.kind.as_str()) {
5583                    rows.entry(node.kind.clone())
5584                        .or_default()
5585                        .entry(node.id.clone())
5586                        .or_insert((node.clone(), graph_path));
5587                }
5588                queue.push_back((edge.to_id, next_path));
5589            }
5590        }
5591
5592        Ok(rows
5593            .into_iter()
5594            .map(|(kind, values)| {
5595                let mut values = values.into_values().collect::<Vec<_>>();
5596                values.sort_by(|(left_node, left_path), (right_node, right_path)| {
5597                    left_path
5598                        .hops
5599                        .cmp(&right_path.hops)
5600                        .then(left_node.label.cmp(&right_node.label))
5601                        .then(left_node.id.cmp(&right_node.id))
5602                });
5603                if limit > 0 && values.len() > limit {
5604                    values.truncate(limit);
5605                }
5606                (kind, values)
5607            })
5608            .collect())
5609    }
5610}
5611
5612pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
5613pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
5614pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
5615const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
5616pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
5617const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
5618const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
5619const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
5620const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
5621
5622#[derive(Clone, Serialize, Deserialize)]
5623pub(crate) struct GraphDbBackendEvalPhaseTiming {
5624    name: String,
5625    duration_micros: u128,
5626    detail: String,
5627}
5628
5629#[derive(Serialize, Deserialize)]
5630struct GraphDbBackendEvalFullProjectionCache {
5631    version: String,
5632    key: String,
5633    source_watermark: String,
5634    projection: GraphProjection,
5635    warnings: Vec<String>,
5636}
5637
5638#[derive(Clone, Default)]
5639struct GraphDbBackendEvalFullProjectionCacheStats {
5640    hit: bool,
5641    disk_bytes: u64,
5642    json_bytes: u64,
5643    pruned_files: usize,
5644    pruned_bytes: u64,
5645}
5646
5647#[derive(Serialize)]
5648struct GraphDbBackendEvalRawSourceWatermarkRow {
5649    path: String,
5650    bytes: u64,
5651    content_hash: String,
5652}
5653
5654#[derive(Clone)]
5655struct GraphDbBackendEvalFullProjectionSourceWatermark {
5656    value: String,
5657    detail: String,
5658}
5659
5660#[derive(Serialize)]
5661pub(crate) struct GraphDbBackendEvalConfig {
5662    high_degree_nodes: usize,
5663    high_degree_fanout: usize,
5664    deep_chain_nodes: usize,
5665    deep_chain_fanout: usize,
5666    depth: usize,
5667    limit: usize,
5668    impact_limit: usize,
5669    path_max_hops: usize,
5670    path_direct_hop_budget: usize,
5671    path_deep_chain_hop_budget: usize,
5672    path_extended_hop_budgets: Vec<usize>,
5673    path_hop_policy: String,
5674    path_probe_strategy: String,
5675    path_query_plan_checks: Vec<String>,
5676    full_projection_enabled: bool,
5677    full_projection_profile: String,
5678    normalization_row_unit: usize,
5679}
5680
5681#[derive(Clone)]
5682struct GraphDbBackendEvalSignature {
5683    operation: String,
5684    value: serde_json::Value,
5685}
5686
5687#[derive(Serialize)]
5688struct GraphDbBackendEvalOperation {
5689    name: String,
5690    supported: bool,
5691    status: String,
5692    duration_micros: u128,
5693    #[serde(skip_serializing_if = "Option::is_none")]
5694    rows: Option<usize>,
5695    #[serde(skip_serializing_if = "Option::is_none")]
5696    error: Option<String>,
5697}
5698
5699#[derive(Serialize)]
5700struct GraphDbBackendEvalParity {
5701    matches_sqlite: bool,
5702    diagnostics: Vec<String>,
5703}
5704
5705#[derive(Serialize)]
5706struct GraphDbBackendEvalBackendReport {
5707    backend: String,
5708    adapter: String,
5709    read_only: bool,
5710    projection_load: String,
5711    operations: Vec<GraphDbBackendEvalOperation>,
5712    total_micros: u128,
5713    parity: GraphDbBackendEvalParity,
5714    lock_behavior: String,
5715    install_portability: String,
5716}
5717
5718#[derive(Serialize)]
5719struct GraphDbBackendEvalDataset {
5720    name: String,
5721    target_count: usize,
5722    nodes: usize,
5723    edges: usize,
5724    backends: Vec<GraphDbBackendEvalBackendReport>,
5725}
5726
5727#[derive(Serialize)]
5728struct GraphDbBackendPromotionDecision {
5729    backend: String,
5730    decision: String,
5731    reasons: Vec<String>,
5732    gate: GraphDbBackendPromotionGate,
5733}
5734
5735#[derive(Serialize)]
5736struct GraphDbBackendEvalPerformanceGate {
5737    baseline_fixture: String,
5738    ci_profile: String,
5739    opt_in_real_profile: String,
5740    full_projection_cache_hit_gate: String,
5741    allowed_regression_percent: f64,
5742    minimum_sample_runs: usize,
5743    normalized_metric_unit: String,
5744    required_metrics: Vec<String>,
5745    digest_command: String,
5746    repeated_sample_command: String,
5747    hop_cap_promotion: GraphDbHopCapPromotionGate,
5748    backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
5749}
5750
5751#[derive(Serialize)]
5752struct GraphDbHopCapPromotionGate {
5753    status: String,
5754    current_default_hops: usize,
5755    candidate_hop_tiers: Vec<usize>,
5756    required_backend: String,
5757    required_workloads: Vec<String>,
5758    required_metrics: Vec<String>,
5759    allowed_regression_percent: f64,
5760    minimum_sample_runs: usize,
5761    decision_rule: String,
5762}
5763
5764#[derive(Serialize)]
5765struct GraphDbBackendAdapterSpikeGate {
5766    status: String,
5767    candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
5768    required_workloads: Vec<String>,
5769    required_checks: Vec<String>,
5770    decision_rule: String,
5771    evidence_plan: String,
5772}
5773
5774#[derive(Serialize)]
5775struct GraphDbBackendAdapterSpikeCandidate {
5776    backend: String,
5777    adapter_label: String,
5778    projection_load: String,
5779    lock_behavior: String,
5780    install_portability: String,
5781}
5782
5783#[derive(Serialize)]
5784pub(crate) struct GraphDbBackendEvalReport {
5785    root: String,
5786    #[serde(skip_serializing_if = "Option::is_none")]
5787    scope: Option<String>,
5788    label: String,
5789    baseline_backend: String,
5790    candidates: Vec<String>,
5791    targets: Vec<String>,
5792    config: GraphDbBackendEvalConfig,
5793    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5794    datasets: Vec<GraphDbBackendEvalDataset>,
5795    promotion: Vec<GraphDbBackendPromotionDecision>,
5796    performance_gate: GraphDbBackendEvalPerformanceGate,
5797    metrics: BTreeMap<String, f64>,
5798    metric_digest_command: String,
5799    warnings: Vec<String>,
5800}
5801
5802#[derive(Clone, Debug, Serialize)]
5803struct GraphDbDoctorCheck {
5804    name: String,
5805    status: String,
5806    fail_closed: bool,
5807    diagnostics: Vec<String>,
5808    repair_commands: Vec<String>,
5809}
5810
5811#[derive(Serialize)]
5812pub(crate) struct GraphDbDoctorReport {
5813    root: String,
5814    #[serde(skip_serializing_if = "Option::is_none")]
5815    scope: Option<String>,
5816    backend: String,
5817    graph_db: String,
5818    #[serde(skip_serializing_if = "Option::is_none")]
5819    convex_snapshot: Option<String>,
5820    status: String,
5821    fail_closed: bool,
5822    checks: Vec<GraphDbDoctorCheck>,
5823    repair_commands: Vec<String>,
5824    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5825    required_indexes: Vec<ConvexRequiredIndex>,
5826}
5827
5828#[derive(Serialize)]
5829struct GraphDbDriftSummary {
5830    node_upserts: usize,
5831    edge_upserts: usize,
5832    node_tombstones: usize,
5833    edge_tombstones: usize,
5834    stale_nodes: usize,
5835    stale_edges: usize,
5836    stale_projection_metadata: usize,
5837    duplicate_failures: usize,
5838    orphan_failures: usize,
5839    missing_required_indexes: usize,
5840}
5841
5842#[derive(Serialize)]
5843struct GraphDbDriftReport {
5844    root: String,
5845    #[serde(skip_serializing_if = "Option::is_none")]
5846    scope: Option<String>,
5847    graph_db: String,
5848    convex_snapshot: String,
5849    status: String,
5850    graph_reads_allowed: bool,
5851    projection_version: String,
5852    local_hash: Option<String>,
5853    snapshot_hash: Option<String>,
5854    summary: GraphDbDriftSummary,
5855    node_upserts: Vec<String>,
5856    edge_upserts: Vec<String>,
5857    node_tombstones: Vec<String>,
5858    edge_tombstones: Vec<String>,
5859    stale_nodes: Vec<String>,
5860    stale_edges: Vec<String>,
5861    diagnostics: Vec<String>,
5862    next_commands: Vec<String>,
5863    required_indexes: Vec<ConvexRequiredIndex>,
5864    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5865    warnings: Vec<String>,
5866}
5867
5868#[derive(Clone, Serialize)]
5869struct GraphDbTombstoneCounts {
5870    nodes: usize,
5871    edges: usize,
5872    total: usize,
5873}
5874
5875#[derive(Clone, Serialize)]
5876struct GraphDbOperatorCounts {
5877    nodes: usize,
5878    edges: usize,
5879    tombstones: GraphDbTombstoneCounts,
5880    #[serde(skip_serializing_if = "Option::is_none")]
5881    file_size_bytes: Option<u64>,
5882    #[serde(skip_serializing_if = "Option::is_none")]
5883    freelist_bytes: Option<u64>,
5884}
5885
5886#[derive(Clone, Serialize)]
5887struct GraphDbCompactionPolicy {
5888    status: String,
5889    tombstone_scan_rows: usize,
5890    live_rows: usize,
5891    file_size_bytes: Option<u64>,
5892    freelist_bytes: Option<u64>,
5893    safe_to_prune_tombstones: bool,
5894    requires_convex_reconciliation: bool,
5895    recommendations: Vec<String>,
5896    proof: Vec<String>,
5897}
5898
5899#[derive(Serialize)]
5900pub(crate) struct GraphDbRefreshSummary {
5901    scope: String,
5902    projection_version: String,
5903    mode: String,
5904    #[serde(skip_serializing_if = "Option::is_none")]
5905    source_watermark: Option<String>,
5906    tombstoned_nodes: usize,
5907    tombstoned_edges: usize,
5908    upserted_nodes: usize,
5909    upserted_edges: usize,
5910    unchanged_nodes: usize,
5911    unchanged_edges: usize,
5912    upserted_properties: usize,
5913    unchanged_properties: usize,
5914    deleted_properties: usize,
5915    deleted_nodes: usize,
5916    deleted_edges: usize,
5917    pruned_tombstones: usize,
5918    #[serde(skip_serializing_if = "Option::is_none")]
5919    file_size_bytes_before: Option<u64>,
5920    #[serde(skip_serializing_if = "Option::is_none")]
5921    file_size_bytes_after: Option<u64>,
5922    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5923    phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
5924}
5925
5926#[derive(Serialize)]
5927struct GraphDbOperatorReport {
5928    root: String,
5929    #[serde(skip_serializing_if = "Option::is_none")]
5930    scope: Option<String>,
5931    graph_db: String,
5932    operation: String,
5933    status: String,
5934    materialized: bool,
5935    freshness: GraphDbFreshnessReport,
5936    readiness: GraphEffectivenessReadiness,
5937    counts: GraphDbOperatorCounts,
5938    #[serde(skip_serializing_if = "Option::is_none")]
5939    refresh: Option<GraphDbRefreshSummary>,
5940    compaction: GraphDbCompactionPolicy,
5941    #[serde(skip_serializing_if = "Option::is_none")]
5942    recovery: Option<index::ReadOnlyRecovery>,
5943    next_commands: Vec<String>,
5944    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5945    warnings: Vec<String>,
5946}
5947
5948#[derive(Serialize)]
5949pub(crate) struct GraphDbCompactionReport {
5950    root: String,
5951    #[serde(skip_serializing_if = "Option::is_none")]
5952    scope: Option<String>,
5953    graph_db: String,
5954    applied: bool,
5955    pruned_tombstones: usize,
5956    counts_before: GraphDbOperatorCounts,
5957    counts_after: GraphDbOperatorCounts,
5958    compaction_before: GraphDbCompactionPolicy,
5959    compaction_after: GraphDbCompactionPolicy,
5960    reclaimed_bytes: i64,
5961    next_commands: Vec<String>,
5962    #[serde(skip_serializing_if = "Vec::is_empty", default)]
5963    warnings: Vec<String>,
5964}
5965
5966#[derive(Clone, Serialize, Deserialize)]
5967struct GraphDbEvidencePath {
5968    to: String,
5969    kind: String,
5970    label: String,
5971    #[serde(skip_serializing_if = "Option::is_none")]
5972    path: Option<substrate::GraphPath>,
5973    #[serde(skip_serializing_if = "Option::is_none")]
5974    expand: Option<String>,
5975}
5976
5977#[derive(Clone, Serialize, Deserialize)]
5978struct GraphDbFixtureCoverage {
5979    test: String,
5980    fixture: String,
5981    assertions: Vec<String>,
5982}
5983
5984#[derive(Clone, Serialize, Deserialize)]
5985struct GraphDbEvidenceReport {
5986    root: String,
5987    #[serde(skip_serializing_if = "Option::is_none")]
5988    scope: Option<String>,
5989    backend: String,
5990    contract_version: String,
5991    target: String,
5992    packet_id: String,
5993    #[serde(skip_serializing_if = "Option::is_none")]
5994    projection_hash: Option<String>,
5995    freshness: GraphDbFreshnessReport,
5996    target_node: SubstrateTerseGraphNode,
5997    worker_context: Vec<SubstrateTerseGraphNode>,
5998    source_handles: Vec<SubstrateTerseGraphNode>,
5999    worker_results: Vec<SubstrateTerseGraphNode>,
6000    semantic_related: Vec<SubstrateTerseGraphNode>,
6001    shortest_paths: Vec<GraphDbEvidencePath>,
6002    #[serde(skip_serializing_if = "Option::is_none")]
6003    output_budget: Option<GraphDbOutputBudgetReport>,
6004    #[serde(default)]
6005    truncated: bool,
6006    #[serde(skip_serializing_if = "Option::is_none")]
6007    next_cursor: Option<String>,
6008    next_commands: Vec<String>,
6009    replay_commands: Vec<String>,
6010    repair_commands: Vec<String>,
6011    fixture_coverage: GraphDbFixtureCoverage,
6012    #[serde(skip_serializing_if = "Vec::is_empty", default)]
6013    warnings: Vec<String>,
6014}
6015
6016pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6017    root: &'a Path,
6018    scope: Option<&'a str>,
6019    backend: &'a str,
6020    target: &'a str,
6021    depth: usize,
6022    limit: usize,
6023    cursor: Option<&'a str>,
6024    store: &'a S,
6025    freshness: GraphDbFreshnessReport,
6026    warnings: Vec<String>,
6027}
6028
6029impl GraphDbDoctorReport {
6030    fn new(
6031        root: &Path,
6032        scope: Option<&str>,
6033        backend: &str,
6034        graph_db: &Path,
6035        convex_snapshot: Option<&Path>,
6036    ) -> Self {
6037        Self {
6038            root: root.to_string_lossy().to_string(),
6039            scope: scope.map(str::to_string),
6040            backend: backend.to_string(),
6041            graph_db: graph_db.to_string_lossy().to_string(),
6042            convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6043            status: "ok".to_string(),
6044            fail_closed: false,
6045            checks: Vec::new(),
6046            repair_commands: Vec::new(),
6047            required_indexes: Vec::new(),
6048        }
6049    }
6050
6051    fn push_check(&mut self, check: GraphDbDoctorCheck) {
6052        self.checks.push(check);
6053    }
6054
6055    fn finalize(&mut self) {
6056        self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6057        self.status = if self.fail_closed {
6058            "fail_closed"
6059        } else {
6060            "ok"
6061        }
6062        .to_string();
6063        let mut commands = BTreeSet::new();
6064        for check in &self.checks {
6065            commands.extend(check.repair_commands.iter().cloned());
6066        }
6067        self.repair_commands = commands.into_iter().collect();
6068    }
6069
6070    fn summary(&self) -> String {
6071        self.checks
6072            .iter()
6073            .filter(|check| check.fail_closed)
6074            .flat_map(|check| check.diagnostics.iter())
6075            .take(3)
6076            .cloned()
6077            .collect::<Vec<_>>()
6078            .join("; ")
6079    }
6080}
6081
6082fn graph_db_doctor_check(
6083    name: impl Into<String>,
6084    diagnostics: Vec<String>,
6085    repair_commands: Vec<String>,
6086) -> GraphDbDoctorCheck {
6087    let fail_closed = !diagnostics.is_empty();
6088    GraphDbDoctorCheck {
6089        name: name.into(),
6090        status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6091        fail_closed,
6092        diagnostics,
6093        repair_commands: if fail_closed {
6094            repair_commands
6095        } else {
6096            Vec::new()
6097        },
6098    }
6099}
6100
6101pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6102    scope
6103        .map(|scope| format!(" --scope {}", shell_quote(scope)))
6104        .unwrap_or_default()
6105}
6106
6107fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6108    format!(
6109        "tsift graph-db --path {}{} refresh --json",
6110        shell_quote(root.to_string_lossy().as_ref()),
6111        graph_db_scope_arg(scope)
6112    )
6113}
6114
6115fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6116    graph_db_refresh_command(root, scope)
6117}
6118
6119fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6120    let backup = format!("{}.bak", graph_db.to_string_lossy());
6121    format!(
6122        "mv {} {} && {}",
6123        shell_quote(graph_db.to_string_lossy().as_ref()),
6124        shell_quote(&backup),
6125        graph_db_rebuild_command(root, scope)
6126    )
6127}
6128
6129fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6130    format!(
6131        "tsift convex-sync {}{} --remote-snapshot --apply --json",
6132        shell_quote(root.to_string_lossy().as_ref()),
6133        graph_db_scope_arg(scope)
6134    )
6135}
6136
6137fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6138    substrate::open_graph_read_only_connection_resilient(graph_db)
6139}
6140
6141fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6142    conn.query_row(
6143        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6144        [table],
6145        |row| row.get::<_, bool>(0),
6146    )
6147    .map_err(Into::into)
6148}
6149
6150fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6151    let sql = match table {
6152        "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6153        "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6154        "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6155        other => bail!("unsupported graph count table {other}"),
6156    };
6157    conn.query_row(sql, [], |row| row.get::<_, usize>(0))
6158        .map_err(Into::into)
6159}
6160
6161fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6162    if !sqlite_table_exists(conn, "graph_tombstones")? {
6163        return Ok(GraphDbTombstoneCounts {
6164            nodes: 0,
6165            edges: 0,
6166            total: 0,
6167        });
6168    }
6169    let mut stmt =
6170        conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6171    let mut rows = stmt.query([])?;
6172    let mut nodes = 0usize;
6173    let mut edges = 0usize;
6174    while let Some(row) = rows.next()? {
6175        let row_kind: String = row.get(0)?;
6176        let count: usize = row.get(1)?;
6177        match row_kind.as_str() {
6178            "node" => nodes = count,
6179            "edge" => edges = count,
6180            _ => {}
6181        }
6182    }
6183    Ok(GraphDbTombstoneCounts {
6184        nodes,
6185        edges,
6186        total: nodes + edges,
6187    })
6188}
6189
6190fn sqlite_graph_counts_from_cache(
6191    conn: &Connection,
6192    scope: &str,
6193) -> Result<Option<GraphDbOperatorCounts>> {
6194    if !sqlite_table_exists(conn, "graph_operator_stats")? {
6195        return Ok(None);
6196    }
6197    let row = conn
6198        .query_row(
6199            r#"
6200        SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6201        FROM graph_operator_stats
6202        WHERE scope = ?1
6203        "#,
6204            [scope],
6205            |row| {
6206                Ok((
6207                    row.get::<_, usize>(0)?,
6208                    row.get::<_, usize>(1)?,
6209                    row.get::<_, usize>(2)?,
6210                    row.get::<_, usize>(3)?,
6211                    row.get::<_, Option<i64>>(4)?,
6212                    row.get::<_, Option<i64>>(5)?,
6213                ))
6214            },
6215        )
6216        .optional()?;
6217    Ok(row.map(
6218        |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6219            GraphDbOperatorCounts {
6220                nodes,
6221                edges,
6222                tombstones: GraphDbTombstoneCounts {
6223                    nodes: tombstone_nodes,
6224                    edges: tombstone_edges,
6225                    total: tombstone_nodes + tombstone_edges,
6226                },
6227                file_size_bytes: file_size_bytes
6228                    .and_then(|value| u64::try_from(value).ok())
6229                    .or_else(|| sqlite_database_size_bytes(conn).ok()),
6230                freelist_bytes: freelist_bytes
6231                    .and_then(|value| u64::try_from(value).ok())
6232                    .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6233            }
6234        },
6235    ))
6236}
6237
6238fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6239    if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6240        return Ok(counts);
6241    }
6242    let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6243        sqlite_known_table_count(conn, "graph_nodes")?
6244    } else {
6245        0
6246    };
6247    let edges = if sqlite_table_exists(conn, "graph_edges")? {
6248        sqlite_known_table_count(conn, "graph_edges")?
6249    } else {
6250        0
6251    };
6252    Ok(GraphDbOperatorCounts {
6253        nodes,
6254        edges,
6255        tombstones: sqlite_tombstone_counts(conn)?,
6256        file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6257        freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6258    })
6259}
6260
6261fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6262    if !sqlite_table_exists(conn, "graph_nodes")? {
6263        return Ok(0);
6264    }
6265    let count: i64 = conn.query_row(
6266        "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6267        [],
6268        |row| row.get(0),
6269    )?;
6270    Ok(count as usize)
6271}
6272
6273pub(crate) fn graph_db_compaction_policy(
6274    root: &Path,
6275    scope: Option<&str>,
6276    counts: &GraphDbOperatorCounts,
6277    prune_confirmed: bool,
6278) -> GraphDbCompactionPolicy {
6279    let live_rows = counts.nodes + counts.edges;
6280    let tombstone_scan_rows = counts.tombstones.total;
6281    let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6282    let freelist_heavy = counts
6283        .file_size_bytes
6284        .zip(counts.freelist_bytes)
6285        .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6286    let status = if tombstone_heavy || freelist_heavy {
6287        "recommended"
6288    } else {
6289        "not_needed"
6290    }
6291    .to_string();
6292    let mut recommendations = vec![
6293        convex_refresh_command(root, scope),
6294        graph_db_refresh_command(root, scope),
6295        format!(
6296            "tsift graph-db --path {}{} compact --apply --json",
6297            shell_quote(root.to_string_lossy().as_ref()),
6298            graph_db_scope_arg(scope)
6299        ),
6300    ];
6301    if prune_confirmed {
6302        recommendations.push(format!(
6303            "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6304            shell_quote(root.to_string_lossy().as_ref()),
6305            graph_db_scope_arg(scope)
6306        ));
6307    }
6308    let proof = vec![
6309        format!("{live_rows} live graph row(s)"),
6310        format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
6311        format!(
6312            "graph.db file_size={} byte(s), freelist={} byte(s)",
6313            counts.file_size_bytes.unwrap_or(0),
6314            counts.freelist_bytes.unwrap_or(0)
6315        ),
6316    ];
6317    GraphDbCompactionPolicy {
6318        status,
6319        tombstone_scan_rows,
6320        live_rows,
6321        file_size_bytes: counts.file_size_bytes,
6322        freelist_bytes: counts.freelist_bytes,
6323        safe_to_prune_tombstones: prune_confirmed,
6324        requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
6325        recommendations,
6326        proof,
6327    }
6328}
6329
6330fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
6331    let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
6332    let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6333    Ok(page_count.saturating_mul(page_size))
6334}
6335
6336fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
6337    let freelist_count: u64 = conn.query_row("PRAGMA freelist_count", [], |row| row.get(0))?;
6338    let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
6339    Ok(freelist_count.saturating_mul(page_size))
6340}
6341
6342fn sqlite_graph_tombstone_retention_diagnostics(
6343    conn: &Connection,
6344    scope: &str,
6345) -> Result<Vec<String>> {
6346    if !sqlite_table_exists(conn, "graph_tombstones")? {
6347        return Ok(Vec::new());
6348    }
6349    let cached = sqlite_graph_counts_from_cache(conn, scope)?;
6350    let counts = match cached.clone() {
6351        Some(counts) => counts,
6352        None => sqlite_graph_counts(conn, scope)?,
6353    };
6354    let live_rows = counts.nodes + counts.edges;
6355    let file_size = counts.file_size_bytes.unwrap_or(0);
6356    let freelist = counts.freelist_bytes.unwrap_or(0);
6357    let stale_live_tombstones = if cached.is_some() {
6358        0
6359    } else {
6360        let mut live_keys = BTreeSet::new();
6361        if sqlite_table_exists(conn, "graph_nodes")? {
6362            let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
6363            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6364                live_keys.insert(format!("node:{}", row?));
6365            }
6366        }
6367        if sqlite_table_exists(conn, "graph_edges")? {
6368            let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
6369            for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6370                live_keys.insert(format!("edge:{}", row?));
6371            }
6372        }
6373        let mut stale_live_tombstones = 0usize;
6374        let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
6375        for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6376            if live_keys.contains(&row?) {
6377                stale_live_tombstones += 1;
6378            }
6379        }
6380        stale_live_tombstones
6381    };
6382
6383    let mut diagnostics = Vec::new();
6384    if stale_live_tombstones > 0 {
6385        diagnostics.push(format!(
6386            "{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"
6387        ));
6388    }
6389    if counts.tombstones.total > live_rows.max(1) {
6390        let source = if cached.is_some() {
6391            "cached refresh stats"
6392        } else {
6393            "live row scan"
6394        };
6395        diagnostics.push(format!(
6396            "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.",
6397            counts.tombstones.total,
6398            live_rows,
6399            source,
6400            file_size,
6401            freelist,
6402            counts.tombstones.total
6403        ));
6404    }
6405    Ok(diagnostics)
6406}
6407
6408fn sqlite_graph_freshness_from_conn(
6409    conn: &Connection,
6410    scope: &str,
6411) -> Result<GraphDbFreshnessReport> {
6412    if !sqlite_table_exists(conn, "graph_projection_versions")? {
6413        return Ok(GraphDbFreshnessReport {
6414            status: "missing".to_string(),
6415            fail_closed: true,
6416            projection_version: None,
6417            content_hash: None,
6418            source_watermark: None,
6419            diagnostics: vec![
6420                "graph projection metadata table is missing; refresh graph.db before trusting reads"
6421                    .to_string(),
6422            ],
6423        });
6424    }
6425    let version = conn
6426        .query_row(
6427            r#"
6428            SELECT projection_version, content_hash, source_watermark
6429            FROM graph_projection_versions
6430            WHERE scope = ?1
6431            "#,
6432            [scope],
6433            |row| {
6434                Ok((
6435                    row.get::<_, String>(0)?,
6436                    row.get::<_, Option<String>>(1)?,
6437                    row.get::<_, Option<String>>(2)?,
6438                ))
6439            },
6440        )
6441        .optional()?;
6442    let Some((projection_version, content_hash, source_watermark)) = version else {
6443        return Ok(GraphDbFreshnessReport {
6444            status: "missing".to_string(),
6445            fail_closed: true,
6446            projection_version: None,
6447            content_hash: None,
6448            source_watermark: None,
6449            diagnostics: vec![
6450                "graph projection metadata is missing; refresh graph.db before trusting reads"
6451                    .to_string(),
6452            ],
6453        });
6454    };
6455
6456    let mut diagnostics = Vec::new();
6457    if projection_version != GRAPH_PROJECTION_VERSION {
6458        diagnostics.push(format!(
6459            "projection version mismatch: expected {} got {}",
6460            GRAPH_PROJECTION_VERSION, projection_version
6461        ));
6462    }
6463    if content_hash.is_none() {
6464        diagnostics.push("projection content hash is missing".to_string());
6465    }
6466    let fail_closed = !diagnostics.is_empty();
6467    Ok(GraphDbFreshnessReport {
6468        status: if fail_closed { "stale" } else { "current" }.to_string(),
6469        fail_closed,
6470        projection_version: Some(projection_version),
6471        content_hash,
6472        source_watermark,
6473        diagnostics,
6474    })
6475}
6476
6477fn graph_db_operator_next_commands(
6478    root: &Path,
6479    scope: Option<&str>,
6480    include_refresh: bool,
6481) -> Vec<String> {
6482    let mut commands = Vec::new();
6483    if include_refresh {
6484        commands.push(graph_db_refresh_command(root, scope));
6485    }
6486    commands.push(format!(
6487        "tsift graph-db --path {}{} doctor --json",
6488        shell_quote(root.to_string_lossy().as_ref()),
6489        graph_db_scope_arg(scope)
6490    ));
6491    commands.push(format!(
6492        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
6493        shell_quote(root.to_string_lossy().as_ref()),
6494        graph_db_scope_arg(scope)
6495    ));
6496    commands.push(format!(
6497        "tsift convex-sync {}{} --remote-snapshot --apply --json",
6498        shell_quote(root.to_string_lossy().as_ref()),
6499        graph_db_scope_arg(scope)
6500    ));
6501    commands
6502}
6503
6504pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
6505    match recovery {
6506        index::ReadOnlyRecovery::SnapshotFallback => {
6507            "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
6508        }
6509        index::ReadOnlyRecovery::SnapshotFallbackWal => {
6510            "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
6511        }
6512    }
6513}
6514
6515fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
6516    let mut stmt = conn.prepare(sql)?;
6517    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6518    let mut values = BTreeSet::new();
6519    for row in rows {
6520        values.insert(row?);
6521    }
6522    Ok(values)
6523}
6524
6525fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
6526    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
6527    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
6528    let mut columns = BTreeSet::new();
6529    for row in rows {
6530        columns.insert(row?);
6531    }
6532    Ok(columns)
6533}
6534
6535fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6536    let mut diagnostics = Vec::new();
6537    let user_version: i64 =
6538        conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
6539    if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
6540        diagnostics.push(format!(
6541            "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6542        ));
6543    } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
6544        diagnostics.push(format!(
6545            "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
6546        ));
6547    }
6548
6549    let tables = sqlite_string_set(
6550        conn,
6551        "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
6552    )?;
6553    let required_tables = [
6554        (
6555            "graph_nodes",
6556            vec![
6557                "id",
6558                "kind",
6559                "label",
6560                "properties_json",
6561                "provenance_json",
6562                "freshness_json",
6563                "row_hash",
6564                "source_watermark",
6565            ],
6566        ),
6567        (
6568            "graph_edges",
6569            vec![
6570                "edge_key",
6571                "from_id",
6572                "to_id",
6573                "kind",
6574                "properties_json",
6575                "provenance_json",
6576                "freshness_json",
6577                "row_hash",
6578                "source_watermark",
6579            ],
6580        ),
6581        (
6582            "graph_projection_versions",
6583            vec![
6584                "scope",
6585                "projection_version",
6586                "content_hash",
6587                "source_watermark",
6588                "observed_at_unix",
6589            ],
6590        ),
6591        (
6592            "graph_tombstones",
6593            vec!["row_key", "row_kind", "deleted_at_unix"],
6594        ),
6595        ("graph_node_properties", vec!["node_id", "key", "value"]),
6596        ("graph_edge_properties", vec!["edge_key", "key", "value"]),
6597    ];
6598    for (table, required_columns) in required_tables {
6599        if !tables.contains(table) {
6600            diagnostics.push(format!("graph.db schema drift: missing table {table}"));
6601            continue;
6602        }
6603        let columns = sqlite_column_names(conn, table)?;
6604        for column in required_columns {
6605            if !columns.contains(column) {
6606                diagnostics.push(format!(
6607                    "graph.db schema drift: missing column {table}.{column}"
6608                ));
6609            }
6610        }
6611    }
6612
6613    let indexes = sqlite_string_set(
6614        conn,
6615        "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
6616    )?;
6617    for index in [
6618        "idx_graph_nodes_kind",
6619        "idx_graph_edges_from_kind",
6620        "idx_graph_edges_to_kind",
6621        "idx_graph_edges_edge_key",
6622        "idx_graph_node_properties_key_value_node",
6623        "idx_graph_edge_properties_key_value_edge",
6624    ] {
6625        if !indexes.contains(index) {
6626            diagnostics.push(format!("graph.db schema drift: missing index {index}"));
6627        }
6628    }
6629
6630    if tables.contains("graph_edges") {
6631        let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
6632        let rows = stmt.query_map([], |row| {
6633            Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
6634        })?;
6635        let mut fks = BTreeSet::new();
6636        for row in rows {
6637            fks.insert(row?);
6638        }
6639        for expected in [
6640            ("from_id".to_string(), "id".to_string()),
6641            ("to_id".to_string(), "id".to_string()),
6642        ] {
6643            if !fks.contains(&expected) {
6644                diagnostics.push(format!(
6645                    "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
6646                    expected.0, expected.1
6647                ));
6648            }
6649        }
6650    }
6651
6652    Ok(diagnostics)
6653}
6654
6655fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
6656    let mut stmt = conn.prepare(sql)?;
6657    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
6658    let mut diagnostics = Vec::new();
6659    for row in rows {
6660        diagnostics.push(row?);
6661    }
6662    Ok(diagnostics)
6663}
6664
6665fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6666    let mut diagnostics = sqlite_query_diagnostics(
6667        conn,
6668        r#"
6669        SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
6670        FROM graph_nodes
6671        GROUP BY id
6672        HAVING COUNT(*) > 1
6673        ORDER BY id
6674        "#,
6675    )?;
6676    diagnostics.extend(sqlite_query_diagnostics(
6677        conn,
6678        r#"
6679        SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
6680        FROM graph_edges
6681        GROUP BY from_id, to_id, kind
6682        HAVING COUNT(*) > 1
6683        ORDER BY from_id, kind, to_id
6684        "#,
6685    )?);
6686    diagnostics.extend(sqlite_query_diagnostics(
6687        conn,
6688        r#"
6689        SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
6690        FROM graph_edges
6691        GROUP BY edge_key
6692        HAVING COUNT(*) > 1
6693        ORDER BY edge_key
6694        "#,
6695    )?);
6696    Ok(diagnostics)
6697}
6698
6699fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6700    sqlite_query_diagnostics(
6701        conn,
6702        r#"
6703        SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6704        FROM graph_edges e
6705        LEFT JOIN graph_nodes n ON n.id = e.from_id
6706        WHERE n.id IS NULL
6707        UNION ALL
6708        SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
6709        FROM graph_edges e
6710        LEFT JOIN graph_nodes n ON n.id = e.to_id
6711        WHERE n.id IS NULL
6712        ORDER BY 1
6713        "#,
6714    )
6715}
6716
6717fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
6718    let mut diagnostics = Vec::new();
6719    let mut node_stmt = conn.prepare(
6720        "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6721    )?;
6722    let node_rows = node_stmt.query_map([], |row| {
6723        Ok((
6724            row.get::<_, String>(0)?,
6725            row.get::<_, String>(1)?,
6726            row.get::<_, String>(2)?,
6727            row.get::<_, Option<String>>(3)?,
6728        ))
6729    })?;
6730    for row in node_rows {
6731        let (id, properties_json, provenance_json, freshness_json) = row?;
6732        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6733            diagnostics.push(format!(
6734                "graph_nodes {id} properties_json is invalid: {err}"
6735            ));
6736        }
6737        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6738            diagnostics.push(format!(
6739                "graph_nodes {id} provenance_json is invalid: {err}"
6740            ));
6741        }
6742        if let Some(freshness_json) = freshness_json
6743            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6744        {
6745            diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
6746        }
6747    }
6748
6749    let mut edge_stmt = conn.prepare(
6750        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6751    )?;
6752    let edge_rows = edge_stmt.query_map([], |row| {
6753        Ok((
6754            row.get::<_, String>(0)?,
6755            row.get::<_, String>(1)?,
6756            row.get::<_, String>(2)?,
6757            row.get::<_, String>(3)?,
6758            row.get::<_, String>(4)?,
6759            row.get::<_, String>(5)?,
6760            row.get::<_, Option<String>>(6)?,
6761        ))
6762    })?;
6763    for row in edge_rows {
6764        let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
6765            row?;
6766        let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
6767        if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
6768            diagnostics.push(format!(
6769                "graph_edges {edge} properties_json is invalid: {err}"
6770            ));
6771        }
6772        if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
6773            diagnostics.push(format!(
6774                "graph_edges {edge} provenance_json is invalid: {err}"
6775            ));
6776        }
6777        if let Some(freshness_json) = freshness_json
6778            && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
6779        {
6780            diagnostics.push(format!(
6781                "graph_edges {edge} freshness_json is invalid: {err}"
6782            ));
6783        }
6784    }
6785    Ok(diagnostics)
6786}
6787
6788fn sqlite_graph_projection_metadata_diagnostics(
6789    conn: &Connection,
6790    scope: Option<&str>,
6791) -> Result<Vec<String>> {
6792    let mut diagnostics = Vec::new();
6793    let scope_key = scope.unwrap_or("root");
6794    let version = conn
6795        .query_row(
6796            r#"
6797            SELECT projection_version, content_hash, source_watermark
6798            FROM graph_projection_versions
6799            WHERE scope = ?1
6800            "#,
6801            [scope_key],
6802            |row| {
6803                Ok((
6804                    row.get::<_, String>(0)?,
6805                    row.get::<_, Option<String>>(1)?,
6806                    row.get::<_, Option<String>>(2)?,
6807                ))
6808            },
6809        )
6810        .optional()?;
6811    let Some((projection_version, content_hash, _source_watermark)) = version else {
6812        diagnostics.push(format!(
6813            "graph projection metadata is missing for scope {scope_key}"
6814        ));
6815        return Ok(diagnostics);
6816    };
6817    if projection_version != GRAPH_PROJECTION_VERSION {
6818        diagnostics.push(format!(
6819            "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
6820        ));
6821    }
6822    if content_hash.is_none() {
6823        diagnostics.push("projection content hash is missing".to_string());
6824    }
6825
6826    let meta_id = graph_projection_meta_id(scope);
6827    let meta_properties = conn
6828        .query_row(
6829            "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
6830            (&meta_id, GRAPH_PROJECTION_META_KIND),
6831            |row| row.get::<_, String>(0),
6832        )
6833        .optional()?;
6834    let Some(meta_properties) = meta_properties else {
6835        diagnostics.push(format!("projection_meta node {meta_id} is missing"));
6836        return Ok(diagnostics);
6837    };
6838    let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
6839        .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
6840    if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
6841        diagnostics.push(format!(
6842            "projection_meta node {meta_id} has stale projection_version"
6843        ));
6844    }
6845    if properties.get("content_hash") != content_hash.as_ref() {
6846        diagnostics.push(format!(
6847            "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
6848        ));
6849    }
6850    Ok(diagnostics)
6851}
6852
6853pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
6854    let mut node_stmt = conn.prepare(
6855        "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
6856    )?;
6857    let node_rows = node_stmt.query_map([], |row| {
6858        let properties_json: String = row.get(3)?;
6859        let provenance_json: String = row.get(4)?;
6860        let freshness_json: Option<String> = row.get(5)?;
6861        Ok((
6862            row.get::<_, String>(0)?,
6863            row.get::<_, String>(1)?,
6864            row.get::<_, String>(2)?,
6865            properties_json,
6866            provenance_json,
6867            freshness_json,
6868        ))
6869    })?;
6870    let mut nodes = Vec::new();
6871    for row in node_rows {
6872        let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
6873        nodes.push(ConvexNodeRow {
6874            external_id,
6875            kind,
6876            label,
6877            properties: serde_json::from_str(&properties_json)?,
6878            provenance: serde_json::from_str(&provenance_json)?,
6879            freshness: freshness_json
6880                .map(|value| serde_json::from_str(&value))
6881                .transpose()?,
6882        });
6883    }
6884
6885    let mut edge_stmt = conn.prepare(
6886        "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
6887    )?;
6888    let edge_rows = edge_stmt.query_map([], |row| {
6889        let properties_json: String = row.get(4)?;
6890        let provenance_json: String = row.get(5)?;
6891        let freshness_json: Option<String> = row.get(6)?;
6892        Ok((
6893            row.get::<_, String>(0)?,
6894            row.get::<_, String>(1)?,
6895            row.get::<_, String>(2)?,
6896            row.get::<_, String>(3)?,
6897            properties_json,
6898            provenance_json,
6899            freshness_json,
6900        ))
6901    })?;
6902    let mut edges = Vec::new();
6903    for row in edge_rows {
6904        let (
6905            edge_key,
6906            from_external_id,
6907            to_external_id,
6908            kind,
6909            properties_json,
6910            provenance_json,
6911            freshness_json,
6912        ) = row?;
6913        edges.push(ConvexEdgeRow {
6914            edge_key,
6915            from_external_id,
6916            to_external_id,
6917            kind,
6918            properties: serde_json::from_str(&properties_json)?,
6919            provenance: serde_json::from_str(&provenance_json)?,
6920            freshness: freshness_json
6921                .map(|value| serde_json::from_str(&value))
6922                .transpose()?,
6923        });
6924    }
6925    Ok(ConvexProjectionRows { nodes, edges })
6926}
6927
6928fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
6929    format!("{}.{}({})", index.table, index.name, index.fields.join(","))
6930}
6931
6932fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
6933    value
6934        .get("indexes")
6935        .or_else(|| value.get("requiredIndexes"))
6936        .or_else(|| {
6937            value
6938                .get("metadata")
6939                .and_then(|metadata| metadata.get("indexes"))
6940        })
6941}
6942
6943fn convex_snapshot_declared_indexes(
6944    value: &serde_json::Value,
6945) -> Result<Option<Vec<ConvexRequiredIndex>>> {
6946    convex_snapshot_index_value(value)
6947        .map(|indexes| {
6948            serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
6949                .context("parsing Convex snapshot index metadata")
6950        })
6951        .transpose()
6952}
6953
6954fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
6955    let required = convex_required_indexes();
6956    let Some(declared) = convex_snapshot_declared_indexes(value)? else {
6957        return Ok(vec![format!(
6958            "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
6959            required
6960                .iter()
6961                .map(convex_required_index_label)
6962                .collect::<Vec<_>>()
6963                .join(", ")
6964        )]);
6965    };
6966    let declared = declared.into_iter().collect::<BTreeSet<_>>();
6967    let missing = required
6968        .iter()
6969        .filter(|index| !declared.contains(*index))
6970        .map(convex_required_index_label)
6971        .collect::<Vec<_>>();
6972    if missing.is_empty() {
6973        Ok(Vec::new())
6974    } else {
6975        Ok(vec![format!(
6976            "Convex snapshot is missing required index metadata: {}",
6977            missing.join(", ")
6978        )])
6979    }
6980}
6981
6982pub(crate) fn load_convex_projection_snapshot_value(
6983    snapshot_path: &Path,
6984) -> Result<(ConvexProjectionRows, serde_json::Value)> {
6985    let content = fs::read_to_string(snapshot_path).with_context(|| {
6986        format!(
6987            "reading Convex projection snapshot {}",
6988            snapshot_path.display()
6989        )
6990    })?;
6991    let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
6992        format!(
6993            "parsing Convex projection snapshot {}",
6994            snapshot_path.display()
6995        )
6996    })?;
6997    let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
6998        .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
6999    Ok((rows, value))
7000}
7001
7002pub(crate) fn append_sqlite_graph_doctor_checks(
7003    report: &mut GraphDbDoctorReport,
7004    root: &Path,
7005    scope: Option<&str>,
7006    graph_db: &Path,
7007) -> Option<substrate::SqliteReadOnlyConnection> {
7008    let rebuild = graph_db_rebuild_command(root, scope);
7009    let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7010    if !graph_db.exists() {
7011        report.push_check(graph_db_doctor_check(
7012            "sqlite_graph_db_exists",
7013            vec![format!("graph.db is missing at {}", graph_db.display())],
7014            vec![rebuild],
7015        ));
7016        return None;
7017    }
7018    report.push_check(graph_db_doctor_check(
7019        "sqlite_graph_db_exists",
7020        Vec::new(),
7021        vec![rebuild.clone()],
7022    ));
7023
7024    let conn = match open_sqlite_graph_db_readonly(graph_db) {
7025        Ok(conn) => conn,
7026        Err(err) => {
7027            report.push_check(graph_db_doctor_check(
7028                "sqlite_graph_db_open",
7029                vec![err.to_string()],
7030                vec![backup_rebuild],
7031            ));
7032            return None;
7033        }
7034    };
7035    report.push_check(graph_db_doctor_check(
7036        "sqlite_graph_db_open",
7037        Vec::new(),
7038        vec![rebuild.clone()],
7039    ));
7040    if let Some(recovery) = conn.recovery() {
7041        report.push_check(GraphDbDoctorCheck {
7042            name: "sqlite_graph_db_read_recovery".to_string(),
7043            status: "recovered".to_string(),
7044            fail_closed: false,
7045            diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7046            repair_commands: Vec::new(),
7047        });
7048    }
7049
7050    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7051        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7052    report.push_check(graph_db_doctor_check(
7053        "sqlite_schema",
7054        schema_diagnostics,
7055        vec![backup_rebuild.clone()],
7056    ));
7057
7058    let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7059        .unwrap_or_else(|err| {
7060            vec![format!(
7061                "graph projection metadata inspection failed: {err}"
7062            )]
7063        });
7064    report.push_check(graph_db_doctor_check(
7065        "sqlite_projection_metadata",
7066        metadata_diagnostics,
7067        vec![rebuild.clone()],
7068    ));
7069
7070    let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7071        .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7072    report.push_check(graph_db_doctor_check(
7073        "sqlite_duplicate_ids",
7074        duplicate_diagnostics,
7075        vec![backup_rebuild.clone()],
7076    ));
7077
7078    let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7079        .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7080    report.push_check(graph_db_doctor_check(
7081        "sqlite_orphan_edges",
7082        orphan_diagnostics,
7083        vec![rebuild.clone()],
7084    ));
7085
7086    let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7087        .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7088    report.push_check(graph_db_doctor_check(
7089        "sqlite_row_json",
7090        json_diagnostics,
7091        vec![backup_rebuild],
7092    ));
7093
7094    let tombstone_diagnostics =
7095        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7096            .unwrap_or_else(|err| {
7097                vec![format!(
7098                    "graph tombstone retention inspection failed: {err}"
7099                )]
7100            });
7101    report.push_check(GraphDbDoctorCheck {
7102        name: "sqlite_tombstone_retention".to_string(),
7103        status: if tombstone_diagnostics.is_empty() {
7104            "ok".to_string()
7105        } else {
7106            "warning".to_string()
7107        },
7108        fail_closed: false,
7109        diagnostics: tombstone_diagnostics,
7110        repair_commands: Vec::new(),
7111    });
7112    let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7113        Ok(counts) => {
7114            let policy = graph_db_compaction_policy(root, scope, &counts, false);
7115            GraphDbDoctorCheck {
7116                name: "sqlite_compaction_policy".to_string(),
7117                status: policy.status.clone(),
7118                fail_closed: false,
7119                diagnostics: policy.proof,
7120                repair_commands: if policy.status == "recommended" {
7121                    policy.recommendations
7122                } else {
7123                    Vec::new()
7124                },
7125            }
7126        }
7127        Err(err) => GraphDbDoctorCheck {
7128            name: "sqlite_compaction_policy".to_string(),
7129            status: "warning".to_string(),
7130            fail_closed: false,
7131            diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7132            repair_commands: Vec::new(),
7133        },
7134    };
7135    report.push_check(compaction_check);
7136
7137    Some(conn)
7138}
7139
7140pub(crate) fn append_convex_snapshot_doctor_checks(
7141    report: &mut GraphDbDoctorReport,
7142    root: &Path,
7143    scope: Option<&str>,
7144    local_rows: Option<&ConvexProjectionRows>,
7145    snapshot_path: Option<&Path>,
7146) {
7147    let repair = convex_refresh_command(root, scope);
7148    let Some(snapshot_path) = snapshot_path else {
7149        report.push_check(graph_db_doctor_check(
7150            "convex_snapshot_present",
7151            vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7152            vec![format!(
7153                "tsift convex-sync {}{} --json > convex-rows.json",
7154                shell_quote(root.to_string_lossy().as_ref()),
7155                graph_db_scope_arg(scope)
7156            )],
7157        ));
7158        return;
7159    };
7160    report.push_check(graph_db_doctor_check(
7161        "convex_snapshot_present",
7162        Vec::new(),
7163        vec![repair.clone()],
7164    ));
7165
7166    let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7167        Ok(snapshot) => snapshot,
7168        Err(err) => {
7169            report.push_check(graph_db_doctor_check(
7170                "convex_snapshot_parse",
7171                vec![err.to_string()],
7172                vec![repair],
7173            ));
7174            return;
7175        }
7176    };
7177    report.push_check(graph_db_doctor_check(
7178        "convex_snapshot_parse",
7179        Vec::new(),
7180        vec![repair.clone()],
7181    ));
7182
7183    let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7184    report.push_check(graph_db_doctor_check(
7185        "convex_snapshot_rows",
7186        row_diagnostics,
7187        vec![repair.clone()],
7188    ));
7189
7190    let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7191        .unwrap_or_else(|err| vec![err.to_string()]);
7192    report.required_indexes = convex_required_indexes();
7193    report.push_check(graph_db_doctor_check(
7194        "convex_required_indexes",
7195        index_diagnostics,
7196        vec![
7197            "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7198                .to_string(),
7199        ],
7200    ));
7201
7202    if let Some(local_rows) = local_rows {
7203        let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7204        report.push_check(graph_db_doctor_check(
7205            "convex_projection_freshness",
7206            freshness.diagnostics,
7207            vec![repair],
7208        ));
7209    } else {
7210        report.push_check(graph_db_doctor_check(
7211            "convex_projection_freshness",
7212            vec![
7213                "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7214                    .to_string(),
7215            ],
7216            vec![graph_db_rebuild_command(root, scope)],
7217        ));
7218    }
7219}
7220
7221fn graph_db_convex_snapshot_doctor_command(
7222    root: &Path,
7223    scope: Option<&str>,
7224    snapshot_path: &Path,
7225) -> String {
7226    format!(
7227        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7228        shell_quote(root.to_string_lossy().as_ref()),
7229        graph_db_scope_arg(scope),
7230        shell_quote(snapshot_path.to_string_lossy().as_ref())
7231    )
7232}
7233
7234fn graph_db_convex_snapshot_read_command(
7235    root: &Path,
7236    scope: Option<&str>,
7237    snapshot_path: &Path,
7238) -> String {
7239    format!(
7240        "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7241        shell_quote(root.to_string_lossy().as_ref()),
7242        graph_db_scope_arg(scope),
7243        shell_quote(snapshot_path.to_string_lossy().as_ref())
7244    )
7245}
7246
7247fn convex_sync_snapshot_diff_command(
7248    root: &Path,
7249    scope: Option<&str>,
7250    snapshot_path: &Path,
7251) -> String {
7252    format!(
7253        "tsift convex-sync {}{} --snapshot {} --json",
7254        shell_quote(root.to_string_lossy().as_ref()),
7255        graph_db_scope_arg(scope),
7256        shell_quote(snapshot_path.to_string_lossy().as_ref())
7257    )
7258}
7259
7260pub(crate) struct GraphDbDriftInput<'a> {
7261    root: &'a Path,
7262    scope: Option<&'a str>,
7263    graph_db: &'a Path,
7264    snapshot_path: &'a Path,
7265    local: &'a ConvexProjectionRows,
7266    snapshot: &'a ConvexProjectionRows,
7267    snapshot_value: &'a serde_json::Value,
7268    warnings: Vec<String>,
7269}
7270
7271pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7272    let GraphDbDriftInput {
7273        root,
7274        scope,
7275        graph_db,
7276        snapshot_path,
7277        local,
7278        snapshot,
7279        snapshot_value,
7280        warnings,
7281    } = input;
7282    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7283    let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7284        convex_rows_diff(local, Some(snapshot));
7285    let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7286    let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7287        .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7288    let local_hash = freshness.local_hash.clone();
7289    let snapshot_hash = freshness.snapshot_hash.clone();
7290    let stale_nodes = freshness.stale_nodes.clone();
7291    let stale_edges = freshness.stale_edges.clone();
7292
7293    let duplicate_failures = row_diagnostics
7294        .iter()
7295        .filter(|diagnostic| diagnostic.contains("duplicate"))
7296        .count();
7297    let orphan_failures = row_diagnostics
7298        .iter()
7299        .filter(|diagnostic| diagnostic.contains("references missing"))
7300        .count();
7301    let missing_required_indexes = index_diagnostics.len();
7302    let stale_projection_metadata =
7303        usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7304    let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7305    let has_drift = freshness.fail_closed
7306        || !node_upserts.is_empty()
7307        || !edge_upserts.is_empty()
7308        || !node_tombstones.is_empty()
7309        || !edge_tombstones.is_empty();
7310    let status = if hard_failures > 0 {
7311        "fail_closed"
7312    } else if has_drift {
7313        "drift"
7314    } else {
7315        "current"
7316    }
7317    .to_string();
7318
7319    let mut diagnostics = Vec::new();
7320    diagnostics.extend(row_diagnostics);
7321    diagnostics.extend(index_diagnostics);
7322    diagnostics.extend(freshness.diagnostics.clone());
7323    if has_drift {
7324        diagnostics.push(format!(
7325            "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
7326            node_upserts.len(),
7327            edge_upserts.len(),
7328            node_tombstones.len(),
7329            edge_tombstones.len()
7330        ));
7331    }
7332
7333    let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
7334        root,
7335        scope,
7336        snapshot_path,
7337    )];
7338    if status == "current" {
7339        next_commands.push(graph_db_convex_snapshot_read_command(
7340            root,
7341            scope,
7342            snapshot_path,
7343        ));
7344    } else {
7345        next_commands.push(convex_sync_snapshot_diff_command(
7346            root,
7347            scope,
7348            snapshot_path,
7349        ));
7350        next_commands.push(convex_refresh_command(root, scope));
7351    }
7352
7353    GraphDbDriftReport {
7354        root: root.to_string_lossy().to_string(),
7355        scope: scope.map(str::to_string),
7356        graph_db: graph_db.to_string_lossy().to_string(),
7357        convex_snapshot: snapshot_path.to_string_lossy().to_string(),
7358        status: status.clone(),
7359        graph_reads_allowed: status == "current",
7360        projection_version: GRAPH_PROJECTION_VERSION.to_string(),
7361        local_hash,
7362        snapshot_hash,
7363        summary: GraphDbDriftSummary {
7364            node_upserts: node_upserts.len(),
7365            edge_upserts: edge_upserts.len(),
7366            node_tombstones: node_tombstones.len(),
7367            edge_tombstones: edge_tombstones.len(),
7368            stale_nodes: stale_nodes.len(),
7369            stale_edges: stale_edges.len(),
7370            stale_projection_metadata,
7371            duplicate_failures,
7372            orphan_failures,
7373            missing_required_indexes,
7374        },
7375        node_upserts: node_upserts
7376            .into_iter()
7377            .map(|row| row.external_id)
7378            .collect(),
7379        edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
7380        node_tombstones,
7381        edge_tombstones,
7382        stale_nodes,
7383        stale_edges,
7384        diagnostics,
7385        next_commands,
7386        required_indexes: convex_required_indexes(),
7387        warnings,
7388    }
7389}
7390
7391pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
7392    println!(
7393        "graph-db drift status: {} reads_allowed: {}",
7394        report.status, report.graph_reads_allowed
7395    );
7396    println!("graph_db: {}", report.graph_db);
7397    println!("convex_snapshot: {}", report.convex_snapshot);
7398    println!(
7399        "upserts: {} node(s), {} edge(s)",
7400        report.summary.node_upserts, report.summary.edge_upserts
7401    );
7402    println!(
7403        "tombstones: {} node(s), {} edge(s)",
7404        report.summary.node_tombstones, report.summary.edge_tombstones
7405    );
7406    for diagnostic in &report.diagnostics {
7407        println!("diagnostic: {diagnostic}");
7408    }
7409    for command in &report.next_commands {
7410        println!("next: {command}");
7411    }
7412}
7413
7414pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
7415    println!(
7416        "graph-db doctor backend: {} status: {}",
7417        report.backend, report.status
7418    );
7419    println!("graph_db: {}", report.graph_db);
7420    if let Some(snapshot) = &report.convex_snapshot {
7421        println!("convex_snapshot: {snapshot}");
7422    }
7423    for check in &report.checks {
7424        println!("check: {} {}", check.name, check.status);
7425        for diagnostic in &check.diagnostics {
7426            println!("  diagnostic: {diagnostic}");
7427        }
7428    }
7429    for command in &report.repair_commands {
7430        println!("repair: {command}");
7431    }
7432}
7433
7434pub(crate) fn graph_db_operator_report_from_disk(
7435    root: &Path,
7436    scope: Option<&str>,
7437    graph_db: &Path,
7438    operation: &str,
7439    refresh: Option<GraphDbRefreshSummary>,
7440    warnings: Vec<String>,
7441) -> Result<GraphDbOperatorReport> {
7442    if !graph_db.exists() {
7443        let next_commands = graph_db_operator_next_commands(root, scope, true);
7444        let counts = GraphDbOperatorCounts {
7445            nodes: 0,
7446            edges: 0,
7447            tombstones: GraphDbTombstoneCounts {
7448                nodes: 0,
7449                edges: 0,
7450                total: 0,
7451            },
7452            file_size_bytes: None,
7453            freelist_bytes: None,
7454        };
7455        return Ok(GraphDbOperatorReport {
7456            root: root.to_string_lossy().to_string(),
7457            scope: scope.map(str::to_string),
7458            graph_db: graph_db.to_string_lossy().to_string(),
7459            operation: operation.to_string(),
7460            status: "missing".to_string(),
7461            materialized: false,
7462            freshness: GraphDbFreshnessReport {
7463                status: "missing".to_string(),
7464                fail_closed: true,
7465                projection_version: None,
7466                content_hash: None,
7467                source_watermark: None,
7468                diagnostics: vec![
7469                    "graph.db is missing; run graph-db refresh before trusting graph reads"
7470                        .to_string(),
7471                ],
7472            },
7473            readiness: graph_effectiveness_blocked(
7474                "graph_db_missing",
7475                vec![
7476                    "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
7477                ],
7478                next_commands.clone(),
7479            ),
7480            counts: counts.clone(),
7481            refresh,
7482            compaction: graph_db_compaction_policy(root, scope, &counts, false),
7483            recovery: None,
7484            next_commands,
7485            warnings,
7486        });
7487    }
7488
7489    let conn = open_sqlite_graph_db_readonly(graph_db)?;
7490    let recovery = conn.recovery();
7491    let mut warnings = warnings;
7492    if let Some(recovery) = recovery {
7493        warnings.push(graph_db_read_recovery_diagnostic(recovery));
7494    }
7495    let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
7496    let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7497        .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7498    if !schema_diagnostics.is_empty() {
7499        freshness.diagnostics.extend(schema_diagnostics);
7500        freshness.fail_closed = true;
7501        freshness.status = "stale".to_string();
7502    }
7503    let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
7504    let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
7505    warnings.extend(
7506        sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7507            .unwrap_or_else(|err| {
7508                vec![format!(
7509                    "graph tombstone retention inspection failed: {err}"
7510                )]
7511            }),
7512    );
7513    let status = if freshness.fail_closed {
7514        "stale"
7515    } else {
7516        "current"
7517    }
7518    .to_string();
7519
7520    Ok(GraphDbOperatorReport {
7521        root: root.to_string_lossy().to_string(),
7522        scope: scope.map(str::to_string),
7523        graph_db: graph_db.to_string_lossy().to_string(),
7524        operation: operation.to_string(),
7525        status,
7526        materialized: true,
7527        freshness,
7528        readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
7529        compaction: graph_db_compaction_policy(root, scope, &counts, false),
7530        counts,
7531        refresh,
7532        recovery,
7533        next_commands: graph_db_operator_next_commands(root, scope, false),
7534        warnings,
7535    })
7536}
7537
7538fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
7539    println!(
7540        "graph-db {} status: {} materialized: {}",
7541        report.operation, report.status, report.materialized
7542    );
7543    println!("graph_db: {}", report.graph_db);
7544    println!(
7545        "projection: version={} hash={} watermark={}",
7546        report
7547            .freshness
7548            .projection_version
7549            .as_deref()
7550            .unwrap_or("<missing>"),
7551        report
7552            .freshness
7553            .content_hash
7554            .as_deref()
7555            .unwrap_or("<missing>"),
7556        report
7557            .freshness
7558            .source_watermark
7559            .as_deref()
7560            .unwrap_or("<missing>")
7561    );
7562    println!(
7563        "rows: {} node(s), {} edge(s), {} tombstone(s)",
7564        report.counts.nodes, report.counts.edges, report.counts.tombstones.total
7565    );
7566    println!(
7567        "readiness: {} reason: {} fail_closed: {}",
7568        report.readiness.status, report.readiness.reason, report.readiness.fail_closed
7569    );
7570    if let Some(file_size) = report.counts.file_size_bytes {
7571        println!(
7572            "storage: {} byte(s), {} free byte(s)",
7573            file_size,
7574            report.counts.freelist_bytes.unwrap_or(0)
7575        );
7576    }
7577    if let Some(refresh) = &report.refresh {
7578        println!(
7579            "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
7580            refresh.tombstoned_nodes, refresh.tombstoned_edges
7581        );
7582        println!(
7583            "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)",
7584            refresh.upserted_nodes,
7585            refresh.upserted_edges,
7586            refresh.upserted_properties,
7587            refresh.unchanged_nodes,
7588            refresh.unchanged_edges,
7589            refresh.unchanged_properties,
7590            refresh.deleted_properties,
7591            refresh.pruned_tombstones
7592        );
7593    }
7594    println!(
7595        "compaction: {} tombstone_scan_rows={} live_rows={}",
7596        report.compaction.status,
7597        report.compaction.tombstone_scan_rows,
7598        report.compaction.live_rows
7599    );
7600    for proof in &report.compaction.proof {
7601        println!("compaction proof: {proof}");
7602    }
7603    if let Some(recovery) = report.recovery {
7604        println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
7605    }
7606    for diagnostic in &report.freshness.diagnostics {
7607        println!("diagnostic: {diagnostic}");
7608    }
7609    for diagnostic in &report.readiness.diagnostics {
7610        println!("readiness diagnostic: {diagnostic}");
7611    }
7612    for warning in &report.warnings {
7613        println!("warning: {warning}");
7614    }
7615    for command in &report.readiness.next_commands {
7616        println!("readiness next: {command}");
7617    }
7618    for command in &report.next_commands {
7619        println!("next: {command}");
7620    }
7621}
7622
7623pub(crate) fn print_graph_db_operator_report(
7624    report: &GraphDbOperatorReport,
7625    format: OutputFormat,
7626) -> Result<()> {
7627    if format.json_output {
7628        print_json_or_envelope(
7629            report,
7630            &format,
7631            "graph-db",
7632            &report.operation,
7633            ToolEnvelopeSummary {
7634                text: format!(
7635                    "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
7636                    report.operation,
7637                    report.status,
7638                    report.counts.nodes,
7639                    report.counts.edges,
7640                    report.counts.tombstones.total
7641                ),
7642                metrics: vec![
7643                    envelope_metric("operation", &report.operation),
7644                    envelope_metric("status", &report.status),
7645                    envelope_metric("nodes", report.counts.nodes),
7646                    envelope_metric("edges", report.counts.edges),
7647                    envelope_metric("tombstones", report.counts.tombstones.total),
7648                    envelope_metric("compaction", &report.compaction.status),
7649                    envelope_metric("readiness", &report.readiness.status),
7650                ],
7651            },
7652            false,
7653            report.next_commands.clone(),
7654        )
7655    } else {
7656        print_graph_db_operator_human(report);
7657        Ok(())
7658    }
7659}
7660
7661fn status_run_command_without_notes(run: &str) -> &str {
7662    run.split_once("  (")
7663        .map(|(command, _)| command)
7664        .unwrap_or(run)
7665}
7666
7667fn status_summarize_extract_command(run: &str) -> &str {
7668    let run = status_run_command_without_notes(run);
7669    run.split(" && ")
7670        .find(|command| command.contains("summarize --extract"))
7671        .unwrap_or(run)
7672}
7673
7674fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
7675    report
7676        .recommendations
7677        .run
7678        .as_deref()
7679        .filter(|command| command.contains("summarize --extract"))
7680        .map(status_summarize_extract_command)
7681        .unwrap_or("tsift summarize --extract .")
7682        .to_string()
7683}
7684
7685fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
7686    let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7687    readiness.diagnostics.push(format!(
7688        "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
7689    ));
7690    readiness
7691}
7692
7693fn graph_db_semantic_readiness(
7694    root: &Path,
7695    scope: Option<&str>,
7696    semantic_row_count: Option<usize>,
7697) -> GraphEffectivenessReadiness {
7698    if let Some(row_count) = semantic_row_count
7699        && row_count > 0
7700    {
7701        return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
7702    }
7703
7704    let report = match status::check_status(root) {
7705        Ok(report) => report,
7706        Err(err) => {
7707            return graph_effectiveness_blocked(
7708                "status_check_unavailable",
7709                vec![format!(
7710                    "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
7711                )],
7712                vec![graph_db_refresh_command(root, scope)],
7713            );
7714        }
7715    };
7716
7717    match &report.summaries {
7718        status::SummaryStatus::Available {
7719            cached_files,
7720            total_indexed_files,
7721            coverage_pct,
7722            ..
7723        } => {
7724            let mut readiness = graph_effectiveness_ready("semantic_rows_available");
7725            readiness.diagnostics.push(format!(
7726                "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
7727            ));
7728            readiness
7729        }
7730        status::SummaryStatus::None { .. } => {
7731            let summarize = graph_db_status_summarize_command(&report);
7732            let index_command = report
7733                .recommendations
7734                .run
7735                .as_deref()
7736                .filter(|cmd| cmd.contains("index"))
7737                .map(str::to_string);
7738            let mut repair = Vec::new();
7739            if let Some(cmd) = index_command {
7740                repair.push(cmd);
7741            }
7742            repair.push(summarize.clone());
7743            repair.push(graph_db_refresh_command(root, scope));
7744            graph_effectiveness_blocked(
7745                "summary_cache_empty",
7746                vec![format!(
7747                    "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7748                    summarize,
7749                    root.display(),
7750                    graph_db_refresh_command(root, scope)
7751                )],
7752                repair,
7753            )
7754        }
7755        status::SummaryStatus::Unavailable => {
7756            let mut repair: Vec<String> = report
7757                .recommendations
7758                .run
7759                .clone()
7760                .into_iter()
7761                .collect();
7762            let summarize = "tsift summarize --extract .".to_string();
7763            repair.push(summarize);
7764            repair.push(graph_db_refresh_command(root, scope));
7765            graph_effectiveness_blocked(
7766                "summary_cache_unavailable",
7767                vec![
7768                    "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(),
7769                ],
7770                repair,
7771            )
7772        }
7773    }
7774}
7775
7776pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
7777    let report = match status::check_status(root) {
7778        Ok(report) => report,
7779        Err(err) => {
7780            return vec![format!(
7781                "status check unavailable after graph-db refresh: {err:#}"
7782            )];
7783        }
7784    };
7785
7786    let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7787        Some(graph_db_status_summarize_command(&report))
7788    } else {
7789        None
7790    };
7791    let mut warnings = report.reminders;
7792    if matches!(report.summaries, status::SummaryStatus::None { .. }) {
7793        let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
7794        warnings.push(format!(
7795            "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
7796            run,
7797            root.display(),
7798            graph_db_refresh_command(root, scope)
7799        ));
7800    }
7801    dedupe_preserve_order(warnings)
7802}
7803
7804pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
7805    println!(
7806        "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
7807        report.applied, report.pruned_tombstones, report.reclaimed_bytes
7808    );
7809    println!("graph_db: {}", report.graph_db);
7810    println!(
7811        "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7812        report.counts_before.nodes,
7813        report.counts_before.edges,
7814        report.counts_before.tombstones.total,
7815        report.counts_before.file_size_bytes.unwrap_or(0),
7816        report.counts_before.freelist_bytes.unwrap_or(0)
7817    );
7818    println!(
7819        "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
7820        report.counts_after.nodes,
7821        report.counts_after.edges,
7822        report.counts_after.tombstones.total,
7823        report.counts_after.file_size_bytes.unwrap_or(0),
7824        report.counts_after.freelist_bytes.unwrap_or(0)
7825    );
7826    for proof in &report.compaction_after.proof {
7827        println!("proof: {proof}");
7828    }
7829    for warning in &report.warnings {
7830        println!("warning: {warning}");
7831    }
7832    for command in &report.next_commands {
7833        println!("next: {command}");
7834    }
7835}
7836
7837fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
7838    raw.iter()
7839        .map(|value| {
7840            let (key, filter_value) = value
7841                .split_once('=')
7842                .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
7843            let key = key.trim();
7844            let filter_value = filter_value.trim();
7845            if key.is_empty() || filter_value.is_empty() {
7846                bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
7847            }
7848            Ok(GraphDbPropertyFilter {
7849                key: key.to_string(),
7850                value: filter_value.to_string(),
7851            })
7852        })
7853        .collect()
7854}
7855
7856fn graph_db_query_options(
7857    cursor: Option<String>,
7858    limit: Option<usize>,
7859    property_filters: &[String],
7860) -> Result<GraphDbQueryOptions> {
7861    Ok(GraphDbQueryOptions {
7862        cursor,
7863        limit: limit.filter(|limit| *limit > 0),
7864        property_filters: parse_graph_db_property_filters(property_filters)?,
7865    })
7866}
7867
7868fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
7869    GraphQueryOptions {
7870        cursor: options.cursor.clone(),
7871        limit: options.limit,
7872        property_filters: options
7873            .property_filters
7874            .iter()
7875            .map(|filter| GraphPropertyFilter {
7876                key: filter.key.clone(),
7877                value: filter.value.clone(),
7878            })
7879            .collect(),
7880    }
7881}
7882
7883fn graph_db_page_report_from_store(
7884    page: GraphQueryPage,
7885    property_filters: Vec<GraphDbPropertyFilter>,
7886) -> GraphDbPageReport {
7887    GraphDbPageReport {
7888        cursor: page.cursor,
7889        limit: page.limit,
7890        next_cursor: page.next_cursor,
7891        returned_nodes: page.returned_nodes,
7892        returned_edges: page.returned_edges,
7893        truncated: page.truncated,
7894        property_filters,
7895        diagnostics: page.diagnostics,
7896    }
7897}
7898
7899fn graph_db_neighborhood_ranking_gate(
7900    ranked_neighbor_cap: usize,
7901) -> GraphDbNeighborhoodRankingGate {
7902    GraphDbNeighborhoodRankingGate {
7903        status: "held_default_order_unchanged".to_string(),
7904        ranked_output_default: false,
7905        default_order: "stable_node_id".to_string(),
7906        default_change_gate: "community_search_quality_metrics".to_string(),
7907        required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
7908            .iter()
7909            .map(|workload| (*workload).to_string())
7910            .collect(),
7911        required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
7912            .iter()
7913            .map(|metric| (*metric).to_string())
7914            .collect(),
7915        max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
7916        min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
7917        min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
7918        min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
7919        diagnostics: vec![
7920            "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
7921            format!(
7922                "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
7923            ),
7924            "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
7925        ],
7926    }
7927}
7928
7929fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
7930    match limit {
7931        Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
7932        Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
7933    }
7934}
7935
7936fn graph_db_ranked_neighbors(
7937    center_id: &str,
7938    nodes: &[SubstrateGraphNode],
7939    edges: &[SubstrateGraphEdge],
7940    cap: usize,
7941) -> Vec<GraphDbRankedNeighbor> {
7942    resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
7943}
7944
7945fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
7946    center_id: &str,
7947    depth: usize,
7948    edge_kind: Option<&str>,
7949    limit: Option<usize>,
7950    unranked_nodes: &[SubstrateGraphNode],
7951    unranked_edges: &[SubstrateGraphEdge],
7952    store: &S,
7953) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
7954    use std::time::Instant;
7955    let max_nodes = match limit {
7956        Some(0) | None => 200,
7957        Some(n) => n.clamp(10, 500),
7958    };
7959    let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
7960        .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
7961    if let Some(kind) = edge_kind {
7962        options = options.with_edge_kind(kind);
7963    }
7964    let start = Instant::now();
7965    let result = store.ranked_neighborhood(center_id, &options)?;
7966    let latency = start.elapsed().as_micros();
7967    let Some(ranked) = result else {
7968        return Ok(None);
7969    };
7970    let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
7971    let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
7972    let overlap_count = ranked_ids.intersection(&unranked_ids).count();
7973    let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
7974        0.0
7975    } else {
7976        (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
7977    };
7978    let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
7979        let mut name_count = BTreeMap::<&str, usize>::new();
7980        for n in nodes {
7981            *name_count.entry(&n.label).or_default() += 1;
7982        }
7983        name_count.values().filter(|&&c| c > 1).count()
7984    };
7985    let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
7986        if nodes.is_empty() {
7987            return 100.0;
7988        }
7989        let with_handle = nodes
7990            .iter()
7991            .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
7992            .count();
7993        (with_handle as f64 / nodes.len() as f64) * 100.0
7994    };
7995    let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
7996        if nodes.is_empty() {
7997            return 0.0;
7998        }
7999        let semantic_kinds = [
8000            "semantic_concept",
8001            "semantic_entity",
8002            "symbol",
8003            "file",
8004            "source_handle",
8005        ];
8006        let useful = nodes
8007            .iter()
8008            .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8009            .count();
8010        let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8011        let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8012        (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8013            / nodes.len() as f64
8014    };
8015    let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8016        let edge_pairs: Vec<(String, String)> = ranked
8017            .edges
8018            .iter()
8019            .map(|e| (e.from_id.clone(), e.to_id.clone()))
8020            .collect();
8021        let cr = tsift_graph::detect_communities(&edge_pairs);
8022        let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8023        let mut fully_kept = 0usize;
8024        let mut partially_pruned = 0usize;
8025        let mut fully_pruned = 0usize;
8026        let mut pruned_kinds = BTreeSet::new();
8027        let mut pruned_labels = Vec::new();
8028        for comm in &cr.communities {
8029            let kept_in_comm: Vec<&str> = comm
8030                .members
8031                .iter()
8032                .filter(|m| kept_labels.contains(m.name.as_str()))
8033                .map(|m| m.name.as_str())
8034                .collect();
8035            if kept_in_comm.len() == comm.members.len() {
8036                fully_kept += 1;
8037            } else if kept_in_comm.is_empty() {
8038                fully_pruned += 1;
8039                for m in &comm.members {
8040                    if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8041                        pruned_kinds.insert(n.kind.clone());
8042                    }
8043                    pruned_labels.push(m.name.clone());
8044                }
8045            } else {
8046                partially_pruned += 1;
8047            }
8048        }
8049        pruned_labels.truncate(5);
8050        Some(CommunityTruncationSummary {
8051            total_communities: cr.communities.len(),
8052            fully_kept,
8053            partially_pruned,
8054            fully_pruned,
8055            pruned_community_kinds: pruned_kinds.into_iter().collect(),
8056            pruned_community_top_labels: pruned_labels,
8057        })
8058    } else {
8059        None
8060    };
8061    Ok(Some(GraphDbRankedNeighborhoodComparison {
8062        traversal_nodes: ranked.nodes.len(),
8063        traversal_edges: ranked.edges.len(),
8064        pruned_count: ranked.pruned_count,
8065        total_discovered: ranked.total_discovered,
8066        latency_micros: latency,
8067        overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8068        useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8069            / 1000.0,
8070        useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8071            .round()
8072            / 1000.0,
8073        duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8074        duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8075        handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8076        handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8077            / 100.0,
8078        community_truncation_summary,
8079        diagnostics: vec![
8080            format!(
8081                "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8082                ranked.nodes.len(),
8083                ranked.edges.len(),
8084                ranked.pruned_count,
8085                ranked.total_discovered,
8086                latency
8087            ),
8088            format!(
8089                "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8090                overlap_pct,
8091                overlap_count,
8092                unranked_ids.len(),
8093                ranked_ids.len()
8094            ),
8095            "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8096        ],
8097    }))
8098}
8099
8100struct GraphDbBudgetedSubgraph {
8101    nodes: Vec<SubstrateGraphNode>,
8102    edges: Vec<SubstrateGraphEdge>,
8103    report: GraphDbOutputBudgetReport,
8104    truncated: bool,
8105    next_cursor: Option<String>,
8106}
8107
8108const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8109const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8110const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8111
8112fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8113    match limit {
8114        Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8115        Some(limit) => limit
8116            .saturating_mul(320)
8117            .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8118    }
8119}
8120
8121fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8122    if matches!(limit, Some(0) | None) {
8123        return match kind {
8124            "source_handle" => 10,
8125            "worker_context" | "worker_result" => 8,
8126            "semantic_concept" | "semantic_entity" => 10,
8127            "file" | "symbol" | "route" => 12,
8128            _ => 8,
8129        };
8130    }
8131    let base = limit.unwrap_or(0).max(1);
8132    match kind {
8133        "source_handle" => base.saturating_add(4),
8134        "worker_context" | "worker_result" => base.saturating_add(2),
8135        "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8136        "file" | "symbol" | "route" => base.saturating_add(4),
8137        _ => base.saturating_add(1),
8138    }
8139}
8140
8141fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8142    if matches!(limit, Some(0) | None) {
8143        return match kind {
8144            "mentions" | "mentions_concept" | "mentions_entity" => 24,
8145            "semantic_relation" | "calls" | "defines" => 20,
8146            _ => 16,
8147        };
8148    }
8149    let base = limit.unwrap_or(0).max(1);
8150    match kind {
8151        "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8152        "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8153        _ => base.saturating_add(2),
8154    }
8155}
8156
8157fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8158    serde_json::to_vec(value)
8159        .map(|bytes| bytes.len().div_ceil(4).max(1))
8160        .unwrap_or(1)
8161}
8162
8163fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8164    let mut parts = vec![node.kind.clone(), node.label.clone()];
8165    for key in [
8166        "detail",
8167        "description",
8168        "source_ref",
8169        "path",
8170        "source_file",
8171        "source_symbol",
8172        "text_preview",
8173    ] {
8174        if let Some(value) = node.properties.get(key) {
8175            parts.push(value.clone());
8176        }
8177    }
8178    parts.join(" ")
8179}
8180
8181fn graph_db_semantic_scores_for_query(
8182    query: Option<&str>,
8183    nodes: &[SubstrateGraphNode],
8184) -> BTreeMap<String, f64> {
8185    let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8186        return BTreeMap::new();
8187    };
8188    let query_embedding = semantic_embedding(query);
8189    nodes
8190        .iter()
8191        .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8192        .filter_map(|node| {
8193            let embedding = node
8194                .properties
8195                .get("embedding")
8196                .and_then(|value| parse_semantic_embedding_property(value))?;
8197            Some((
8198                node.id.clone(),
8199                semantic_cosine(&query_embedding, &embedding),
8200            ))
8201        })
8202        .collect()
8203}
8204
8205fn graph_db_depth_by_id(
8206    origin_ids: &[String],
8207    edges: &[SubstrateGraphEdge],
8208) -> BTreeMap<String, usize> {
8209    let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8210    for edge in edges {
8211        adjacency
8212            .entry(edge.from_id.clone())
8213            .or_default()
8214            .push(edge.to_id.clone());
8215        adjacency
8216            .entry(edge.to_id.clone())
8217            .or_default()
8218            .push(edge.from_id.clone());
8219    }
8220
8221    let mut depth_by_id = BTreeMap::<String, usize>::new();
8222    let mut queue = VecDeque::<String>::new();
8223    for origin in origin_ids {
8224        if depth_by_id.insert(origin.clone(), 0).is_none() {
8225            queue.push_back(origin.clone());
8226        }
8227    }
8228    while let Some(current) = queue.pop_front() {
8229        let depth = depth_by_id.get(&current).copied().unwrap_or(0);
8230        for next in adjacency.get(&current).into_iter().flatten() {
8231            if depth_by_id.contains_key(next) {
8232                continue;
8233            }
8234            depth_by_id.insert(next.clone(), depth.saturating_add(1));
8235            queue.push_back(next.clone());
8236        }
8237    }
8238    depth_by_id
8239}
8240
8241fn graph_db_source_covered_ids(
8242    nodes: &[SubstrateGraphNode],
8243    edges: &[SubstrateGraphEdge],
8244) -> BTreeSet<String> {
8245    let source_ids = nodes
8246        .iter()
8247        .filter(|node| node.kind == "source_handle")
8248        .map(|node| node.id.as_str())
8249        .collect::<BTreeSet<_>>();
8250    let mut covered = source_ids
8251        .iter()
8252        .map(|id| (*id).to_string())
8253        .collect::<BTreeSet<_>>();
8254    for edge in edges {
8255        if source_ids.contains(edge.from_id.as_str()) {
8256            covered.insert(edge.to_id.clone());
8257        }
8258        if source_ids.contains(edge.to_id.as_str()) {
8259            covered.insert(edge.from_id.clone());
8260        }
8261    }
8262    covered
8263}
8264
8265fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8266    for key in [
8267        "observed_at_unix",
8268        "completed_at_unix",
8269        "created_at_unix",
8270        "started_at_unix",
8271    ] {
8272        if let Some(value) = node.properties.get(key)
8273            && let Ok(epoch) = value.parse::<i64>()
8274        {
8275            return epoch.div_euclid(86_400).clamp(0, 40_000);
8276        }
8277    }
8278    0
8279}
8280
8281fn graph_db_node_kind_score(kind: &str) -> i64 {
8282    match kind {
8283        "source_handle" => 180,
8284        "worker_context" => 170,
8285        "worker_result" => 160,
8286        "semantic_concept" | "semantic_entity" => 150,
8287        "backlog" | "job_packet" => 130,
8288        "symbol" => 120,
8289        "file" => 110,
8290        "route" => 105,
8291        "session" => 90,
8292        _ => 40,
8293    }
8294}
8295
8296fn graph_db_edge_kind_score(kind: &str) -> i64 {
8297    match kind {
8298        "mentions_concept" | "mentions_entity" => 180,
8299        "semantic_relation" => 170,
8300        "mentions" => 165,
8301        "requests_context" | "scopes_context" | "scopes_source" => 155,
8302        "explains_result" => 150,
8303        "calls" => 145,
8304        "defines" | "handled_by" | "defines_route" => 130,
8305        "contains" | "targets" => 120,
8306        "records_memory_source" | "has_vector_handle" => 115,
8307        _ => 40,
8308    }
8309}
8310
8311fn graph_db_node_usefulness_score(
8312    node: &SubstrateGraphNode,
8313    depth_by_id: &BTreeMap<String, usize>,
8314    semantic_scores: &BTreeMap<String, f64>,
8315    source_covered_ids: &BTreeSet<String>,
8316    origin_ids: &[String],
8317) -> i64 {
8318    if origin_ids.iter().any(|origin| origin == &node.id) {
8319        return 1_000_000;
8320    }
8321    let semantic = semantic_scores
8322        .get(&node.id)
8323        .map(|score| (score.max(0.0) * 1_000.0) as i64)
8324        .unwrap_or(0);
8325    let depth_penalty = depth_by_id
8326        .get(&node.id)
8327        .map(|depth| (*depth as i64).saturating_mul(55))
8328        .unwrap_or(180);
8329    let source_coverage = if source_covered_ids.contains(&node.id)
8330        || node.properties.contains_key("source_ref")
8331        || node.properties.contains_key("path")
8332    {
8333        120
8334    } else {
8335        0
8336    };
8337    graph_db_node_kind_score(&node.kind)
8338        + semantic
8339        + source_coverage
8340        + graph_db_recency_score(node).min(80)
8341        - depth_penalty
8342}
8343
8344fn graph_db_edge_usefulness_score(
8345    edge: &SubstrateGraphEdge,
8346    node_score_by_id: &BTreeMap<String, i64>,
8347    depth_by_id: &BTreeMap<String, usize>,
8348) -> i64 {
8349    let endpoint_score = node_score_by_id
8350        .get(&edge.from_id)
8351        .copied()
8352        .unwrap_or_default()
8353        .max(
8354            node_score_by_id
8355                .get(&edge.to_id)
8356                .copied()
8357                .unwrap_or_default(),
8358        );
8359    let depth_penalty = depth_by_id
8360        .get(&edge.from_id)
8361        .into_iter()
8362        .chain(depth_by_id.get(&edge.to_id))
8363        .min()
8364        .map(|depth| (*depth as i64).saturating_mul(35))
8365        .unwrap_or(140);
8366    graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
8367}
8368
8369fn graph_db_push_drop(
8370    drops: &mut BTreeMap<(String, String, String), usize>,
8371    item: &str,
8372    kind: &str,
8373    reason: &str,
8374) {
8375    *drops
8376        .entry((item.to_string(), kind.to_string(), reason.to_string()))
8377        .or_default() += 1;
8378}
8379
8380fn graph_db_budget_drop_report(
8381    drops: BTreeMap<(String, String, String), usize>,
8382) -> Vec<GraphDbDroppedByBudget> {
8383    drops
8384        .into_iter()
8385        .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
8386            item,
8387            kind,
8388            reason,
8389            dropped,
8390        })
8391        .collect()
8392}
8393
8394fn graph_db_apply_output_budget(
8395    origin_ids: &[String],
8396    semantic_scores: &BTreeMap<String, f64>,
8397    nodes: Vec<SubstrateGraphNode>,
8398    edges: Vec<SubstrateGraphEdge>,
8399    limit: Option<usize>,
8400) -> GraphDbBudgetedSubgraph {
8401    graph_db_apply_output_budget_with_depths_and_cursor(
8402        origin_ids,
8403        semantic_scores,
8404        nodes,
8405        edges,
8406        limit,
8407        None,
8408        None,
8409    )
8410}
8411
8412fn graph_db_apply_output_budget_with_depths_and_cursor(
8413    origin_ids: &[String],
8414    semantic_scores: &BTreeMap<String, f64>,
8415    nodes: Vec<SubstrateGraphNode>,
8416    edges: Vec<SubstrateGraphEdge>,
8417    limit: Option<usize>,
8418    depth_overrides: Option<&BTreeMap<String, usize>>,
8419    cursor: Option<&str>,
8420) -> GraphDbBudgetedSubgraph {
8421    let max_tokens = graph_db_output_token_cap(limit);
8422    let candidate_nodes = nodes.len();
8423    let candidate_edges = edges.len();
8424    let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
8425    if let Some(depth_overrides) = depth_overrides {
8426        for (id, depth) in depth_overrides {
8427            depth_by_id
8428                .entry(id.clone())
8429                .and_modify(|current| *current = (*current).min(*depth))
8430                .or_insert(*depth);
8431        }
8432    }
8433    let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
8434    let node_score_by_id = nodes
8435        .iter()
8436        .map(|node| {
8437            (
8438                node.id.clone(),
8439                graph_db_node_usefulness_score(
8440                    node,
8441                    &depth_by_id,
8442                    semantic_scores,
8443                    &source_covered_ids,
8444                    origin_ids,
8445                ),
8446            )
8447        })
8448        .collect::<BTreeMap<_, _>>();
8449
8450    let mut node_candidates = nodes.iter().collect::<Vec<_>>();
8451    node_candidates.sort_by(|left, right| {
8452        node_score_by_id
8453            .get(&right.id)
8454            .cmp(&node_score_by_id.get(&left.id))
8455            .then_with(|| left.kind.cmp(&right.kind))
8456            .then_with(|| left.label.cmp(&right.label))
8457            .then_with(|| left.id.cmp(&right.id))
8458    });
8459
8460    let cursor_skip = if let Some(cursor) = cursor {
8461        node_candidates
8462            .iter()
8463            .position(|node| node.id == cursor)
8464            .map(|pos| pos.saturating_add(1))
8465            .unwrap_or(0)
8466    } else {
8467        0
8468    };
8469    if cursor_skip > 0 {
8470        node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
8471    }
8472
8473    let mut selected_node_ids = BTreeSet::new();
8474    let mut selected_node_counts = BTreeMap::<String, usize>::new();
8475    let mut estimated_tokens = 0usize;
8476    let mut drops = BTreeMap::<(String, String, String), usize>::new();
8477    for node in &node_candidates {
8478        let kind_count = selected_node_counts
8479            .get(&node.kind)
8480            .copied()
8481            .unwrap_or_default();
8482        if !origin_ids.iter().any(|origin| origin == &node.id)
8483            && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
8484        {
8485            graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
8486            continue;
8487        }
8488        let tokens = graph_db_estimated_tokens(node);
8489        if !origin_ids.iter().any(|origin| origin == &node.id)
8490            && estimated_tokens.saturating_add(tokens) > max_tokens
8491        {
8492            graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
8493            continue;
8494        }
8495        selected_node_ids.insert(node.id.clone());
8496        *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
8497        estimated_tokens = estimated_tokens.saturating_add(tokens);
8498    }
8499
8500    let has_remaining_candidates = node_candidates
8501        .iter()
8502        .any(|node| !selected_node_ids.contains(&node.id));
8503
8504    let mut selected_nodes = nodes
8505        .into_iter()
8506        .filter(|node| selected_node_ids.contains(&node.id))
8507        .collect::<Vec<_>>();
8508
8509    let mut edge_candidates = edges
8510        .iter()
8511        .filter(|edge| {
8512            selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
8513        })
8514        .collect::<Vec<_>>();
8515    let edge_score_by_key = edge_candidates
8516        .iter()
8517        .map(|edge| {
8518            (
8519                graph_db_edge_key(edge),
8520                graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
8521            )
8522        })
8523        .collect::<BTreeMap<_, _>>();
8524    edge_candidates.sort_by(|left, right| {
8525        edge_score_by_key
8526            .get(&graph_db_edge_key(right))
8527            .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
8528            .then_with(|| left.kind.cmp(&right.kind))
8529            .then_with(|| left.from_id.cmp(&right.from_id))
8530            .then_with(|| left.to_id.cmp(&right.to_id))
8531    });
8532
8533    let endpoint_dropped_edges = edges
8534        .iter()
8535        .filter(|edge| {
8536            !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
8537        })
8538        .count();
8539    if endpoint_dropped_edges > 0 {
8540        drops.insert(
8541            (
8542                "edge".to_string(),
8543                "*".to_string(),
8544                "endpoint_node_dropped".to_string(),
8545            ),
8546            endpoint_dropped_edges,
8547        );
8548    }
8549
8550    let mut selected_edge_ids = BTreeSet::new();
8551    let mut selected_edge_counts = BTreeMap::<String, usize>::new();
8552    for edge in edge_candidates {
8553        let kind_count = selected_edge_counts
8554            .get(&edge.kind)
8555            .copied()
8556            .unwrap_or_default();
8557        if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
8558            graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
8559            continue;
8560        }
8561        let tokens = graph_db_estimated_tokens(edge);
8562        if estimated_tokens.saturating_add(tokens) > max_tokens {
8563            graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
8564            continue;
8565        }
8566        selected_edge_ids.insert(graph_db_edge_key(edge));
8567        *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
8568        estimated_tokens = estimated_tokens.saturating_add(tokens);
8569    }
8570
8571    let selected_edges = edges
8572        .into_iter()
8573        .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
8574        .collect::<Vec<_>>();
8575    let dropped_by_budget = graph_db_budget_drop_report(drops);
8576    let truncated = has_remaining_candidates;
8577    let next_cursor = if truncated {
8578        selected_nodes.last().map(|node| node.id.clone())
8579    } else {
8580        None
8581    };
8582    let mut diagnostics = vec![
8583        "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
8584            .to_string(),
8585        format!(
8586            "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
8587            selected_nodes.len(),
8588            candidate_nodes,
8589            selected_edges.len(),
8590            candidate_edges,
8591            max_tokens
8592        ),
8593    ];
8594    if cursor.is_some() {
8595        diagnostics.push(format!(
8596            "cursor skipped {} previously returned candidate(s)",
8597            cursor_skip
8598        ));
8599    }
8600    if next_cursor.is_some() {
8601        diagnostics.push(
8602            "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
8603        );
8604    }
8605    selected_nodes.shrink_to_fit();
8606
8607    GraphDbBudgetedSubgraph {
8608        nodes: selected_nodes,
8609        edges: selected_edges,
8610        report: GraphDbOutputBudgetReport {
8611            max_tokens,
8612            estimated_tokens,
8613            selected_nodes: selected_node_ids.len(),
8614            selected_edges: selected_edge_ids.len(),
8615            candidate_nodes,
8616            candidate_edges,
8617            dropped_by_budget,
8618            diagnostics,
8619        },
8620        truncated,
8621        next_cursor,
8622    }
8623}
8624
8625fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
8626    if edge.id.is_empty() {
8627        substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
8628    } else {
8629        edge.id.clone()
8630    }
8631}
8632
8633fn graph_db_schema() -> GraphDbSchema {
8634    GraphDbSchema {
8635        contract_versions: vec![
8636            GraphDbSchemaContract {
8637                name: "graph_db_evidence",
8638                version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
8639                description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
8640            },
8641            GraphDbSchemaContract {
8642                name: "worker_prompt_packet",
8643                version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
8644                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",
8645            },
8646            GraphDbSchemaContract {
8647                name: "conflict_matrix",
8648                version: CONFLICT_MATRIX_CONTRACT_VERSION,
8649                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",
8650            },
8651            GraphDbSchemaContract {
8652                name: "context_pack_graph_orchestration",
8653                version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
8654                description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
8655            },
8656            GraphDbSchemaContract {
8657                name: "session_review_follow_up",
8658                version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
8659                description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
8660            },
8661            GraphDbSchemaContract {
8662                name: "dispatch_trace",
8663                version: DISPATCH_TRACE_CONTRACT_VERSION,
8664                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",
8665            },
8666            GraphDbSchemaContract {
8667                name: "dependency_dag",
8668                version: DEPENDENCY_DAG_CONTRACT_VERSION,
8669                description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
8670            },
8671        ],
8672        node_fields: vec![
8673            GraphDbSchemaField {
8674                name: "id",
8675                value_type: "string",
8676                description: "Stable provider-neutral node id",
8677            },
8678            GraphDbSchemaField {
8679                name: "kind",
8680                value_type: "string",
8681                description: "Application-defined node family such as file, symbol, or backlog",
8682            },
8683            GraphDbSchemaField {
8684                name: "label",
8685                value_type: "string",
8686                description: "Human-readable label",
8687            },
8688            GraphDbSchemaField {
8689                name: "properties",
8690                value_type: "object<string,string>",
8691                description: "Adapter-specific string properties",
8692            },
8693            GraphDbSchemaField {
8694                name: "provenance",
8695                value_type: "array",
8696                description: "Source system and source reference metadata",
8697            },
8698            GraphDbSchemaField {
8699                name: "freshness",
8700                value_type: "object|null",
8701                description: "Optional content hash and observed timestamp",
8702            },
8703        ],
8704        edge_fields: vec![
8705            GraphDbSchemaField {
8706                name: "id",
8707                value_type: "string",
8708                description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
8709            },
8710            GraphDbSchemaField {
8711                name: "from_id",
8712                value_type: "string",
8713                description: "Source node id",
8714            },
8715            GraphDbSchemaField {
8716                name: "to_id",
8717                value_type: "string",
8718                description: "Target node id",
8719            },
8720            GraphDbSchemaField {
8721                name: "kind",
8722                value_type: "string",
8723                description: "Application-defined edge relation",
8724            },
8725            GraphDbSchemaField {
8726                name: "properties",
8727                value_type: "object<string,string>",
8728                description: "Adapter-specific string properties",
8729            },
8730            GraphDbSchemaField {
8731                name: "provenance",
8732                value_type: "array",
8733                description: "Source system and source reference metadata",
8734            },
8735            GraphDbSchemaField {
8736                name: "freshness",
8737                value_type: "object|null",
8738                description: "Optional content hash and observed timestamp",
8739            },
8740        ],
8741        operations: vec![
8742            GraphDbSchemaOperation {
8743                command: "refresh",
8744                description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
8745            },
8746            GraphDbSchemaOperation {
8747                command: "status",
8748                description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
8749            },
8750            GraphDbSchemaOperation {
8751                command: "doctor",
8752                description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
8753            },
8754            GraphDbSchemaOperation {
8755                command: "drift",
8756                description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
8757            },
8758            GraphDbSchemaOperation {
8759                command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
8760                description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
8761            },
8762            GraphDbSchemaOperation {
8763                command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
8764                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",
8765            },
8766            GraphDbSchemaOperation {
8767                command: "evidence <target> [--depth N] [--limit N]",
8768                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",
8769            },
8770            GraphDbSchemaOperation {
8771                command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
8772                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",
8773            },
8774            GraphDbSchemaOperation {
8775                command: "dispatch-trace [target...] --path <session> [--format json|html]",
8776                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",
8777            },
8778            GraphDbSchemaOperation {
8779                command: "dependency-dag [target...] --path <session>",
8780                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",
8781            },
8782            GraphDbSchemaOperation {
8783                command: "schema",
8784                description: "Return record and operation schemas",
8785            },
8786            GraphDbSchemaOperation {
8787                command: "node <id>",
8788                description: "Return one node by stable id",
8789            },
8790            GraphDbSchemaOperation {
8791                command: "edge <id>",
8792                description: "Return one edge by stable edge id",
8793            },
8794            GraphDbSchemaOperation {
8795                command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8796                description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
8797            },
8798            GraphDbSchemaOperation {
8799                command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
8800                description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
8801            },
8802            GraphDbSchemaOperation {
8803                command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
8804                description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
8805            },
8806            GraphDbSchemaOperation {
8807                command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
8808                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",
8809            },
8810            GraphDbSchemaOperation {
8811                command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
8812                description: "Return the shortest directed path by node id, optionally bounded by hop count",
8813            },
8814        ],
8815    }
8816}
8817
8818pub(crate) fn sqlite_graph_freshness(
8819    store: &SqliteGraphStore,
8820    scope: &str,
8821) -> Result<GraphDbFreshnessReport> {
8822    let version = store.projection_version(scope)?;
8823    let Some(version) = version else {
8824        return Ok(GraphDbFreshnessReport {
8825            status: "missing".to_string(),
8826            fail_closed: true,
8827            projection_version: None,
8828            content_hash: None,
8829            source_watermark: None,
8830            diagnostics: vec![
8831                "graph projection metadata is missing; rebuild the graph before trusting reads"
8832                    .to_string(),
8833            ],
8834        });
8835    };
8836    let mut diagnostics = Vec::new();
8837    let fail_closed =
8838        version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
8839    if version.projection_version != GRAPH_PROJECTION_VERSION {
8840        diagnostics.push(format!(
8841            "projection version mismatch: expected {} got {}",
8842            GRAPH_PROJECTION_VERSION, version.projection_version
8843        ));
8844    }
8845    if version.content_hash.is_none() {
8846        diagnostics.push("projection content hash is missing".to_string());
8847    }
8848    Ok(GraphDbFreshnessReport {
8849        status: if fail_closed { "stale" } else { "current" }.to_string(),
8850        fail_closed,
8851        projection_version: Some(version.projection_version),
8852        content_hash: version.content_hash,
8853        source_watermark: version.source_watermark,
8854        diagnostics,
8855    })
8856}
8857
8858pub(crate) fn convex_graph_freshness(
8859    local: &ConvexProjectionRows,
8860    snapshot: &ConvexProjectionRows,
8861    scope: Option<&str>,
8862) -> GraphDbFreshnessReport {
8863    let freshness = convex_projection_freshness(local, Some(snapshot), scope);
8864    GraphDbFreshnessReport {
8865        status: freshness.status,
8866        fail_closed: freshness.fail_closed,
8867        projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
8868        content_hash: freshness.snapshot_hash,
8869        source_watermark: None,
8870        diagnostics: freshness.diagnostics,
8871    }
8872}
8873
8874pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
8875    let (nodes, edges) = store.graph_counts()?;
8876    let files = store.file_count()?;
8877    Ok(GraphDbFreshnessReport {
8878        status: "current".to_string(),
8879        fail_closed: false,
8880        projection_version: Some("tokensave-readonly".to_string()),
8881        content_hash: None,
8882        source_watermark: Some(store.db_path().to_string_lossy().to_string()),
8883        diagnostics: vec![format!(
8884            "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
8885            nodes, edges, files
8886        )],
8887    })
8888}
8889
8890pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
8891    match TokensaveDb::discover(root) {
8892        Ok(Some(store)) => {
8893            report.push_check(GraphDbDoctorCheck {
8894                name: "tokensave_db_open".to_string(),
8895                status: "ok".to_string(),
8896                fail_closed: false,
8897                diagnostics: vec![format!(
8898                    "opened tokensave database at {}",
8899                    store.db_path().display()
8900                )],
8901                repair_commands: Vec::new(),
8902            });
8903            match (store.node_count(), store.edge_count(), store.file_count()) {
8904                (Ok(nodes), Ok(edges), Ok(files)) => {
8905                    report.push_check(GraphDbDoctorCheck {
8906                        name: "tokensave_counts".to_string(),
8907                        status: "ok".to_string(),
8908                        fail_closed: false,
8909                        diagnostics: vec![format!(
8910                            "tokensave contains {} node(s), {} edge(s), {} file(s)",
8911                            nodes, edges, files
8912                        )],
8913                        repair_commands: Vec::new(),
8914                    });
8915                }
8916                (nodes, edges, files) => {
8917                    report.push_check(graph_db_doctor_check(
8918                        "tokensave_counts",
8919                        vec![format!(
8920                            "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
8921                            nodes.err(),
8922                            edges.err(),
8923                            files.err()
8924                        )],
8925                        Vec::new(),
8926                    ));
8927                }
8928            }
8929        }
8930        Ok(None) => report.push_check(graph_db_doctor_check(
8931            "tokensave_db_exists",
8932            vec![format!(
8933                "tokensave database is missing at {}",
8934                root.join(".tokensave").join("tokensave.db").display()
8935            )],
8936            Vec::new(),
8937        )),
8938        Err(err) => report.push_check(graph_db_doctor_check(
8939            "tokensave_db_open",
8940            vec![err.to_string()],
8941            Vec::new(),
8942        )),
8943    }
8944}
8945
8946pub(crate) fn graph_db_resolve_evidence_target(
8947    store: &impl GraphStore,
8948    target: &str,
8949) -> Result<Option<SubstrateGraphNode>> {
8950    store.resolve_evidence_target(
8951        target,
8952        &[
8953            "backlog",
8954            "job_packet",
8955            "worker_result",
8956            "worker_context",
8957            "source_handle",
8958        ],
8959    )
8960}
8961
8962fn graph_db_reachable_nodes_by_kind(
8963    store: &impl GraphStore,
8964    from_id: &str,
8965    kind: &str,
8966    depth: usize,
8967    limit: usize,
8968) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
8969    store.reachable_nodes_by_kind(from_id, kind, depth, limit)
8970}
8971
8972fn graph_db_evidence_completed_queue_drift_warnings(
8973    store: &impl GraphStore,
8974    target: &SubstrateGraphNode,
8975    worker_results: &[SubstrateGraphNode],
8976) -> Result<Vec<String>> {
8977    let ref_id = target.properties.get("ref_id").map(String::as_str);
8978    let has_completed_result = worker_results.iter().any(|node| {
8979        node.properties.get("status").map(String::as_str) == Some("completed")
8980            && node.properties.get("ref_id").map(String::as_str) == ref_id
8981    });
8982    if !has_completed_result {
8983        return Ok(Vec::new());
8984    }
8985    let active_jobs = store
8986        .nodes_by_kind("job_packet")?
8987        .into_iter()
8988        .filter(|node| {
8989            node.properties.get("ref_id").map(String::as_str) == ref_id
8990                && node.label.starts_with("do #")
8991        })
8992        .collect::<Vec<_>>();
8993    if active_jobs.is_empty() {
8994        return Ok(Vec::new());
8995    }
8996    let repair = match (target.properties.get("path"), ref_id) {
8997        (Some(path), Some(id)) => format!(
8998            "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
8999            shell_quote(path),
9000            shell_quote(id),
9001            shell_quote(id)
9002        ),
9003        _ => {
9004            "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9005        }
9006    };
9007    Ok(vec![format!(
9008        "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",
9009        target.label,
9010        active_jobs.len()
9011    )])
9012}
9013
9014fn graph_db_evidence_next_commands(
9015    root: &Path,
9016    scope: Option<&str>,
9017    target: &SubstrateGraphNode,
9018    worker_context: &[SubstrateGraphNode],
9019    source_handles: &[SubstrateGraphNode],
9020    worker_results: &[SubstrateGraphNode],
9021    semantic_related: &[SubstrateGraphNode],
9022) -> Vec<String> {
9023    let mut commands = BTreeSet::new();
9024    if let Some(expand) = target.properties.get("expand") {
9025        commands.insert(expand.clone());
9026    }
9027    for worker in worker_context {
9028        if let Some(expand) = worker.properties.get("expand") {
9029            commands.insert(expand.clone());
9030        }
9031    }
9032    for source in source_handles {
9033        if let Some(expand) = source.properties.get("expand") {
9034            commands.insert(expand.clone());
9035        }
9036    }
9037    for result in worker_results {
9038        if let Some(expand) = result.properties.get("expand") {
9039            commands.insert(expand.clone());
9040        }
9041    }
9042    for semantic in semantic_related {
9043        if let Some(expand) = semantic.properties.get("expand") {
9044            commands.insert(expand.clone());
9045        }
9046    }
9047    commands.insert(format!(
9048        "tsift graph-db --path {}{} status --json",
9049        shell_quote(root.to_string_lossy().as_ref()),
9050        graph_db_scope_arg(scope)
9051    ));
9052    commands.insert(format!(
9053        "tsift graph-db --path {}{} doctor --json",
9054        shell_quote(root.to_string_lossy().as_ref()),
9055        graph_db_scope_arg(scope)
9056    ));
9057    commands.into_iter().collect()
9058}
9059
9060fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9061    vec![
9062        format!(
9063            "tsift graph-db --path {}{} refresh --json",
9064            shell_quote(root.to_string_lossy().as_ref()),
9065            graph_db_scope_arg(scope)
9066        ),
9067        format!(
9068            "tsift graph-db --path {}{} doctor --json",
9069            shell_quote(root.to_string_lossy().as_ref()),
9070            graph_db_scope_arg(scope)
9071        ),
9072    ]
9073}
9074
9075fn graph_db_evidence_replay_commands(
9076    root: &Path,
9077    scope: Option<&str>,
9078    target: &str,
9079    depth: usize,
9080    limit: usize,
9081) -> Vec<String> {
9082    vec![
9083        format!(
9084            "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9085            shell_quote(root.to_string_lossy().as_ref()),
9086            graph_db_scope_arg(scope),
9087            shell_quote(target),
9088            depth,
9089            limit
9090        ),
9091        format!(
9092            "tsift conflict-matrix --path {} {} --json",
9093            shell_quote(root.to_string_lossy().as_ref()),
9094            shell_quote(target)
9095        ),
9096    ]
9097}
9098
9099fn graph_db_evidence_packet_id(
9100    target: &str,
9101    target_node: &SubstrateGraphNode,
9102    freshness: &GraphDbFreshnessReport,
9103) -> String {
9104    stable_handle(
9105        "gevd",
9106        &format!(
9107            "{}:{}:{}:{}",
9108            GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9109            target,
9110            target_node.id,
9111            freshness.content_hash.as_deref().unwrap_or("no-hash")
9112        ),
9113    )
9114}
9115
9116pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9117    input: GraphDbEvidenceInput<'_, S>,
9118) -> Result<GraphDbEvidenceReport> {
9119    let GraphDbEvidenceInput {
9120        root,
9121        scope,
9122        backend,
9123        target,
9124        depth,
9125        limit,
9126        cursor,
9127        store,
9128        freshness,
9129        mut warnings,
9130    } = input;
9131    let repair_commands = graph_db_repair_commands(root, scope);
9132    if freshness.fail_closed {
9133        bail!(
9134            "graph database evidence failed closed for {} backend: {}; repair: {}",
9135            backend,
9136            freshness.diagnostics.join("; "),
9137            repair_commands.join("; ")
9138        );
9139    }
9140    let semantic_readiness = graph_db_semantic_readiness(
9141        root,
9142        scope,
9143        graph_store_semantic_node_count(store).ok(),
9144    );
9145    if semantic_readiness.fail_closed {
9146        warnings.push(format!(
9147            "graph evidence semantic readiness blocked: {} — {}",
9148            semantic_readiness.reason,
9149            semantic_readiness.diagnostics.join("; ")
9150        ));
9151        warnings.push(format!(
9152            "repair: {}",
9153            semantic_readiness.next_commands.join("; then ")
9154        ));
9155    }
9156    let target_node = graph_db_resolve_evidence_target(store, target)?
9157        .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9158    let max_rows = if limit == 0 { usize::MAX } else { limit };
9159    let mut reachable = store.reachable_nodes_by_kinds(
9160        &target_node.id,
9161        &[
9162            "worker_context",
9163            "source_handle",
9164            "worker_result",
9165            "semantic_concept",
9166            "semantic_entity",
9167        ],
9168        depth,
9169        max_rows,
9170    )?;
9171    let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9172    let source_paths = reachable.remove("source_handle").unwrap_or_default();
9173    let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9174    let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9175    semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9176    semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9177        left_path
9178            .hops
9179            .cmp(&right_path.hops)
9180            .then(left_node.kind.cmp(&right_node.kind))
9181            .then(left_node.label.cmp(&right_node.label))
9182            .then(left_node.id.cmp(&right_node.id))
9183    });
9184    if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9185        semantic_paths.truncate(max_rows);
9186    }
9187
9188    let evidence_nodes = worker_paths
9189        .iter()
9190        .chain(source_paths.iter())
9191        .chain(worker_result_paths.iter())
9192        .chain(semantic_paths.iter())
9193        .map(|(node, _)| node.clone())
9194        .collect::<Vec<_>>();
9195    let evidence_depth_by_id = worker_paths
9196        .iter()
9197        .chain(source_paths.iter())
9198        .chain(worker_result_paths.iter())
9199        .chain(semantic_paths.iter())
9200        .map(|(node, path)| (node.id.clone(), path.hops))
9201        .collect::<BTreeMap<_, _>>();
9202    let target_query = graph_db_node_search_text(&target_node);
9203    let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9204    let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9205        std::slice::from_ref(&target_node.id),
9206        &semantic_scores,
9207        evidence_nodes,
9208        Vec::new(),
9209        Some(limit),
9210        Some(&evidence_depth_by_id),
9211        cursor,
9212    );
9213    let output_budget = budgeted.report;
9214    let truncated = budgeted.truncated;
9215    let next_cursor = budgeted.next_cursor;
9216    let retained_evidence_ids = budgeted
9217        .nodes
9218        .iter()
9219        .map(|node| node.id.as_str())
9220        .collect::<BTreeSet<_>>();
9221    let worker_context = worker_paths
9222        .iter()
9223        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9224        .map(|(node, _)| node.clone())
9225        .collect::<Vec<_>>();
9226    let source_handles = source_paths
9227        .iter()
9228        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9229        .map(|(node, _)| node.clone())
9230        .collect::<Vec<_>>();
9231    let worker_results = worker_result_paths
9232        .iter()
9233        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9234        .map(|(node, _)| node.clone())
9235        .collect::<Vec<_>>();
9236    let semantic_related = semantic_paths
9237        .iter()
9238        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9239        .map(|(node, _)| node.clone())
9240        .collect::<Vec<_>>();
9241    warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
9242        store,
9243        &target_node,
9244        &worker_results,
9245    )?);
9246    if worker_context.is_empty()
9247        && source_handles.is_empty()
9248        && worker_results.is_empty()
9249        && semantic_related.is_empty()
9250    {
9251        warnings.push(format!(
9252            "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",
9253            target, target_node.kind
9254        ));
9255    }
9256    let shortest_paths = worker_paths
9257        .iter()
9258        .chain(source_paths.iter())
9259        .chain(worker_result_paths.iter())
9260        .chain(semantic_paths.iter())
9261        .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9262        .map(|(node, path)| GraphDbEvidencePath {
9263            to: node.id.clone(),
9264            kind: node.kind.clone(),
9265            label: node.label.clone(),
9266            path: Some(path.clone()),
9267            expand: node.properties.get("expand").cloned(),
9268        })
9269        .collect::<Vec<_>>();
9270    let next_commands = graph_db_evidence_next_commands(
9271        root,
9272        scope,
9273        &target_node,
9274        &worker_context,
9275        &source_handles,
9276        &worker_results,
9277        &semantic_related,
9278    );
9279    let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
9280    let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
9281    let projection_hash = freshness.content_hash.clone();
9282
9283    Ok(GraphDbEvidenceReport {
9284        root: root.to_string_lossy().to_string(),
9285        scope: scope.map(str::to_string),
9286        backend: backend.to_string(),
9287        contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
9288        target: target.to_string(),
9289        packet_id,
9290        projection_hash,
9291        freshness,
9292        target_node: target_node.into(),
9293        worker_context: worker_context.into_iter().map(Into::into).collect(),
9294        source_handles: source_handles.into_iter().map(Into::into).collect(),
9295        worker_results: worker_results.into_iter().map(Into::into).collect(),
9296        semantic_related: semantic_related.into_iter().map(Into::into).collect(),
9297        shortest_paths,
9298        output_budget: Some(output_budget),
9299        truncated,
9300        next_cursor,
9301        next_commands,
9302        replay_commands,
9303        repair_commands,
9304        fixture_coverage: GraphDbFixtureCoverage {
9305            test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
9306                .to_string(),
9307            fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
9308            assertions: vec![
9309                "backlog id and job packet handle resolve to graph nodes".to_string(),
9310                "worker_context rows are reachable from queued work".to_string(),
9311                "source_handle rows are reachable through bounded shortest paths".to_string(),
9312                "worker_result rows are reachable from completed or blocked work".to_string(),
9313            ],
9314        },
9315        warnings,
9316    })
9317}
9318
9319fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
9320    println!(
9321        "graph-db evidence backend: {} target: {} [{}] packet:{}",
9322        report.backend, report.target_node.id, report.target_node.kind, report.packet_id
9323    );
9324    let page_info = if report.truncated {
9325        let cursor = report.next_cursor.as_deref().unwrap_or("?");
9326        format!(" (truncated, next_cursor: {cursor})")
9327    } else {
9328        String::new()
9329    };
9330    println!(
9331        "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
9332        report.worker_context.len(),
9333        report.source_handles.len(),
9334        report.worker_results.len(),
9335        report.semantic_related.len(),
9336        report.shortest_paths.len()
9337    );
9338    for path in &report.shortest_paths {
9339        if let Some(graph_path) = &path.path {
9340            println!(
9341                "path: {} hop(s) {}",
9342                graph_path.hops,
9343                graph_path.nodes.join(" -> ")
9344            );
9345        }
9346    }
9347    for command in &report.next_commands {
9348        println!("next: {command}");
9349    }
9350    for warning in &report.warnings {
9351        println!("warning: {warning}");
9352    }
9353}
9354
9355pub(crate) fn print_graph_db_evidence_report(
9356    report: &GraphDbEvidenceReport,
9357    format: OutputFormat,
9358) -> Result<()> {
9359    if format.json_output {
9360        let page_info = if report.truncated {
9361            let cursor = report.next_cursor.as_deref().unwrap_or("?");
9362            format!(" (truncated, next_cursor: {cursor})")
9363        } else {
9364            String::new()
9365        };
9366        print_json_or_envelope(
9367            report,
9368            &format,
9369            "graph-db",
9370            "evidence",
9371            ToolEnvelopeSummary {
9372                text: format!(
9373                    "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}",
9374                    report.target,
9375                    report.worker_context.len(),
9376                    report.source_handles.len(),
9377                    report.worker_results.len(),
9378                    report.semantic_related.len(),
9379                    report.shortest_paths.len()
9380                ),
9381                metrics: vec![
9382                    envelope_metric("backend", &report.backend),
9383                    envelope_metric("worker_context", report.worker_context.len()),
9384                    envelope_metric("source_handles", report.source_handles.len()),
9385                    envelope_metric("worker_results", report.worker_results.len()),
9386                    envelope_metric("semantic_related", report.semantic_related.len()),
9387                    envelope_metric("paths", report.shortest_paths.len()),
9388                ],
9389            },
9390            report.truncated,
9391            report.next_commands.clone(),
9392        )
9393    } else {
9394        print_graph_db_evidence_human(report);
9395        Ok(())
9396    }
9397}
9398
9399pub(crate) fn graph_db_report_from_store(
9400    root: &Path,
9401    scope: Option<&str>,
9402    backend: &str,
9403    query: GraphDbQuery,
9404    store: &impl GraphStore,
9405    freshness: GraphDbFreshnessReport,
9406    warnings: Vec<String>,
9407) -> Result<GraphDbReport> {
9408    if freshness.fail_closed {
9409        bail!(
9410            "graph database read failed closed for {} backend: {}",
9411            backend,
9412            freshness.diagnostics.join("; ")
9413        );
9414    }
9415    let mut report = GraphDbReport {
9416        root: root.to_string_lossy().to_string(),
9417        scope: scope.map(str::to_string),
9418        backend: backend.to_string(),
9419        query: format!("{query:?}"),
9420        freshness,
9421        readiness: None,
9422        schema: None,
9423        node: None,
9424        edge: None,
9425        nodes: Vec::new(),
9426        edges: Vec::new(),
9427        ranked_neighbors: Vec::new(),
9428        semantic_related: Vec::new(),
9429        neighborhood_ranking_gate: None,
9430        ranked_neighborhood_comparison: None,
9431        knowledge_retrieval: None,
9432        output_budget: None,
9433        path: None,
9434        page: None,
9435        warnings,
9436    };
9437
9438    match query {
9439        GraphDbQuery::Refresh => {
9440            bail!("graph-db refresh must be handled by the refresh command path");
9441        }
9442        GraphDbQuery::Status => {
9443            bail!("graph-db status must be handled by the status command path");
9444        }
9445        GraphDbQuery::Doctor => {
9446            bail!("graph-db doctor must be handled by the doctor command path");
9447        }
9448        GraphDbQuery::Drift => {
9449            bail!("graph-db drift must be handled by the drift command path");
9450        }
9451        GraphDbQuery::Compact { .. } => {
9452            bail!("graph-db compact must be handled by the compact command path");
9453        }
9454        GraphDbQuery::BackendEval { .. } => {
9455            bail!("graph-db backend-eval must be handled by the benchmark command path");
9456        }
9457        GraphDbQuery::Evidence { .. } => {
9458            bail!("graph-db evidence must be handled by the evidence command path");
9459        }
9460        GraphDbQuery::Related {
9461            query,
9462            kind,
9463            depth,
9464            seed_limit,
9465            limit,
9466        } => {
9467            let semantic =
9468                semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
9469            let SemanticRelatedReport {
9470                items,
9471                warnings: semantic_warnings,
9472                ..
9473            } = semantic;
9474            let readiness = graph_db_semantic_readiness(
9475                root,
9476                scope,
9477                (!items.is_empty()).then_some(items.len()),
9478            );
9479            report.warnings.extend(semantic_warnings);
9480            let seed_ids = items
9481                .iter()
9482                .map(|item| item.handle.clone())
9483                .collect::<Vec<_>>();
9484            let semantic_scores = items
9485                .iter()
9486                .map(|item| (item.handle.clone(), item.score))
9487                .collect::<BTreeMap<_, _>>();
9488            let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
9489            let seed_count = seed_ids.len();
9490            let mut diagnostics = subgraph.diagnostics;
9491            let budgeted = graph_db_apply_output_budget(
9492                &seed_ids,
9493                &semantic_scores,
9494                subgraph.nodes,
9495                subgraph.edges,
9496                Some(limit),
9497            );
9498            let budget_report = budgeted.report;
9499            let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
9500            diagnostics.extend(budget_report.diagnostics.clone());
9501            diagnostics.extend(readiness.diagnostics.clone());
9502
9503            report.readiness = Some(readiness);
9504            report.semantic_related = items;
9505            if let Some(seed_id) = seed_ids.first() {
9506                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
9507                report.ranked_neighbors = graph_db_ranked_neighbors(
9508                    seed_id,
9509                    &budgeted.nodes,
9510                    &budgeted.edges,
9511                    ranked_neighbor_cap,
9512                );
9513                report.neighborhood_ranking_gate =
9514                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9515            }
9516            report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9517            report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9518            report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
9519                mode: "semantic_seeded_neighborhood".to_string(),
9520                query,
9521                seed_kind: semantic_related_kind_name(kind).to_string(),
9522                seed_limit,
9523                seed_count,
9524                depth,
9525                limit,
9526                node_count: report.nodes.len(),
9527                edge_count: report.edges.len(),
9528                truncated: subgraph.truncated || dropped_by_budget,
9529                traversal: "incident_plus_outgoing_edges".to_string(),
9530                freshness_boundary:
9531                    "semantic rows must come from refreshed summary or tsift-memory graph records"
9532                        .to_string(),
9533                privacy_boundary:
9534                    "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
9535                        .to_string(),
9536                diagnostics,
9537            });
9538            report.output_budget = Some(budget_report);
9539        }
9540        GraphDbQuery::Schema => {
9541            report.schema = Some(graph_db_schema());
9542        }
9543        GraphDbQuery::Node { id } => {
9544            report.node = store.node(&id)?.map(Into::into);
9545        }
9546        GraphDbQuery::Edge { id } => {
9547            report.edge = store.edge(&id)?.map(Into::into);
9548        }
9549        GraphDbQuery::Edges {
9550            edge_kind,
9551            cursor,
9552            limit,
9553            property_filters,
9554        } => {
9555            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9556            let paged = store.paged_edges(
9557                edge_kind.as_deref(),
9558                graph_db_query_options_for_store(&options),
9559            )?;
9560            report.edges = paged.edges.into_iter().map(Into::into).collect();
9561            report.page = Some(graph_db_page_report_from_store(
9562                paged.page,
9563                options.property_filters,
9564            ));
9565        }
9566        GraphDbQuery::Incident {
9567            id,
9568            edge_kind,
9569            cursor,
9570            limit,
9571            property_filters,
9572        } => {
9573            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9574            let paged = store.paged_incident_edges(
9575                &id,
9576                edge_kind.as_deref(),
9577                graph_db_query_options_for_store(&options),
9578            )?;
9579            report.edges = paged.edges.into_iter().map(Into::into).collect();
9580            report.page = Some(graph_db_page_report_from_store(
9581                paged.page,
9582                options.property_filters,
9583            ));
9584        }
9585        GraphDbQuery::Kind {
9586            kind,
9587            cursor,
9588            limit,
9589            property_filters,
9590        } => {
9591            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9592            let paged =
9593                store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
9594            report.nodes = paged.nodes.into_iter().map(Into::into).collect();
9595            report.edges = paged.edges.into_iter().map(Into::into).collect();
9596            report.page = Some(graph_db_page_report_from_store(
9597                paged.page,
9598                options.property_filters,
9599            ));
9600        }
9601        GraphDbQuery::Neighborhood {
9602            id,
9603            depth,
9604            edge_kind,
9605            cursor,
9606            limit,
9607            property_filters,
9608        } => {
9609            let options = graph_db_query_options(cursor, limit, &property_filters)?;
9610            if let Some(paged) = store.paged_neighborhood(
9611                &id,
9612                depth,
9613                edge_kind.as_deref(),
9614                graph_db_query_options_for_store(&options),
9615            )? {
9616                let budgeted = graph_db_apply_output_budget(
9617                    std::slice::from_ref(&id),
9618                    &BTreeMap::new(),
9619                    paged.nodes,
9620                    paged.edges,
9621                    options.limit,
9622                );
9623                let budget_report = budgeted.report;
9624                let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
9625                let ranked_neighbors = graph_db_ranked_neighbors(
9626                    &id,
9627                    &budgeted.nodes,
9628                    &budgeted.edges,
9629                    ranked_neighbor_cap,
9630                );
9631                let comparison = graph_db_ranked_neighborhood_comparison(
9632                    &id,
9633                    depth,
9634                    edge_kind.as_deref(),
9635                    options.limit,
9636                    &budgeted.nodes,
9637                    &budgeted.edges,
9638                    store,
9639                )?;
9640                report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
9641                report.edges = budgeted.edges.into_iter().map(Into::into).collect();
9642                report.ranked_neighbors = ranked_neighbors;
9643                report.neighborhood_ranking_gate =
9644                    Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
9645                let mut page =
9646                    graph_db_page_report_from_store(paged.page, options.property_filters);
9647                page.returned_nodes = report.nodes.len();
9648                page.returned_edges = report.edges.len();
9649                page.truncated |= !budget_report.dropped_by_budget.is_empty();
9650                page.diagnostics.extend(budget_report.diagnostics.clone());
9651                report.page = Some(page);
9652                report.output_budget = Some(budget_report);
9653                if let Some(comparison) = comparison {
9654                    report.ranked_neighborhood_comparison = Some(comparison);
9655                }
9656            }
9657        }
9658        GraphDbQuery::Path {
9659            from,
9660            to,
9661            edge_kind,
9662            max_hops,
9663        } => {
9664            report.path =
9665                store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
9666            if let Some(max_hops) = max_hops
9667                && report.path.is_none()
9668            {
9669                report.warnings.push(format!(
9670                    "no directed path found within --max-hops {}",
9671                    max_hops
9672                ));
9673            }
9674        }
9675        GraphDbQuery::Map { .. } => {
9676            bail!("graph-db map must be handled by the map command path");
9677        }
9678    }
9679    Ok(report)
9680}
9681
9682pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
9683    if compact {
9684        println!(
9685            "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
9686            report.backend,
9687            report.query,
9688            report.nodes.len() + usize::from(report.node.is_some()),
9689            report.edges.len() + usize::from(report.edge.is_some()),
9690            report.freshness.status
9691        );
9692        return;
9693    }
9694    println!("graph-db backend: {}", report.backend);
9695    println!("freshness: {}", report.freshness.status);
9696    if let Some(readiness) = &report.readiness {
9697        println!(
9698            "readiness: {} reason: {} fail_closed: {}",
9699            readiness.status, readiness.reason, readiness.fail_closed
9700        );
9701        for diagnostic in &readiness.diagnostics {
9702            println!("readiness diagnostic: {diagnostic}");
9703        }
9704        for command in &readiness.next_commands {
9705            println!("readiness next: {command}");
9706        }
9707    }
9708    if let Some(schema) = &report.schema {
9709        println!(
9710            "schema: {} node fields, {} edge fields, {} operations",
9711            schema.node_fields.len(),
9712            schema.edge_fields.len(),
9713            schema.operations.len()
9714        );
9715    }
9716    if let Some(node) = &report.node {
9717        println!("node: {} [{}] {}", node.id, node.kind, node.label);
9718    }
9719    if let Some(edge) = &report.edge {
9720        let edge_full: SubstrateGraphEdge = edge.into();
9721        println!(
9722            "edge: {} {} -{}-> {}",
9723            graph_db_edge_key(&edge_full),
9724            edge.from_id,
9725            edge.kind,
9726            edge.to_id
9727        );
9728    }
9729    if let Some(knowledge) = &report.knowledge_retrieval {
9730        println!(
9731            "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
9732            knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
9733        );
9734    }
9735    for item in &report.semantic_related {
9736        println!(
9737            "semantic_seed: {:.3} [{}] {} ({})",
9738            item.score, item.kind, item.label, item.handle
9739        );
9740    }
9741    for node in &report.nodes {
9742        println!("node: {} [{}] {}", node.id, node.kind, node.label);
9743    }
9744    for edge in &report.edges {
9745        let edge_full: SubstrateGraphEdge = edge.into();
9746        println!(
9747            "edge: {} {} -{}-> {}",
9748            graph_db_edge_key(&edge_full),
9749            edge.from_id,
9750            edge.kind,
9751            edge.to_id
9752        );
9753    }
9754    for neighbor in &report.ranked_neighbors {
9755        println!(
9756            "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
9757            neighbor.rank,
9758            neighbor.score,
9759            neighbor
9760                .depth
9761                .map(|depth| depth.to_string())
9762                .unwrap_or_else(|| "unknown".to_string()),
9763            neighbor.node_id,
9764            neighbor.kind,
9765            neighbor.label
9766        );
9767    }
9768    if let Some(gate) = &report.neighborhood_ranking_gate {
9769        println!(
9770            "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
9771            gate.status, gate.default_order, gate.ranked_output_default
9772        );
9773    }
9774    if let Some(path) = &report.path {
9775        println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
9776    }
9777    if let Some(page) = &report.page {
9778        if let Some(next_cursor) = &page.next_cursor {
9779            println!("next_cursor: {next_cursor}");
9780        }
9781        for diagnostic in &page.diagnostics {
9782            println!("page: {diagnostic}");
9783        }
9784    }
9785    for warning in &report.warnings {
9786        println!("warning: {warning}");
9787    }
9788}
9789
9790pub(crate) fn graph_db_backend_eval_phase_timing(
9791    name: &str,
9792    duration_micros: u128,
9793    detail: &str,
9794) -> GraphDbBackendEvalPhaseTiming {
9795    GraphDbBackendEvalPhaseTiming {
9796        name: name.to_string(),
9797        duration_micros,
9798        detail: detail.to_string(),
9799    }
9800}
9801
9802pub(crate) fn graph_db_backend_eval_timed_phase<T>(
9803    phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
9804    name: &str,
9805    detail: &str,
9806    run: impl FnOnce() -> Result<T>,
9807) -> Result<T> {
9808    let started = Instant::now();
9809    let result = run();
9810    phases.push(graph_db_backend_eval_phase_timing(
9811        name,
9812        started.elapsed().as_micros(),
9813        detail,
9814    ));
9815    result
9816}
9817
9818pub(crate) fn graph_db_backend_eval_refresh_total_micros(
9819    phases: &[GraphDbBackendEvalPhaseTiming],
9820) -> u128 {
9821    phases
9822        .iter()
9823        .filter(|phase| phase.name != "conflict_matrix_preparation")
9824        .map(|phase| phase.duration_micros)
9825        .sum()
9826}
9827
9828pub(crate) fn graph_db_backend_eval_cached_refresh(
9829    root: &Path,
9830    scope: Option<&str>,
9831    source_watermark: Option<&str>,
9832) -> Result<
9833    Option<(
9834        TraversalGraphBuild,
9835        SqliteProjectionRefresh,
9836        Vec<GraphDbBackendEvalPhaseTiming>,
9837    )>,
9838> {
9839    let Some(source_watermark) = source_watermark else {
9840        return Ok(None);
9841    };
9842    let graph_db = graph_substrate_db_path(root, scope);
9843    if !graph_db.exists() {
9844        return Ok(None);
9845    }
9846
9847    let started = Instant::now();
9848    let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
9849        Ok(store) => store,
9850        Err(_) => return Ok(None),
9851    };
9852    if store.has_user_triggers().unwrap_or(true) {
9853        return Ok(None);
9854    }
9855    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
9856    if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
9857        return Ok(None);
9858    }
9859
9860    let phases = vec![
9861        graph_db_backend_eval_phase_timing(
9862            "source_graph_build",
9863            started.elapsed().as_micros(),
9864            "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
9865        ),
9866        graph_db_backend_eval_phase_timing(
9867            "projection_rows",
9868            0,
9869            "reused cached provider-neutral projection rows from graph.db",
9870        ),
9871        graph_db_backend_eval_phase_timing(
9872            "sqlite_open",
9873            0,
9874            "reused existing graph.db projection without opening a write transaction",
9875        ),
9876    ];
9877    let refresh = SqliteProjectionRefresh {
9878        scope: scope.unwrap_or("root").to_string(),
9879        projection_version: freshness
9880            .projection_version
9881            .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
9882        source_watermark: Some(source_watermark.to_string()),
9883        tombstoned_nodes: Vec::new(),
9884        tombstoned_edges: Vec::new(),
9885        upserted_nodes: 0,
9886        upserted_edges: 0,
9887        unchanged_nodes: 0,
9888        unchanged_edges: 0,
9889        upserted_properties: 0,
9890        unchanged_properties: 0,
9891        deleted_properties: 0,
9892        deleted_nodes: 0,
9893        deleted_edges: 0,
9894        pruned_tombstones: 0,
9895        file_size_bytes_before: None,
9896        file_size_bytes_after: None,
9897        phase_timings: Vec::new(),
9898    };
9899    Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
9900}
9901
9902pub(crate) fn graph_db_backend_eval_reused_cached_projection(
9903    phases: &[GraphDbBackendEvalPhaseTiming],
9904) -> bool {
9905    phases.iter().any(|phase| {
9906        phase.name == "source_graph_build"
9907            && phase.detail.contains("reused current graph.db projection")
9908    })
9909}
9910
9911pub(crate) fn graph_db_backend_eval_update_source_watermark(
9912    root: &Path,
9913    path_hint: &Path,
9914    scope: Option<&str>,
9915) -> Result<()> {
9916    let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
9917        return Ok(());
9918    };
9919    let graph_db = graph_substrate_db_path(root, scope);
9920    let mut store = SqliteGraphStore::open(&graph_db)?;
9921    store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
9922    Ok(())
9923}
9924
9925pub(crate) fn graph_db_backend_eval_refresh_with_profile(
9926    root: &Path,
9927    path_hint: &Path,
9928    scope: Option<&str>,
9929) -> Result<(
9930    TraversalGraphBuild,
9931    SqliteProjectionRefresh,
9932    Vec<GraphDbBackendEvalPhaseTiming>,
9933)> {
9934    let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
9935    if let Some(cached) =
9936        graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
9937    {
9938        return Ok(cached);
9939    }
9940
9941    let mut phases = Vec::new();
9942    let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
9943        "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"
9944    } else {
9945        "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
9946    };
9947    let source_graph = graph_db_backend_eval_timed_phase(
9948        &mut phases,
9949        "source_graph_build",
9950        source_graph_detail,
9951        || build_traversal_graph_source_with_options(root, path_hint, scope, false),
9952    )?;
9953    let projection = graph_db_backend_eval_timed_phase(
9954        &mut phases,
9955        "projection_rows",
9956        "provider-neutral GraphStore node/edge row construction before SQLite persistence",
9957        || traversal_projection_from_graph(root, scope, &source_graph),
9958    )?;
9959    let graph_db = graph_substrate_db_path(root, scope);
9960    let mut store = graph_db_backend_eval_timed_phase(
9961        &mut phases,
9962        "sqlite_open",
9963        "open the local SQLite graph.db with WAL and busy-timeout settings",
9964        || SqliteGraphStore::open(&graph_db),
9965    )?;
9966    let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
9967        .ok()
9968        .flatten();
9969    let refresh = store.replace_projection_with_version(
9970        scope.unwrap_or("root"),
9971        &projection,
9972        Some(GRAPH_PROJECTION_VERSION),
9973        refreshed_source_watermark
9974            .or(source_watermark)
9975            .or_else(|| graph_projection_content_hash(&projection)),
9976    )?;
9977    phases.extend(
9978        refresh
9979            .phase_timings
9980            .iter()
9981            .map(|phase| GraphDbBackendEvalPhaseTiming {
9982                name: phase.name.clone(),
9983                duration_micros: phase.duration_micros,
9984                detail: phase.detail.clone(),
9985            }),
9986    );
9987    Ok((source_graph, refresh, phases))
9988}
9989
9990fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
9991    root.join(".tsift/backend-eval-cache")
9992}
9993
9994fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
9995    graph_db_backend_eval_disk_cache_dir(root)
9996        .join(kind)
9997        .join(format!("{key}.json.gz"))
9998}
9999
10000fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10001    graph_db_backend_eval_disk_cache_dir(root)
10002        .join(kind)
10003        .join(format!("{key}.json"))
10004}
10005
10006#[derive(Default, Clone)]
10007struct GraphDbBackendEvalDiskCacheReadProfile {
10008    file_read_micros: u128,
10009    gzip_decode_micros: u128,
10010    serde_decode_micros: u128,
10011    legacy: bool,
10012}
10013
10014fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10015    root: &Path,
10016    kind: &str,
10017    key: &str,
10018) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10019    let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10020    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10021    let read_started = Instant::now();
10022    let read_result = fs::read(&path);
10023    profile.file_read_micros = read_started.elapsed().as_micros();
10024    if let Ok(bytes) = read_result {
10025        let decode_started = Instant::now();
10026        let mut decoder = GzDecoder::new(bytes.as_slice());
10027        let mut decoded = Vec::new();
10028        let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10029        profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10030        if decode_ok {
10031            let serde_started = Instant::now();
10032            let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10033            profile.serde_decode_micros = serde_started.elapsed().as_micros();
10034            if let Some(value) = parsed {
10035                return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10036            }
10037        }
10038    }
10039
10040    let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10041    let legacy_started = Instant::now();
10042    let bytes = fs::read(legacy_path).ok()?;
10043    profile.file_read_micros = profile
10044        .file_read_micros
10045        .saturating_add(legacy_started.elapsed().as_micros());
10046    let serde_started = Instant::now();
10047    let value = serde_json::from_slice(&bytes).ok()?;
10048    profile.serde_decode_micros = profile
10049        .serde_decode_micros
10050        .saturating_add(serde_started.elapsed().as_micros());
10051    profile.legacy = true;
10052    Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10053}
10054
10055#[derive(Default, Clone)]
10056struct GraphDbBackendEvalDiskCacheWriteProfile {
10057    serde_encode_micros: u128,
10058    gzip_encode_micros: u128,
10059    file_write_micros: u128,
10060}
10061
10062fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10063    root: &Path,
10064    kind: &str,
10065    key: &str,
10066    value: &T,
10067) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10068    let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10069    let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10070    let parent = path.parent()?;
10071    if fs::create_dir_all(parent).is_err() {
10072        return None;
10073    }
10074    let serde_started = Instant::now();
10075    let bytes = serde_json::to_vec(value).ok()?;
10076    profile.serde_encode_micros = serde_started.elapsed().as_micros();
10077    let gzip_started = Instant::now();
10078    let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10079    if encoder.write_all(&bytes).is_err() {
10080        return None;
10081    }
10082    let encoded = encoder.finish().ok()?;
10083    profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10084    let write_started = Instant::now();
10085    if fs::write(&path, &encoded).is_err() {
10086        return None;
10087    }
10088    profile.file_write_micros = write_started.elapsed().as_micros();
10089    Some((encoded.len() as u64, bytes.len() as u64, profile))
10090}
10091
10092fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10093    let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10094    let Ok(entries) = fs::read_dir(dir) else {
10095        return (0, 0);
10096    };
10097    let keep_name = format!("{keep_key}.json.gz");
10098    let mut pruned_files = 0usize;
10099    let mut pruned_bytes = 0u64;
10100    for entry in entries.flatten() {
10101        let path = entry.path();
10102        if !path.is_file() {
10103            continue;
10104        }
10105        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10106            continue;
10107        };
10108        if name == keep_name {
10109            continue;
10110        }
10111        let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10112        if !is_backend_eval_cache {
10113            continue;
10114        }
10115        let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10116        if fs::remove_file(&path).is_ok() {
10117            pruned_files += 1;
10118            pruned_bytes += bytes;
10119        }
10120    }
10121    (pruned_files, pruned_bytes)
10122}
10123
10124fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10125    root: &Path,
10126    source_root: &Path,
10127) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10128    let mut rows = Vec::new();
10129    let mut entries = walk::walk_files(source_root)?;
10130    entries.sort_by(|left, right| left.path.cmp(&right.path));
10131    for entry in entries {
10132        if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10133            continue;
10134        }
10135        if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10136            continue;
10137        }
10138        let bytes = fs::read(&entry.path)
10139            .with_context(|| format!("reading source input {}", entry.path.display()))?;
10140        rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10141            path: traversal_watermark_path(root, &entry.path),
10142            bytes: bytes.len() as u64,
10143            content_hash: content_hash(&bytes)?,
10144        });
10145    }
10146    Ok(rows)
10147}
10148
10149fn graph_db_backend_eval_full_projection_source_watermark(
10150    root: &Path,
10151    scope: Option<&str>,
10152) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10153    let path_hint = root;
10154    let mut detail_parts = Vec::new();
10155    let mut parts = vec![
10156        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10157        format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10158        "watermark_kind:stable_full_projection_inputs".to_string(),
10159        format!("scope:{}", scope.unwrap_or("root")),
10160        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10161    ];
10162
10163    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10164    match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10165        Some(db_path) => {
10166            let db = index::IndexDb::open_read_only_resilient(db_path)?;
10167            parts.push("index_mode:indexed".to_string());
10168            detail_parts.push("mode=indexed".to_string());
10169            parts.push(format!(
10170                "index_source_root:{}",
10171                traversal_watermark_path(root, &gate.source_root)
10172            ));
10173
10174            let symbols = db
10175                .all_symbols()?
10176                .into_iter()
10177                .filter(|symbol| {
10178                    !traversal_path_is_generated_artifact(
10179                        root,
10180                        &gate.source_root,
10181                        Path::new(&symbol.file),
10182                    ) && !traversal_path_is_session_markdown(
10183                        root,
10184                        &gate.source_root,
10185                        Path::new(&symbol.file),
10186                    )
10187                })
10188                .collect::<Vec<_>>();
10189            let symbols_hash = content_hash(&symbols)?;
10190            detail_parts.push(format!("symbols={symbols_hash}"));
10191            parts.push(format!("index_symbols:{symbols_hash}"));
10192
10193            let edges = db
10194                .all_stored_edges()?
10195                .into_iter()
10196                .filter(|edge| {
10197                    !traversal_path_is_generated_artifact(
10198                        root,
10199                        &gate.source_root,
10200                        Path::new(&edge.caller_file),
10201                    ) && !traversal_path_is_session_markdown(
10202                        root,
10203                        &gate.source_root,
10204                        Path::new(&edge.caller_file),
10205                    )
10206                })
10207                .collect::<Vec<_>>();
10208            let edges_hash = content_hash(&edges)?;
10209            detail_parts.push(format!("call_edges={edges_hash}"));
10210            parts.push(format!("index_call_edges:{edges_hash}"));
10211
10212            let routes = db
10213                .all_routes()?
10214                .into_iter()
10215                .filter(|route| {
10216                    !traversal_path_is_generated_artifact(
10217                        root,
10218                        &gate.source_root,
10219                        Path::new(&route.file),
10220                    ) && !traversal_path_is_session_markdown(
10221                        root,
10222                        &gate.source_root,
10223                        Path::new(&route.file),
10224                    )
10225                })
10226                .collect::<Vec<_>>();
10227            let routes_hash = content_hash(&routes)?;
10228            detail_parts.push(format!("routes={routes_hash}"));
10229            parts.push(format!("index_routes:{routes_hash}"));
10230        }
10231        None => {
10232            parts.push("index_mode:raw_fallback".to_string());
10233            detail_parts.push("mode=raw_fallback".to_string());
10234            parts.push(format!(
10235                "raw_source_root:{}",
10236                traversal_watermark_path(root, &gate.source_root)
10237            ));
10238            let raw_rows =
10239                graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
10240            let raw_hash = content_hash(&raw_rows)?;
10241            detail_parts.push(format!("raw_source_files={raw_hash}"));
10242            parts.push(format!("raw_source_files:{raw_hash}"));
10243        }
10244    }
10245
10246    parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
10247    detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
10248    let summaries_start = parts.len();
10249    push_traversal_summaries_watermark_part(root, &mut parts)?;
10250    let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
10251    detail_parts.push(format!("summaries={summaries_hash}"));
10252    let value = content_hash(&parts)?;
10253    detail_parts.push(format!("watermark={value}"));
10254    Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
10255        value,
10256        detail: detail_parts.join(" "),
10257    })
10258}
10259
10260fn graph_db_backend_eval_full_projection_cache_key(
10261    root: &Path,
10262    scope: Option<&str>,
10263) -> Result<(String, String, String)> {
10264    let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
10265    let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10266        root,
10267        scope,
10268        &source_watermark.value,
10269    )?;
10270    Ok((source_watermark.value, key, source_watermark.detail))
10271}
10272
10273fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
10274    root: &Path,
10275    scope: Option<&str>,
10276    source_watermark: &str,
10277) -> Result<String> {
10278    content_hash(&serde_json::json!({
10279    "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
10280    "root": root.display().to_string(),
10281    "scope": scope.unwrap_or("root"),
10282    "source_watermark": source_watermark,
10283    }))
10284}
10285
10286pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
10287    root: &Path,
10288    scope: Option<&str>,
10289) -> Result<(
10290    GraphProjection,
10291    Vec<String>,
10292    Vec<GraphDbBackendEvalPhaseTiming>,
10293    GraphDbBackendEvalFullProjectionCacheStats,
10294)> {
10295    let (source_watermark, key, source_watermark_detail) =
10296        graph_db_backend_eval_full_projection_cache_key(root, scope)?;
10297    let lookup_started = Instant::now();
10298    if let Some((cached, disk_bytes, json_bytes, read_profile)) =
10299        graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
10300            root,
10301            "full_projection",
10302            &key,
10303        )
10304        && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
10305        && cached.key == key
10306        && cached.source_watermark == source_watermark
10307    {
10308        let lookup_overhead_micros = lookup_started
10309            .elapsed()
10310            .as_micros()
10311            .saturating_sub(read_profile.file_read_micros)
10312            .saturating_sub(read_profile.gzip_decode_micros)
10313            .saturating_sub(read_profile.serde_decode_micros);
10314        let prune_started = Instant::now();
10315        let (pruned_files, pruned_bytes) =
10316            graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
10317        let prune_micros = prune_started.elapsed().as_micros();
10318        let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
10319            hit: true,
10320            disk_bytes,
10321            json_bytes,
10322            pruned_files,
10323            pruned_bytes,
10324        };
10325        let read_detail_suffix = if read_profile.legacy {
10326            " (legacy uncompressed cache path)"
10327        } else {
10328            ""
10329        };
10330        return Ok((
10331            cached.projection,
10332            cached.warnings,
10333            vec![
10334                graph_db_backend_eval_phase_timing(
10335                    "full_projection.cache_lookup",
10336                    lookup_overhead_micros,
10337                    &format!(
10338                        "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
10339                    ),
10340                ),
10341                graph_db_backend_eval_phase_timing(
10342                    "full_projection.cache.file_read",
10343                    read_profile.file_read_micros,
10344                    &format!(
10345                        "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
10346                    ),
10347                ),
10348                graph_db_backend_eval_phase_timing(
10349                    "full_projection.cache.gzip_decode",
10350                    read_profile.gzip_decode_micros,
10351                    "gunzip the compressed projection cache bytes",
10352                ),
10353                graph_db_backend_eval_phase_timing(
10354                    "full_projection.cache.serde_decode",
10355                    read_profile.serde_decode_micros,
10356                    "serde_json deserialize the decoded projection cache payload",
10357                ),
10358                graph_db_backend_eval_phase_timing(
10359                    "full_projection.cache.prune",
10360                    prune_micros,
10361                    "prune sibling cache files older than the current key",
10362                ),
10363                graph_db_backend_eval_phase_timing(
10364                    "full_projection.source_graph_build",
10365                    0,
10366                    "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10367                ),
10368                graph_db_backend_eval_phase_timing(
10369                    "full_projection.projection_rows",
10370                    0,
10371                    "reused cached provider-neutral full-project projection rows",
10372                ),
10373            ],
10374            cache_stats,
10375        ));
10376    }
10377
10378    let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
10379    let mut phases = vec![graph_db_backend_eval_phase_timing(
10380        "full_projection.cache_lookup",
10381        lookup_started.elapsed().as_micros(),
10382        &format!(
10383            "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
10384        ),
10385    )];
10386    let full_source = graph_db_backend_eval_timed_phase(
10387        &mut phases,
10388        "full_projection.source_graph_build",
10389        "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
10390        || build_traversal_graph_source_with_options(root, root, scope, false),
10391    )?;
10392    let projection = graph_db_backend_eval_timed_phase(
10393        &mut phases,
10394        "full_projection.projection_rows",
10395        "provider-neutral row construction for the opt-in full-project projection dataset",
10396        || traversal_projection_from_graph(root, scope, &full_source),
10397    )?;
10398    let warnings = full_source.warnings;
10399    let refreshed_source_watermark =
10400        graph_db_backend_eval_full_projection_source_watermark(root, scope)
10401            .map(|watermark| watermark.value)
10402            .unwrap_or_else(|_| source_watermark.clone());
10403    let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10404        root,
10405        scope,
10406        &refreshed_source_watermark,
10407    )?;
10408    let cache = GraphDbBackendEvalFullProjectionCache {
10409        version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
10410        key: write_key.clone(),
10411        source_watermark: refreshed_source_watermark,
10412        projection: projection.clone(),
10413        warnings: warnings.clone(),
10414    };
10415    if let Some((disk_bytes, json_bytes, write_profile)) =
10416        graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
10417    {
10418        cache_stats.disk_bytes = disk_bytes;
10419        cache_stats.json_bytes = json_bytes;
10420        phases.push(graph_db_backend_eval_phase_timing(
10421            "full_projection.cache.serde_encode",
10422            write_profile.serde_encode_micros,
10423            "serde_json serialize the projection cache payload before compression",
10424        ));
10425        phases.push(graph_db_backend_eval_phase_timing(
10426            "full_projection.cache.gzip_encode",
10427            write_profile.gzip_encode_micros,
10428            "gzip-compress the serialized projection cache payload",
10429        ));
10430        phases.push(graph_db_backend_eval_phase_timing(
10431            "full_projection.cache.file_write",
10432            write_profile.file_write_micros,
10433            "write the compressed projection cache bytes to .tsift/backend-eval-cache",
10434        ));
10435    }
10436    let prune_started = Instant::now();
10437    let (pruned_files, pruned_bytes) =
10438        graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
10439    phases.push(graph_db_backend_eval_phase_timing(
10440        "full_projection.cache.prune",
10441        prune_started.elapsed().as_micros(),
10442        "prune sibling cache files older than the current key",
10443    ));
10444    cache_stats.pruned_files = pruned_files;
10445    cache_stats.pruned_bytes = pruned_bytes;
10446    Ok((projection, warnings, phases, cache_stats))
10447}
10448
10449fn graph_db_backend_eval_timed(
10450    name: &str,
10451    run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
10452) -> (
10453    GraphDbBackendEvalOperation,
10454    Option<GraphDbBackendEvalSignature>,
10455) {
10456    let started = Instant::now();
10457    match run() {
10458        Ok((rows, value)) => (
10459            GraphDbBackendEvalOperation {
10460                name: name.to_string(),
10461                supported: true,
10462                status: "ok".to_string(),
10463                duration_micros: started.elapsed().as_micros(),
10464                rows,
10465                error: None,
10466            },
10467            Some(GraphDbBackendEvalSignature {
10468                operation: name.to_string(),
10469                value,
10470            }),
10471        ),
10472        Err(err) => (
10473            GraphDbBackendEvalOperation {
10474                name: name.to_string(),
10475                supported: false,
10476                status: "error".to_string(),
10477                duration_micros: started.elapsed().as_micros(),
10478                rows: None,
10479                error: Some(format!("{err:#}")),
10480            },
10481            None,
10482        ),
10483    }
10484}
10485
10486fn graph_db_backend_eval_parity(
10487    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10488    candidate_signatures: &[GraphDbBackendEvalSignature],
10489) -> GraphDbBackendEvalParity {
10490    let Some(sqlite_signatures) = sqlite_signatures else {
10491        return GraphDbBackendEvalParity {
10492            matches_sqlite: true,
10493            diagnostics: Vec::new(),
10494        };
10495    };
10496    let sqlite = sqlite_signatures
10497        .iter()
10498        .map(|signature| (signature.operation.as_str(), &signature.value))
10499        .collect::<BTreeMap<_, _>>();
10500    let candidate = candidate_signatures
10501        .iter()
10502        .map(|signature| (signature.operation.as_str(), &signature.value))
10503        .collect::<BTreeMap<_, _>>();
10504    let mut diagnostics = Vec::new();
10505    for (operation, sqlite_value) in sqlite {
10506        match candidate.get(operation) {
10507            Some(candidate_value) if *candidate_value == sqlite_value => {}
10508            Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
10509            None => diagnostics.push(format!(
10510                "{operation} did not complete for candidate backend"
10511            )),
10512        }
10513    }
10514    GraphDbBackendEvalParity {
10515        matches_sqlite: diagnostics.is_empty(),
10516        diagnostics,
10517    }
10518}
10519
10520pub(crate) fn graph_db_backend_eval_targets(
10521    store: &impl GraphStore,
10522    requested: &[String],
10523) -> Result<Vec<String>> {
10524    let requested = requested
10525        .iter()
10526        .filter_map(|target| normalize_conflict_target(target))
10527        .collect::<Vec<_>>();
10528    if !requested.is_empty() {
10529        return Ok(requested);
10530    }
10531
10532    for kind in ["backlog", "job_packet"] {
10533        let nodes = store.nodes_by_kind(kind)?;
10534        if let Some(node) = nodes.first() {
10535            if let Some(ref_id) = node.properties.get("ref_id") {
10536                return Ok(vec![ref_id.clone()]);
10537            }
10538            return Ok(vec![node.id.clone()]);
10539        }
10540    }
10541    Ok(Vec::new())
10542}
10543
10544fn graph_db_backend_eval_path_targets(
10545    store: &impl GraphStore,
10546    max_hops: usize,
10547) -> Result<Option<(String, String, usize)>> {
10548    let synthetic_from = "gsym-synthetic-0000";
10549    let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
10550    if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
10551        let outgoing = store.outgoing_edges(synthetic_from, None)?;
10552        if outgoing.len() > 1
10553            && let Some(edge) = outgoing.first()
10554        {
10555            return Ok(Some((
10556                edge.from_id.clone(),
10557                edge.to_id.clone(),
10558                GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10559            )));
10560        }
10561        return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
10562    }
10563
10564    Ok(store.sample_edge(None)?.map(|edge| {
10565        (
10566            edge.from_id,
10567            edge.to_id,
10568            GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
10569        )
10570    }))
10571}
10572
10573fn graph_db_backend_eval_path_operation<S: GraphStore>(
10574    store: &S,
10575    configured_max_hops: usize,
10576) -> (
10577    GraphDbBackendEvalOperation,
10578    Option<GraphDbBackendEvalSignature>,
10579) {
10580    let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10581        "path_max_hops".to_string()
10582    } else {
10583        format!("path_max_hops_{configured_max_hops}")
10584    };
10585    graph_db_backend_eval_timed(&operation_name, || {
10586        let (from, to, effective_max_hops) =
10587            graph_db_backend_eval_path_targets(store, configured_max_hops)?
10588                .context("backend-eval path probe requires at least one traversable edge")?;
10589        let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
10590        let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
10591            Some(format!(
10592                "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
10593                GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
10594            ))
10595        } else if path.is_none() && effective_max_hops == configured_max_hops {
10596            Some(format!(
10597                "path probe truncated at {configured_max_hops} hops before a route was found"
10598            ))
10599        } else {
10600            None
10601        };
10602        Ok((
10603            path.as_ref().map(|path| path.nodes.len()),
10604            serde_json::json!({
10605                "from": from,
10606                "to": to,
10607                "configured_max_hops": configured_max_hops,
10608                "effective_max_hops": effective_max_hops,
10609                "hops": path.as_ref().map(|path| path.hops),
10610                "nodes": path.as_ref().map(|path| &path.nodes),
10611                "found": path.is_some(),
10612                "warning": warning,
10613            }),
10614        ))
10615    })
10616}
10617
10618fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
10619    store: &S,
10620    depth: usize,
10621    limit: usize,
10622) -> (
10623    GraphDbBackendEvalOperation,
10624    Option<GraphDbBackendEvalSignature>,
10625) {
10626    graph_db_backend_eval_timed("neighborhood", || {
10627        let edge = match store.sample_edge(Some("calls"))? {
10628            Some(edge) => edge,
10629            None => store.sample_edge(None)?.context(
10630                "backend-eval neighborhood probe requires at least one traversable edge",
10631            )?,
10632        };
10633        let page = store
10634            .paged_neighborhood(
10635                &edge.from_id,
10636                depth,
10637                Some(&edge.kind),
10638                GraphQueryOptions {
10639                    limit: Some(limit.max(1)),
10640                    ..GraphQueryOptions::default()
10641                },
10642            )?
10643            .with_context(|| {
10644                format!(
10645                    "backend-eval neighborhood target not found: {}",
10646                    edge.from_id
10647                )
10648            })?;
10649        Ok((
10650            Some(page.nodes.len() + page.edges.len()),
10651            serde_json::json!({
10652                "center": edge.from_id,
10653                "kind": edge.kind,
10654                "depth": depth,
10655                "limit": limit.max(1),
10656                "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10657                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10658                "truncated": page.page.truncated,
10659            }),
10660        ))
10661    })
10662}
10663
10664fn graph_db_backend_eval_related_operation<S: GraphStore>(
10665    root: &Path,
10666    scope: Option<&str>,
10667    store: &S,
10668    depth: usize,
10669    limit: usize,
10670) -> (
10671    GraphDbBackendEvalOperation,
10672    Option<GraphDbBackendEvalSignature>,
10673) {
10674    graph_db_backend_eval_timed("related", || {
10675        let query = "backend evaluation";
10676        let semantic = semantic_related_report_from_store(
10677            root,
10678            scope,
10679            query,
10680            3,
10681            SemanticRelatedKind::All,
10682            store,
10683        )?;
10684        let seed_ids = semantic
10685            .items
10686            .iter()
10687            .map(|item| item.handle.clone())
10688            .collect::<Vec<_>>();
10689        let subgraph =
10690            graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
10691        Ok((
10692            Some(subgraph.nodes.len() + subgraph.edges.len()),
10693            serde_json::json!({
10694                "query": query,
10695                "seed_ids": seed_ids,
10696                "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10697                "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10698                "truncated": subgraph.truncated,
10699                "warnings": semantic.warnings,
10700                "diagnostics": subgraph.diagnostics,
10701            }),
10702        ))
10703    })
10704}
10705
10706fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
10707    serde_json::json!({
10708        "target": report.target,
10709        "target_node_id": report.target_node.id,
10710        "target_kind": report.target_node.kind,
10711        "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
10712        "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
10713        "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
10714        "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
10715        "path_count": report.shortest_paths.len(),
10716    })
10717}
10718
10719fn graph_db_backend_eval_target_resolution_signature(
10720    resolved: &[(String, SubstrateGraphNode)],
10721) -> serde_json::Value {
10722    serde_json::json!({
10723        "targets": resolved.iter().map(|(target, node)| {
10724            serde_json::json!({
10725                "target": target,
10726                "target_node_id": node.id,
10727                "target_kind": node.kind,
10728                "target_label": node.label,
10729            })
10730        }).collect::<Vec<_>>(),
10731    })
10732}
10733
10734fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
10735    serde_json::json!({
10736        "targets": report.targets,
10737        "can_parallel": report.can_parallel,
10738        "fail_closed": report.fail_closed,
10739        "cross_target_parallel_safe": report.cross_target_parallel_safe,
10740        "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
10741        "candidates": report.candidates.iter().map(|candidate| {
10742            serde_json::json!({
10743                "target": candidate.target,
10744                "risk": conflict_risk_label(candidate.risk),
10745                "owned_files": candidate.owned_files,
10746                "owned_symbols": candidate.owned_symbols,
10747                "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
10748                "previously_completed": candidate.previously_completed,
10749                "parallel_safe": candidate.parallel_safe,
10750            })
10751        }).collect::<Vec<_>>(),
10752        "conflicts": report.conflicts.iter().map(|pair| {
10753            serde_json::json!({
10754                "left": pair.left,
10755                "right": pair.right,
10756                "risk": conflict_risk_label(pair.risk),
10757            })
10758        }).collect::<Vec<_>>(),
10759    })
10760}
10761
10762fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
10763    serde_json::json!({
10764        "targets": report.targets,
10765        "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
10766        "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
10767        "evidence_packet_ids": report.evidence_packet_ids,
10768        "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
10769        "truncated": report.truncated,
10770    })
10771}
10772
10773fn graph_db_backend_eval_edge_scan_probe(
10774    store: &impl GraphStore,
10775) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
10776    if let Some((edge, filter)) = store.sample_edge_with_property()? {
10777        return Ok((edge, vec![filter]));
10778    }
10779    let edge = store
10780        .sample_edge(None)?
10781        .context("backend-eval edge scan requires at least one edge")?;
10782    Ok((edge, Vec::new()))
10783}
10784
10785#[allow(clippy::too_many_arguments)]
10786fn graph_db_backend_eval_report_for_store<S: GraphStore>(
10787    backend: &str,
10788    adapter: &str,
10789    read_only: bool,
10790    root: &Path,
10791    path: &Path,
10792    scope: Option<&str>,
10793    targets: &[String],
10794    depth: usize,
10795    limit: usize,
10796    impact_limit: usize,
10797    store: &S,
10798    freshness: GraphDbFreshnessReport,
10799    refresh_operation: GraphDbBackendEvalOperation,
10800    refresh_signature: Option<GraphDbBackendEvalSignature>,
10801    sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
10802    extra_warnings: Vec<String>,
10803    prepared: &ConflictMatrixPreparedInputs,
10804    projection_load: &str,
10805    lock_behavior: &str,
10806    install_portability: &str,
10807) -> (
10808    GraphDbBackendEvalBackendReport,
10809    Vec<GraphDbBackendEvalSignature>,
10810) {
10811    let mut operations = vec![refresh_operation];
10812    let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
10813
10814    let (operation, signature) = graph_db_backend_eval_timed("status", || {
10815        let (nodes, edges) = store.graph_counts()?;
10816        Ok((
10817            Some(nodes + edges),
10818            serde_json::json!({
10819                "freshness": freshness.status,
10820                "nodes": nodes,
10821                "edges": edges,
10822            }),
10823        ))
10824    });
10825    operations.push(operation);
10826    signatures.extend(signature);
10827
10828    let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
10829        let edge = store
10830            .sample_edge(None)?
10831            .context("backend-eval edge lookup requires at least one edge")?;
10832        let edge_id = graph_db_edge_key(&edge);
10833        let found = store
10834            .edge(&edge_id)?
10835            .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
10836        Ok((
10837            Some(1),
10838            serde_json::json!({
10839                "edge_id": edge_id,
10840                "from_id": found.from_id,
10841                "to_id": found.to_id,
10842                "kind": found.kind,
10843            }),
10844        ))
10845    });
10846    operations.push(operation);
10847    signatures.extend(signature);
10848
10849    let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
10850        let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
10851        let page = store.paged_edges(
10852            Some(&edge.kind),
10853            GraphQueryOptions {
10854                limit: Some(limit.max(1)),
10855                property_filters: filters.clone(),
10856                ..GraphQueryOptions::default()
10857            },
10858        )?;
10859        Ok((
10860            Some(page.edges.len()),
10861            serde_json::json!({
10862                "kind": edge.kind,
10863                "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
10864                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10865                "truncated": page.page.truncated,
10866            }),
10867        ))
10868    });
10869    operations.push(operation);
10870    signatures.extend(signature);
10871
10872    let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
10873        let edge = store
10874            .sample_edge(None)?
10875            .context("backend-eval incident edge scan requires at least one edge")?;
10876        let page = store.paged_incident_edges(
10877            &edge.from_id,
10878            Some(&edge.kind),
10879            GraphQueryOptions {
10880                limit: Some(limit.max(1)),
10881                ..GraphQueryOptions::default()
10882            },
10883        )?;
10884        Ok((
10885            Some(page.edges.len()),
10886            serde_json::json!({
10887                "node_id": edge.from_id,
10888                "kind": edge.kind,
10889                "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
10890                "truncated": page.page.truncated,
10891            }),
10892        ))
10893    });
10894    operations.push(operation);
10895    signatures.extend(signature);
10896
10897    let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
10898    operations.push(operation);
10899    signatures.extend(signature);
10900
10901    let (operation, signature) =
10902        graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
10903    operations.push(operation);
10904    signatures.extend(signature);
10905
10906    for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
10907        .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
10908    {
10909        let (operation, signature) =
10910            graph_db_backend_eval_path_operation(store, configured_max_hops);
10911        operations.push(operation);
10912        signatures.extend(signature);
10913    }
10914
10915    let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
10916        let resolved = targets
10917            .iter()
10918            .map(|target| {
10919                let node = graph_db_resolve_evidence_target(store, target)?
10920                    .with_context(|| format!("backend-eval target not found: {target}"))?;
10921                Ok((target.clone(), node))
10922            })
10923            .collect::<Result<Vec<_>>>()?;
10924        let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
10925        Ok((Some(resolved.len()), signature))
10926    });
10927    operations.push(operation);
10928    signatures.extend(signature);
10929
10930    let mut evidence_for_report = None;
10931    let mut graph_snapshot_for_trace = None;
10932    let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
10933        let resolved_targets =
10934            resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
10935        let evidence = collect_conflict_matrix_evidence_packets(
10936            root,
10937            scope,
10938            backend,
10939            &resolved_targets,
10940            depth,
10941            limit,
10942            store,
10943            freshness.clone(),
10944        )?;
10945        let report = &evidence
10946            .first()
10947            .context("backend-eval evidence requires at least one target")?
10948            .report;
10949        let rows = evidence
10950            .iter()
10951            .map(|entry| {
10952                entry.report.worker_context.len()
10953                    + entry.report.source_handles.len()
10954                    + entry.report.worker_results.len()
10955                    + entry.report.semantic_related.len()
10956            })
10957            .sum();
10958        let signature = graph_db_backend_eval_evidence_signature(report);
10959        evidence_for_report = Some((resolved_targets, evidence));
10960        Ok((Some(rows), signature))
10961    });
10962    operations.push(operation);
10963    signatures.extend(signature);
10964
10965    let mut conflict_for_trace = None;
10966    let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
10967        let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
10968            let graph =
10969                conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
10970            let shared_preparation =
10971                conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
10972            ConflictMatrixGraphPreparedInputs {
10973                targets,
10974                graph,
10975                evidence,
10976                shared_preparation,
10977            }
10978        } else {
10979            prepare_conflict_matrix_graph_orchestration(
10980                root,
10981                scope,
10982                backend,
10983                targets,
10984                prepared,
10985                depth,
10986                limit,
10987                store,
10988                freshness.clone(),
10989            )?
10990        };
10991        let report = build_conflict_matrix_report_from_prepared_graph(
10992            root,
10993            path,
10994            scope,
10995            depth,
10996            limit,
10997            impact_limit,
10998            freshness.clone(),
10999            extra_warnings.clone(),
11000            prepared,
11001            &graph_prepared,
11002        )?;
11003        let signature = graph_db_backend_eval_conflict_signature(&report);
11004        let rows = report.candidates.len() + report.conflicts.len();
11005        conflict_for_trace = Some(report);
11006        graph_snapshot_for_trace = Some(graph_prepared.graph);
11007        Ok((Some(rows), signature))
11008    });
11009    operations.push(operation);
11010    signatures.extend(signature);
11011
11012    let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11013        let conflict = conflict_for_trace
11014            .take()
11015            .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11016        let graph = graph_snapshot_for_trace
11017            .take()
11018            .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11019        let report = build_dispatch_trace_report_from_conflict_snapshot(
11020            root,
11021            scope,
11022            conflict,
11023            graph.nodes,
11024            graph.edges,
11025            depth,
11026            limit,
11027            Vec::new(),
11028        )?;
11029        Ok((
11030            Some(report.nodes.len() + report.edges.len()),
11031            graph_db_backend_eval_dispatch_signature(&report),
11032        ))
11033    });
11034    operations.push(operation);
11035    signatures.extend(signature);
11036
11037    let total_micros = operations
11038        .iter()
11039        .map(|operation| operation.duration_micros)
11040        .sum();
11041    let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11042    (
11043        GraphDbBackendEvalBackendReport {
11044            backend: backend.to_string(),
11045            adapter: adapter.to_string(),
11046            read_only,
11047            projection_load: projection_load.to_string(),
11048            operations,
11049            total_micros,
11050            parity,
11051            lock_behavior: lock_behavior.to_string(),
11052            install_portability: install_portability.to_string(),
11053        },
11054        signatures,
11055    )
11056}
11057
11058pub(crate) fn graph_db_backend_eval_refresh_operation(
11059    duration_micros: u128,
11060    rows: usize,
11061    value: serde_json::Value,
11062) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11063    (
11064        GraphDbBackendEvalOperation {
11065            name: "refresh".to_string(),
11066            supported: true,
11067            status: "ok".to_string(),
11068            duration_micros,
11069            rows: Some(rows),
11070            error: None,
11071        },
11072        GraphDbBackendEvalSignature {
11073            operation: "refresh".to_string(),
11074            value,
11075        },
11076    )
11077}
11078
11079pub(crate) fn graph_db_backend_eval_synthetic_projection(
11080    nodes: usize,
11081    fanout: usize,
11082) -> GraphProjection {
11083    let nodes = nodes.max(12);
11084    let symbol_count = nodes.saturating_sub(9).max(1);
11085    let source = GraphProvenance::new("backend-eval", "synthetic");
11086    let mut projection_nodes = vec![
11087        SubstrateGraphNode::new(
11088            "projection:tsift-traversal:synthetic",
11089            GRAPH_PROJECTION_META_KIND,
11090            "synthetic projection",
11091        )
11092        .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11093        .with_property(
11094            "content_hash",
11095            format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11096        )
11097        .with_provenance(source.clone()),
11098        SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11099            .with_property("ref_id", "synthetic-session"),
11100        SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11101            .with_property("ref_id", "synthetic")
11102            .with_property("path", "tasks/software/synthetic.md")
11103            .with_property("line", "1")
11104            .with_property(
11105                "expand",
11106                "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11107            ),
11108        SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11109            .with_property("ref_id", "synthetic"),
11110        SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11111            .with_property("target", "synthetic")
11112            .with_property("summary", "Synthetic worker owns synthetic.rs")
11113            .with_property(
11114                "expand",
11115                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11116            ),
11117        SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11118            .with_property("file", "synthetic.rs")
11119            .with_property("start", "1")
11120            .with_property("end", "80")
11121            .with_property(
11122                "expand",
11123                "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11124            ),
11125        SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11126            .with_property("path", "synthetic.rs"),
11127        SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11128            .with_property("handle", "gsem-synthetic")
11129            .with_property("label", "backend evaluation")
11130            .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11131            .with_property(
11132                "embedding",
11133                semantic_embedding_property("backend evaluation"),
11134            ),
11135        SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11136            .with_property("ref_id", "synthetic")
11137            .with_property("status", "completed")
11138            .with_property("touched_files", "synthetic.rs")
11139            .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11140    ];
11141    for idx in 0..symbol_count {
11142        projection_nodes.push(
11143            SubstrateGraphNode::new(
11144                format!("gsym-synthetic-{idx:04}"),
11145                "symbol",
11146                format!("synthetic_symbol_{idx:04}"),
11147            )
11148            .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11149            .with_property("path", "synthetic.rs")
11150            .with_property("line", (idx + 1).to_string()),
11151        );
11152    }
11153
11154    let mut projection_edges = vec![
11155        SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11156        SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11157        SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11158        SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11159        SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11160        SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11161        SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11162        SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11163        SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11164    ];
11165    for idx in 0..symbol_count {
11166        let from = format!("gsym-synthetic-{idx:04}");
11167        for offset in 1..=fanout.max(1).min(symbol_count) {
11168            let to_idx = (idx + offset) % symbol_count;
11169            if to_idx != idx {
11170                projection_edges.push(SubstrateGraphEdge::new(
11171                    from.clone(),
11172                    format!("gsym-synthetic-{to_idx:04}"),
11173                    "calls",
11174                ));
11175            }
11176        }
11177    }
11178
11179    GraphProjection {
11180        nodes: projection_nodes,
11181        edges: projection_edges
11182            .into_iter()
11183            .map(|edge| {
11184                edge.with_property("dataset", "synthetic")
11185                    .with_provenance(source.clone())
11186            })
11187            .collect(),
11188    }
11189}
11190
11191pub(crate) fn graph_db_backend_eval_promotion(
11192    datasets: &[GraphDbBackendEvalDataset],
11193    candidates: &[GraphDbExperimentalBackend],
11194) -> Vec<GraphDbBackendPromotionDecision> {
11195    let mut decisions = Vec::new();
11196    for candidate in candidates {
11197        let mut reasons = Vec::new();
11198        let mut faster_everywhere = true;
11199        let mut parity_everywhere = true;
11200        for dataset in datasets {
11201            let Some(sqlite_report) = dataset
11202                .backends
11203                .iter()
11204                .find(|backend| backend.backend == "sqlite")
11205            else {
11206                parity_everywhere = false;
11207                faster_everywhere = false;
11208                reasons.push(format!(
11209                    "{} dataset is missing SQLite baseline",
11210                    dataset.name
11211                ));
11212                continue;
11213            };
11214            let sqlite_total = sqlite_report.total_micros;
11215            let Some(candidate_report) = dataset
11216                .backends
11217                .iter()
11218                .find(|backend| backend.backend == candidate.name())
11219            else {
11220                parity_everywhere = false;
11221                reasons.push(format!("{} dataset did not run", dataset.name));
11222                continue;
11223            };
11224            if !candidate_report.parity.matches_sqlite {
11225                parity_everywhere = false;
11226                reasons.push(format!("{} parity differed from SQLite", dataset.name));
11227            }
11228            if candidate_report.total_micros >= sqlite_total {
11229                faster_everywhere = false;
11230                reasons.push(format!(
11231                    "{} total {}us did not beat SQLite {}us",
11232                    dataset.name, candidate_report.total_micros, sqlite_total
11233                ));
11234            }
11235            let sqlite_operations = sqlite_report
11236                .operations
11237                .iter()
11238                .map(|operation| (operation.name.as_str(), operation.duration_micros))
11239                .collect::<BTreeMap<_, _>>();
11240            for operation in &candidate_report.operations {
11241                if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
11242                    && operation.duration_micros >= *sqlite_duration
11243                {
11244                    faster_everywhere = false;
11245                    reasons.push(format!(
11246                        "{} {} operation {}us did not beat SQLite {}us",
11247                        dataset.name, operation.name, operation.duration_micros, sqlite_duration
11248                    ));
11249                }
11250            }
11251            if candidate_report
11252                .operations
11253                .iter()
11254                .any(|operation| operation.status != "ok")
11255            {
11256                parity_everywhere = false;
11257                reasons.push(format!("{} has failed benchmark operations", dataset.name));
11258            }
11259        }
11260        let decision = if let Some(reason) = candidate.prototype_hold_reason() {
11261            reasons.push(reason.to_string());
11262            reasons.push(
11263                "current bounded prototype timings are benchmark evidence, not a backend switch approval"
11264                    .to_string(),
11265            );
11266            "hold"
11267        } else if parity_everywhere && faster_everywhere {
11268            reasons.push(
11269                "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
11270                    .to_string(),
11271            );
11272            "eligible"
11273        } else {
11274            reasons.push(
11275                "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
11276                    .to_string(),
11277            );
11278            "hold"
11279        };
11280        decisions.push(GraphDbBackendPromotionDecision {
11281            backend: candidate.name().to_string(),
11282            decision: decision.to_string(),
11283            reasons: dedupe_preserve_order(reasons),
11284            gate: candidate.promotion_gate(),
11285        });
11286    }
11287    decisions
11288}
11289
11290pub(crate) fn graph_db_backend_eval_metrics(
11291    datasets: &[GraphDbBackendEvalDataset],
11292) -> BTreeMap<String, f64> {
11293    let mut metrics = BTreeMap::new();
11294    for dataset in datasets {
11295        let graph_rows = graph_db_backend_eval_graph_rows(dataset);
11296        metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
11297        metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
11298        metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
11299        for backend in &dataset.backends {
11300            let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
11301            metrics.insert(
11302                format!("{prefix}.total_duration_micros"),
11303                backend.total_micros as f64,
11304            );
11305            append_graph_db_backend_eval_normalized_duration_metric(
11306                &mut metrics,
11307                &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
11308                backend.total_micros,
11309                graph_rows,
11310            );
11311            for operation in &backend.operations {
11312                metrics.insert(
11313                    format!("{prefix}.{}.duration_micros", operation.name),
11314                    operation.duration_micros as f64,
11315                );
11316                append_graph_db_backend_eval_normalized_duration_metric(
11317                    &mut metrics,
11318                    &format!(
11319                        "{prefix}.{}.duration_micros_per_1k_graph_rows",
11320                        operation.name
11321                    ),
11322                    operation.duration_micros,
11323                    graph_rows,
11324                );
11325                if let Some(rows) = operation.rows {
11326                    metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
11327                }
11328            }
11329        }
11330    }
11331    metrics
11332}
11333
11334pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
11335    dataset.nodes + dataset.edges
11336}
11337
11338pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
11339    metrics: &mut BTreeMap<String, f64>,
11340    key: &str,
11341    duration_micros: u128,
11342    graph_rows: usize,
11343) {
11344    if graph_rows == 0 {
11345        return;
11346    }
11347    metrics.insert(
11348        key.to_string(),
11349        duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
11350    );
11351}
11352
11353pub(crate) fn append_graph_db_backend_eval_phase_metrics(
11354    metrics: &mut BTreeMap<String, f64>,
11355    dataset: &str,
11356    graph_rows: usize,
11357    phases: &[GraphDbBackendEvalPhaseTiming],
11358) {
11359    for phase in phases {
11360        metrics.insert(
11361            format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
11362            phase.duration_micros as f64,
11363        );
11364        append_graph_db_backend_eval_normalized_duration_metric(
11365            metrics,
11366            &format!(
11367                "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
11368                phase.name
11369            ),
11370            phase.duration_micros,
11371            graph_rows,
11372        );
11373    }
11374}
11375
11376fn graph_db_backend_eval_base_command(
11377    root: &Path,
11378    scope: Option<&str>,
11379    full_projection: bool,
11380) -> String {
11381    let full_projection_arg = if full_projection {
11382        " --full-projection"
11383    } else {
11384        ""
11385    };
11386    format!(
11387        "tsift graph-db --path {}{} --json backend-eval{}",
11388        shell_quote(root.to_string_lossy().as_ref()),
11389        graph_db_scope_arg(scope),
11390        full_projection_arg
11391    )
11392}
11393
11394pub(crate) fn graph_db_backend_eval_metric_digest_command(
11395    root: &Path,
11396    scope: Option<&str>,
11397    full_projection: bool,
11398) -> String {
11399    format!(
11400        "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11401        graph_db_backend_eval_base_command(root, scope, full_projection)
11402    )
11403}
11404
11405fn graph_db_backend_eval_repeated_sample_command(
11406    root: &Path,
11407    scope: Option<&str>,
11408    full_projection: bool,
11409) -> String {
11410    format!(
11411        "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
11412        graph_db_backend_eval_base_command(root, scope, full_projection)
11413    )
11414}
11415
11416fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
11417    let mut required_metrics = Vec::new();
11418    for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
11419        required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
11420        required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
11421        for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
11422            required_metrics.push(format!(
11423                "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
11424            ));
11425            required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
11426        }
11427    }
11428    GraphDbHopCapPromotionGate {
11429        status: "hold_64_default_until_gate_passes".to_string(),
11430        current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
11431        candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
11432        required_backend: perf_gate::BASELINE_BACKEND.to_string(),
11433        required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
11434            .iter()
11435            .map(|workload| (*workload).to_string())
11436            .collect(),
11437        required_metrics,
11438        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11439        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11440        decision_rule:
11441            "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"
11442                .to_string(),
11443    }
11444}
11445
11446fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
11447    let candidate_backends = [
11448        GraphDbExperimentalBackend::Falkordb,
11449        GraphDbExperimentalBackend::Kuzu,
11450        GraphDbExperimentalBackend::Surrealdb,
11451    ]
11452    .into_iter()
11453    .map(|backend| GraphDbBackendAdapterSpikeCandidate {
11454        backend: backend.name().to_string(),
11455        adapter_label: backend.adapter_label().to_string(),
11456        projection_load: backend.projection_load().to_string(),
11457        lock_behavior: backend.lock_behavior().to_string(),
11458        install_portability: backend.install_portability().to_string(),
11459    })
11460    .collect();
11461
11462    GraphDbBackendAdapterSpikeGate {
11463        status: "hold_real_optional_adapter_required".to_string(),
11464        candidate_backends,
11465        required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
11466            .iter()
11467            .map(|workload| (*workload).to_string())
11468            .collect(),
11469        required_checks: vec![
11470            "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
11471            "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
11472            "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
11473            "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
11474            "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
11475                .to_string(),
11476            "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
11477            "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
11478        ],
11479        decision_rule:
11480            "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"
11481                .to_string(),
11482        evidence_plan: "plans/gback-evidence.md".to_string(),
11483    }
11484}
11485
11486pub(crate) fn graph_db_backend_eval_performance_gate(
11487    root: &Path,
11488    scope: Option<&str>,
11489    full_projection: bool,
11490) -> GraphDbBackendEvalPerformanceGate {
11491    let mut required_metrics = vec![
11492        "real.sqlite.refresh.duration_micros".to_string(),
11493        "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
11494        "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
11495        "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
11496        "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11497        "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11498        "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
11499        "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
11500        "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11501        "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
11502        "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
11503        "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
11504        "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
11505        "real.sqlite.conflict_matrix.duration_micros".to_string(),
11506        "real.sqlite.dispatch_trace.duration_micros".to_string(),
11507        "real.sqlite.path_max_hops.duration_micros".to_string(),
11508        "real.sqlite.path_max_hops_128.duration_micros".to_string(),
11509        "real.sqlite.path_max_hops_256.duration_micros".to_string(),
11510        "real.sqlite.path_max_hops_512.duration_micros".to_string(),
11511        "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
11512        "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
11513        "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
11514        "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
11515        "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11516        "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11517        "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
11518            .to_string(),
11519        "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11520            .to_string(),
11521        "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
11522        "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
11523        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
11524        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
11525        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
11526        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
11527        "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
11528            .to_string(),
11529        "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
11530        "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
11531            .to_string(),
11532        "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
11533            .to_string(),
11534        "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
11535            .to_string(),
11536    ];
11537    if full_projection {
11538        required_metrics.extend([
11539            "full_projection.cache.hit".to_string(),
11540            "full_projection.cache.disk_bytes".to_string(),
11541            "full_projection.cache.compression_ratio".to_string(),
11542            "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
11543            "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
11544            "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
11545                .to_string(),
11546            "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
11547                .to_string(),
11548            "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
11549            "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
11550            "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
11551            "full_projection.sqlite.neighborhood.duration_micros".to_string(),
11552            "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
11553            "full_projection.sqlite.evidence.duration_micros".to_string(),
11554            "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
11555            "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
11556            "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
11557            "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
11558            "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
11559            "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
11560        ]);
11561    }
11562    GraphDbBackendEvalPerformanceGate {
11563        baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
11564        ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
11565            .to_string(),
11566        opt_in_real_profile:
11567            "pass --full-projection to add the full-project dataset when checking for large projection regressions"
11568                .to_string(),
11569        full_projection_cache_hit_gate: if full_projection {
11570            "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"
11571                .to_string()
11572        } else {
11573            "not evaluated until --full-projection is enabled".to_string()
11574        },
11575        allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
11576        minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
11577        normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
11578        required_metrics,
11579        digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
11580        repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
11581            root,
11582            scope,
11583            full_projection,
11584        ),
11585        hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
11586        backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
11587    }
11588}
11589
11590#[cfg(feature = "backend-surrealdb")]
11591fn graph_db_backend_eval_path_segment(value: &str) -> String {
11592    value
11593        .chars()
11594        .map(|ch| {
11595            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
11596                ch
11597            } else {
11598                '_'
11599            }
11600        })
11601        .collect()
11602}
11603
11604#[cfg(feature = "backend-surrealdb")]
11605fn graph_db_backend_eval_surrealdb_store_path(
11606    root: &Path,
11607    scope: Option<&str>,
11608    dataset: &str,
11609) -> PathBuf {
11610    root.join(".tsift/backend-eval-cache/surrealdb")
11611        .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
11612        .join(graph_db_backend_eval_path_segment(dataset))
11613        .join("surrealkv")
11614}
11615
11616pub(crate) struct GraphDbBackendEvalOptions<'a> {
11617    path: &'a Path,
11618    scope: Option<&'a str>,
11619    candidates: &'a [String],
11620    targets: &'a [String],
11621    full_projection: bool,
11622}
11623
11624#[allow(clippy::too_many_arguments)]
11625pub(crate) fn graph_db_backend_eval_dataset(
11626    name: &str,
11627    root: &Path,
11628    path: &Path,
11629    scope: Option<&str>,
11630    targets: &[String],
11631    depth: usize,
11632    limit: usize,
11633    impact_limit: usize,
11634    candidates: &[GraphDbExperimentalBackend],
11635    sqlite_store: &SqliteGraphStore,
11636    sqlite_freshness: GraphDbFreshnessReport,
11637    sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
11638    sqlite_rows: ConvexProjectionRows,
11639    extra_warnings: Vec<String>,
11640    prepared: &ConflictMatrixPreparedInputs,
11641) -> Result<GraphDbBackendEvalDataset> {
11642    let (nodes, edges) = sqlite_store.graph_counts()?;
11643    let (sqlite_operation, sqlite_signature) = sqlite_refresh;
11644    let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
11645        "sqlite",
11646        "SQLite GraphStore correctness baseline",
11647        false,
11648        root,
11649        path,
11650        scope,
11651        targets,
11652        depth,
11653        limit,
11654        impact_limit,
11655        sqlite_store,
11656        sqlite_freshness,
11657        sqlite_operation,
11658        Some(sqlite_signature),
11659        None,
11660        extra_warnings.clone(),
11661        prepared,
11662        "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
11663        "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
11664        "bundled rusqlite baseline; no external service or runtime required",
11665    );
11666
11667    let mut backends = vec![sqlite_report];
11668    for candidate in candidates {
11669        #[cfg(feature = "backend-surrealdb")]
11670        if *candidate == GraphDbExperimentalBackend::Surrealdb {
11671            let started = Instant::now();
11672            let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
11673            let (store, warm_start) =
11674                SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
11675            let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11676            let rows = candidate_nodes + candidate_edges;
11677            let mut refresh_meta = serde_json::json!({
11678                "nodes": candidate_nodes,
11679                "edges": candidate_edges,
11680            });
11681            if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
11682                refresh_meta["warm_start"] = serde_json::json!("cache_hit");
11683            }
11684            let refresh = graph_db_backend_eval_refresh_operation(
11685                started.elapsed().as_micros(),
11686                rows,
11687                refresh_meta,
11688            );
11689            let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11690            let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11691                candidate.name(),
11692                "SurrealDB SurrealKV optional adapter spike",
11693                false,
11694                root,
11695                path,
11696                scope,
11697                targets,
11698                depth,
11699                limit,
11700                impact_limit,
11701                &store,
11702                freshness,
11703                refresh.0,
11704                Some(refresh.1),
11705                Some(&sqlite_signatures),
11706                extra_warnings.clone(),
11707                prepared,
11708                "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",
11709                "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
11710                "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
11711            );
11712            backends.push(candidate_report);
11713            continue;
11714        }
11715        let started = Instant::now();
11716        let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
11717        let (candidate_nodes, candidate_edges) = store.graph_counts()?;
11718        let rows = candidate_nodes + candidate_edges;
11719        let refresh = graph_db_backend_eval_refresh_operation(
11720            started.elapsed().as_micros(),
11721            rows,
11722            serde_json::json!({
11723                "nodes": candidate_nodes,
11724                "edges": candidate_edges,
11725            }),
11726        );
11727        let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
11728        let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
11729            candidate.name(),
11730            candidate.adapter_label(),
11731            true,
11732            root,
11733            path,
11734            scope,
11735            targets,
11736            depth,
11737            limit,
11738            impact_limit,
11739            &store,
11740            freshness,
11741            refresh.0,
11742            Some(refresh.1),
11743            Some(&sqlite_signatures),
11744            extra_warnings.clone(),
11745            prepared,
11746            candidate.projection_load(),
11747            candidate.lock_behavior(),
11748            candidate.install_portability(),
11749        );
11750        backends.push(candidate_report);
11751    }
11752
11753    Ok(GraphDbBackendEvalDataset {
11754        name: name.to_string(),
11755        target_count: targets.len(),
11756        nodes,
11757        edges,
11758        backends,
11759    })
11760}
11761
11762pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
11763    println!(
11764        "graph-db backend-eval baseline:{} candidates:{}",
11765        report.baseline_backend,
11766        report.candidates.join(", ")
11767    );
11768    for phase in &report.phase_timings {
11769        println!(
11770            "phase:{} {}us {}",
11771            phase.name, phase.duration_micros, phase.detail
11772        );
11773    }
11774    for dataset in &report.datasets {
11775        println!(
11776            "dataset:{} targets:{} rows:{}",
11777            dataset.name,
11778            dataset.target_count,
11779            dataset.nodes + dataset.edges
11780        );
11781        for backend in &dataset.backends {
11782            println!(
11783                "  backend:{} total:{}us parity:{}",
11784                backend.backend, backend.total_micros, backend.parity.matches_sqlite
11785            );
11786            println!("    projection-load: {}", backend.projection_load);
11787            println!("    lock-behavior: {}", backend.lock_behavior);
11788            println!("    install-portability: {}", backend.install_portability);
11789            for operation in &backend.operations {
11790                println!(
11791                    "    {} {} {}us",
11792                    operation.name, operation.status, operation.duration_micros
11793                );
11794            }
11795            for diagnostic in &backend.parity.diagnostics {
11796                println!("    parity: {diagnostic}");
11797            }
11798        }
11799    }
11800    for decision in &report.promotion {
11801        println!("promotion {}: {}", decision.backend, decision.decision);
11802        println!("  gate: {}", decision.gate.status);
11803        for reason in &decision.reasons {
11804            println!("  reason: {reason}");
11805        }
11806        for check in &decision.gate.required_checks {
11807            println!("  check: {check}");
11808        }
11809    }
11810    println!("metric-digest: {}", report.metric_digest_command);
11811    println!(
11812        "repeat-samples: {}",
11813        report.performance_gate.repeated_sample_command
11814    );
11815}
11816
11817fn traversal_expand_command(root: &Path, handle: &str) -> String {
11818    format!(
11819        "tsift traverse {} --path {} --depth 1 --limit 50",
11820        shell_quote(handle),
11821        shell_quote(root.to_string_lossy().as_ref())
11822    )
11823}
11824
11825fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
11826    let display = relativize(file, root);
11827    let handle = stable_handle("gfil", &format!("file:{display}"));
11828    TraversalNode {
11829        handle: handle.clone(),
11830        kind: "file".to_string(),
11831        label: display.clone(),
11832        ref_id: Some(display.clone()),
11833        path: Some(display),
11834        line: None,
11835        detail: None,
11836        properties: BTreeMap::new(),
11837        expand: traversal_expand_command(root, &handle),
11838    }
11839}
11840
11841fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
11842    let mut node = traversal_file_node(root, file);
11843    if let Some(path) = node.path.clone() {
11844        node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
11845        node.expand = source_read_command(root, &path, 1, 80);
11846    }
11847    node
11848}
11849
11850fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
11851    let file = relativize(&symbol.file, root);
11852    let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
11853    let handle = stable_handle("gsym", &key);
11854    TraversalNode {
11855        handle: handle.clone(),
11856        kind: "symbol".to_string(),
11857        label: symbol.name.clone(),
11858        ref_id: Some(symbol.name.clone()),
11859        path: Some(file),
11860        line: Some(symbol.line),
11861        detail: Some(format!("{} {}", symbol.language, symbol.kind)),
11862        properties: BTreeMap::new(),
11863        expand: traversal_expand_command(root, &handle),
11864    }
11865}
11866
11867fn traversal_ast_span_expand_command(
11868    root: &Path,
11869    file: &str,
11870    symbol: &index::StoredSymbol,
11871    span: &AstSpanPreview,
11872) -> String {
11873    if symbol.language == "markdown" {
11874        markdown_ast_command(root, file, Some(&span.handle))
11875    } else {
11876        let line_count = span
11877            .end_line
11878            .saturating_sub(span.start_line)
11879            .saturating_add(1)
11880            .max(1);
11881        source_read_command(root, file, span.start_line, line_count)
11882    }
11883}
11884
11885fn traversal_ast_span_node(
11886    root: &Path,
11887    symbol: &index::StoredSymbol,
11888    source: &[u8],
11889    symbols: &[index::StoredSymbol],
11890) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
11891    let span = stored_symbol_ast_span(symbol, source, symbols, usize::MAX)?;
11892    let file = relativize(&symbol.file, root);
11893    let mut properties = BTreeMap::new();
11894    properties.insert("layer".to_string(), "ast_navigation".to_string());
11895    properties.insert("language".to_string(), symbol.language.clone());
11896    properties.insert("symbol_kind".to_string(), symbol.kind.clone());
11897    properties.insert("node_kind".to_string(), span.node_kind.clone());
11898    properties.insert("start_byte".to_string(), span.start_byte.to_string());
11899    properties.insert("end_byte".to_string(), span.end_byte.to_string());
11900    properties.insert("end_line".to_string(), span.end_line.to_string());
11901    if let Some(body_start_byte) = span.body_start_byte {
11902        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
11903    }
11904    if let Some(body_end_byte) = span.body_end_byte {
11905        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
11906    }
11907    if let Some(body_start_line) = span.body_start_line {
11908        properties.insert("body_start_line".to_string(), body_start_line.to_string());
11909    }
11910    if let Some(body_end_line) = span.body_end_line {
11911        properties.insert("body_end_line".to_string(), body_end_line.to_string());
11912    }
11913    if let Some(parent_handle) = &span.parent_handle {
11914        properties.insert("parent_handle".to_string(), parent_handle.clone());
11915    }
11916    if !span.child_handles.is_empty() {
11917        properties.insert("child_handles".to_string(), span.child_handles.join(","));
11918    }
11919    if let Some(parent_module) = &symbol.parent_module {
11920        properties.insert("parent_module".to_string(), parent_module.clone());
11921    }
11922    if let Some(markdown) = &span.markdown {
11923        properties.insert(
11924            "markdown_block_kind".to_string(),
11925            markdown_ast_block_kind(&symbol.kind),
11926        );
11927        if let Some(heading_level) = markdown.heading_level {
11928            properties.insert("heading_level".to_string(), heading_level.to_string());
11929        }
11930        if !markdown.section_path.is_empty() {
11931            properties.insert(
11932                "section_path".to_string(),
11933                markdown.section_path.join(" > "),
11934            );
11935        }
11936        if let Some(section_handle) = &markdown.section_handle {
11937            properties.insert("section_handle".to_string(), section_handle.clone());
11938        }
11939        if let Some(list_depth) = markdown.list_depth {
11940            properties.insert("list_depth".to_string(), list_depth.to_string());
11941        }
11942        if let Some(fence_language) = &markdown.fence_language {
11943            properties.insert("fence_language".to_string(), fence_language.clone());
11944        }
11945    }
11946
11947    let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
11948    let node = TraversalNode {
11949        handle: span.handle.clone(),
11950        kind: "ast_span".to_string(),
11951        label: symbol.name.clone(),
11952        ref_id: Some(symbol.name.clone()),
11953        path: Some(file.clone()),
11954        line: Some(line),
11955        detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
11956        properties,
11957        expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
11958    };
11959    let entry = TraversalAstSpanIndexEntry {
11960        handle: span.handle,
11961        symbol_handle: String::new(),
11962        file_handle: None,
11963        file,
11964        name: symbol.name.clone(),
11965        kind: symbol.kind.clone(),
11966        language: symbol.language.clone(),
11967        node_kind: span.node_kind,
11968        start_byte: span.start_byte,
11969        end_byte: span.end_byte,
11970        parent_module: symbol.parent_module.clone(),
11971        markdown: span.markdown,
11972    };
11973    Some((node, entry))
11974}
11975
11976fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
11977    let handle = stable_handle("gsym", &format!("symbol:{name}"));
11978    TraversalNode {
11979        handle: handle.clone(),
11980        kind: "symbol".to_string(),
11981        label: name.to_string(),
11982        ref_id: Some(name.to_string()),
11983        path: None,
11984        line: None,
11985        detail: Some("unresolved call target".to_string()),
11986        properties: BTreeMap::new(),
11987        expand: traversal_expand_command(root, &handle),
11988    }
11989}
11990
11991fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
11992    let file = relativize(&route.file, root);
11993    let method = route.method.as_deref().unwrap_or("any");
11994    let key = format!(
11995        "route:{file}:{}:{}:{}",
11996        route.line, method, route.route_path
11997    );
11998    let handle = stable_handle("grte", &key);
11999    TraversalNode {
12000        handle: handle.clone(),
12001        kind: "route".to_string(),
12002        label: format!("{} {}", method.to_uppercase(), route.route_path),
12003        ref_id: Some(route.route_path.clone()),
12004        path: Some(file),
12005        line: Some(route.line),
12006        detail: Some(format!(
12007            "{} route handled by {}",
12008            route.framework, route.handler_name
12009        )),
12010        properties: BTreeMap::new(),
12011        expand: traversal_expand_command(root, &handle),
12012    }
12013}
12014
12015fn traversal_cargo_workspace_node(
12016    root: &Path,
12017    workspace: &multiplicity::CargoWorkspaceInfo,
12018) -> TraversalNode {
12019    let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12020        .to_string_lossy()
12021        .replace('\\', "/");
12022    let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12023        .to_string_lossy()
12024        .replace('\\', "/");
12025    let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12026    let mut properties = BTreeMap::new();
12027    properties.insert("layer".to_string(), "cargo_workspace".to_string());
12028    properties.insert("workspace_root".to_string(), workspace_root.clone());
12029    properties.insert("members".to_string(), workspace.members.join(","));
12030    properties.insert(
12031        "default_members".to_string(),
12032        workspace.default_members.join(","),
12033    );
12034    TraversalNode {
12035        handle: handle.clone(),
12036        kind: "cargo_workspace".to_string(),
12037        label: if workspace_root.is_empty() {
12038            "root cargo workspace".to_string()
12039        } else {
12040            workspace_root
12041        },
12042        ref_id: Some(workspace.id.clone()),
12043        path: Some(manifest),
12044        line: None,
12045        detail: Some("Cargo workspace manifest".to_string()),
12046        properties,
12047        expand: traversal_expand_command(root, &handle),
12048    }
12049}
12050
12051fn traversal_cargo_package_node(
12052    root: &Path,
12053    package: &multiplicity::CargoPackageInfo,
12054) -> TraversalNode {
12055    let manifest = relativize_pathbuf(&package.manifest_path, root)
12056        .to_string_lossy()
12057        .replace('\\', "/");
12058    let package_root = relativize_pathbuf(&package.package_root, root)
12059        .to_string_lossy()
12060        .replace('\\', "/");
12061    let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12062        .to_string_lossy()
12063        .replace('\\', "/");
12064    let handle = stable_handle(
12065        "gcpk",
12066        &format!("cargo-package:{manifest}:{}", package.name),
12067    );
12068    let mut properties = BTreeMap::new();
12069    properties.insert("layer".to_string(), "cargo_package".to_string());
12070    properties.insert("package_name".to_string(), package.name.clone());
12071    properties.insert(
12072        "normalized_name".to_string(),
12073        package.normalized_name.clone(),
12074    );
12075    properties.insert("package_root".to_string(), package_root.clone());
12076    properties.insert("workspace_root".to_string(), workspace_root);
12077    properties.insert("features".to_string(), package.features.join(","));
12078    properties.insert("targets".to_string(), package.targets.join(","));
12079    properties.insert(
12080        "dependencies".to_string(),
12081        package
12082            .dependencies
12083            .iter()
12084            .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12085            .collect::<Vec<_>>()
12086            .join(","),
12087    );
12088    TraversalNode {
12089        handle: handle.clone(),
12090        kind: "cargo_package".to_string(),
12091        label: package.name.clone(),
12092        ref_id: Some(package.scope_id.clone()),
12093        path: Some(manifest),
12094        line: None,
12095        detail: Some(format!(
12096            "Cargo package in {}",
12097            if package_root.is_empty() {
12098                "."
12099            } else {
12100                package_root.as_str()
12101            }
12102        )),
12103        properties,
12104        expand: traversal_expand_command(root, &handle),
12105    }
12106}
12107
12108fn traversal_session_node(
12109    root: &Path,
12110    markdown_path: &Path,
12111    session_id: Option<&str>,
12112) -> TraversalNode {
12113    let display = relativize_pathbuf(markdown_path, root)
12114        .to_string_lossy()
12115        .replace('\\', "/");
12116    let handle = stable_handle("gses", &format!("session:{display}"));
12117    TraversalNode {
12118        handle: handle.clone(),
12119        kind: "session".to_string(),
12120        label: session_id.unwrap_or(&display).to_string(),
12121        ref_id: session_id.map(str::to_string),
12122        path: Some(display),
12123        line: None,
12124        detail: Some("agent-doc session artifact".to_string()),
12125        properties: BTreeMap::new(),
12126        expand: traversal_expand_command(root, &handle),
12127    }
12128}
12129
12130fn traversal_backlog_node(
12131    root: &Path,
12132    markdown_path: &Path,
12133    id: &str,
12134    text: &str,
12135    line: i64,
12136) -> TraversalNode {
12137    let display = relativize_pathbuf(markdown_path, root)
12138        .to_string_lossy()
12139        .replace('\\', "/");
12140    let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12141    TraversalNode {
12142        handle: handle.clone(),
12143        kind: "backlog".to_string(),
12144        label: format!("#{id}"),
12145        ref_id: Some(id.to_string()),
12146        path: Some(display),
12147        line: Some(line),
12148        detail: Some(text.to_string()),
12149        properties: BTreeMap::new(),
12150        expand: traversal_expand_command(root, &handle),
12151    }
12152}
12153
12154fn traversal_job_packet_node(
12155    root: &Path,
12156    markdown_path: &Path,
12157    label: &str,
12158    ref_id: Option<&str>,
12159    detail: &str,
12160    line: i64,
12161) -> TraversalNode {
12162    let display = relativize_pathbuf(markdown_path, root)
12163        .to_string_lossy()
12164        .replace('\\', "/");
12165    let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12166    TraversalNode {
12167        handle: handle.clone(),
12168        kind: "job_packet".to_string(),
12169        label: label.to_string(),
12170        ref_id: ref_id.map(str::to_string),
12171        path: Some(display),
12172        line: Some(line),
12173        detail: Some(detail.to_string()),
12174        properties: BTreeMap::new(),
12175        expand: traversal_expand_command(root, &handle),
12176    }
12177}
12178
12179#[derive(Clone, Debug)]
12180struct ParsedWorkerResult {
12181    id: String,
12182    status: String,
12183    touched_files: Vec<String>,
12184    tests: Vec<String>,
12185    follow_up_ids: Vec<String>,
12186}
12187
12188fn traversal_worker_result_node(
12189    root: &Path,
12190    markdown_path: &Path,
12191    parsed: &ParsedWorkerResult,
12192    line_text: &str,
12193    line: i64,
12194) -> TraversalNode {
12195    let display = relativize_pathbuf(markdown_path, root)
12196        .to_string_lossy()
12197        .replace('\\', "/");
12198    let handle = stable_handle(
12199        "wres",
12200        &format!(
12201            "worker-result:{display}:{}:{}:{}",
12202            parsed.id, parsed.status, line
12203        ),
12204    );
12205    let mut properties = BTreeMap::new();
12206    properties.insert("status".to_string(), parsed.status.clone());
12207    if !parsed.touched_files.is_empty() {
12208        properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12209    }
12210    if !parsed.tests.is_empty() {
12211        properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12212    }
12213    if !parsed.follow_up_ids.is_empty() {
12214        properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12215    }
12216    TraversalNode {
12217        handle: handle.clone(),
12218        kind: "worker_result".to_string(),
12219        label: format!("{} #{}", parsed.status, parsed.id),
12220        ref_id: Some(parsed.id.clone()),
12221        path: Some(display),
12222        line: Some(line),
12223        detail: Some(line_text.trim().to_string()),
12224        properties,
12225        expand: traversal_expand_command(root, &handle),
12226    }
12227}
12228
12229fn traversal_tokens(input: &str) -> BTreeSet<String> {
12230    input
12231        .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
12232        .flat_map(|part| part.split(['_', '-']))
12233        .map(str::trim)
12234        .filter(|part| part.len() >= 3)
12235        .map(|part| part.to_ascii_lowercase())
12236        .collect()
12237}
12238
12239fn traversal_ast_span_contains(
12240    parent: &TraversalAstSpanIndexEntry,
12241    child: &TraversalAstSpanIndexEntry,
12242) -> bool {
12243    parent.handle != child.handle
12244        && parent.file == child.file
12245        && parent.start_byte <= child.start_byte
12246        && parent.end_byte >= child.end_byte
12247}
12248
12249fn traversal_ast_parent_handle<'a>(
12250    entry: &TraversalAstSpanIndexEntry,
12251    entries: &'a [TraversalAstSpanIndexEntry],
12252) -> Option<&'a str> {
12253    entries
12254        .iter()
12255        .filter(|candidate| traversal_ast_span_contains(candidate, entry))
12256        .min_by_key(|candidate| {
12257            (
12258                candidate.end_byte.saturating_sub(candidate.start_byte),
12259                candidate.start_byte,
12260                candidate.end_byte,
12261                candidate.kind.as_str(),
12262                candidate.name.as_str(),
12263                candidate.node_kind.as_str(),
12264            )
12265        })
12266        .map(|candidate| candidate.handle.as_str())
12267}
12268
12269fn traversal_ast_enclosing_module_handle<'a>(
12270    entry: &TraversalAstSpanIndexEntry,
12271    entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
12272    parent_by_handle: &BTreeMap<String, String>,
12273) -> Option<&'a str> {
12274    let mut current = parent_by_handle.get(&entry.handle);
12275    while let Some(handle) = current {
12276        let Some(parent) = entries_by_handle.get(handle) else {
12277            break;
12278        };
12279        if matches!(parent.kind.as_str(), "module" | "mod")
12280            || entry
12281                .parent_module
12282                .as_deref()
12283                .is_some_and(|module| module == parent.name)
12284        {
12285            return Some(parent.handle.as_str());
12286        }
12287        current = parent_by_handle.get(&parent.handle);
12288    }
12289    None
12290}
12291
12292fn link_ast_navigation_edges(
12293    graph: &mut TraversalGraphBuild,
12294    entries: &[TraversalAstSpanIndexEntry],
12295) {
12296    let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
12297    let entries_by_handle = entries
12298        .iter()
12299        .map(|entry| (entry.handle.clone(), entry.clone()))
12300        .collect::<BTreeMap<_, _>>();
12301    let mut parent_by_handle = BTreeMap::<String, String>::new();
12302    let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
12303
12304    for entry in entries {
12305        entries_by_file
12306            .entry(entry.file.clone())
12307            .or_default()
12308            .push(entry.clone());
12309    }
12310
12311    for file_entries in entries_by_file.values() {
12312        for entry in file_entries {
12313            let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
12314            if let Some(parent) = &parent {
12315                parent_by_handle.insert(entry.handle.clone(), parent.clone());
12316            }
12317            let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
12318            children_by_parent
12319                .entry(sibling_key)
12320                .or_default()
12321                .push(entry.clone());
12322        }
12323    }
12324
12325    for entry in entries {
12326        let parent = parent_by_handle.get(&entry.handle);
12327        if let Some(parent) = parent {
12328            graph.add_edge(
12329                parent,
12330                &entry.handle,
12331                "contains",
12332                Some("AST parent contains child span".to_string()),
12333                1,
12334            );
12335            graph.add_edge(
12336                parent,
12337                &entry.handle,
12338                "child",
12339                Some("AST child span".to_string()),
12340                1,
12341            );
12342            graph.add_edge(
12343                &entry.handle,
12344                parent,
12345                "parent",
12346                Some("AST parent span".to_string()),
12347                1,
12348            );
12349        } else if let Some(file_handle) = &entry.file_handle {
12350            graph.add_edge(
12351                file_handle,
12352                &entry.handle,
12353                "contains",
12354                Some("file contains top-level AST span".to_string()),
12355                1,
12356            );
12357        }
12358
12359        if let Some(module_handle) =
12360            traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
12361        {
12362            graph.add_edge(
12363                &entry.handle,
12364                module_handle,
12365                "enclosing_module",
12366                Some("nearest enclosing module AST span".to_string()),
12367                1,
12368            );
12369        }
12370
12371        if entry.language == "markdown"
12372            && let Some(markdown) = &entry.markdown
12373            && let Some(section_handle) = &markdown.section_handle
12374            && section_handle != &entry.handle
12375        {
12376            graph.add_edge(
12377                section_handle,
12378                &entry.handle,
12379                "contains_markdown_block",
12380                Some("Markdown section contains block".to_string()),
12381                1,
12382            );
12383            graph.add_edge(
12384                &entry.handle,
12385                section_handle,
12386                "enclosing_section",
12387                Some("Markdown enclosing section".to_string()),
12388                1,
12389            );
12390        }
12391    }
12392
12393    for siblings in children_by_parent.values_mut() {
12394        siblings.sort_by(|left, right| {
12395            left.start_byte
12396                .cmp(&right.start_byte)
12397                .then(left.end_byte.cmp(&right.end_byte))
12398                .then(left.kind.cmp(&right.kind))
12399                .then(left.name.cmp(&right.name))
12400                .then(left.node_kind.cmp(&right.node_kind))
12401                .then(left.handle.cmp(&right.handle))
12402        });
12403        for pair in siblings.windows(2) {
12404            let previous = &pair[0];
12405            let next = &pair[1];
12406            graph.add_edge(
12407                &previous.handle,
12408                &next.handle,
12409                "next_sibling",
12410                Some("next AST sibling span".to_string()),
12411                1,
12412            );
12413            graph.add_edge(
12414                &next.handle,
12415                &previous.handle,
12416                "previous_sibling",
12417                Some("previous AST sibling span".to_string()),
12418                1,
12419            );
12420        }
12421    }
12422}
12423
12424fn traversal_markdown_embedded_symbol_node(
12425    root: &Path,
12426    entry: &TraversalAstSpanIndexEntry,
12427    markdown: &MarkdownSpanMetadata,
12428    embedded: &MarkdownEmbeddedSymbol,
12429) -> TraversalNode {
12430    let mut properties = BTreeMap::new();
12431    properties.insert("layer".to_string(), "embedded_code".to_string());
12432    properties.insert("embedded".to_string(), "true".to_string());
12433    properties.insert("language".to_string(), embedded.language.clone());
12434    properties.insert("symbol_kind".to_string(), embedded.kind.clone());
12435    properties.insert("node_kind".to_string(), embedded.node_kind.clone());
12436    properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
12437    properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
12438    properties.insert("end_line".to_string(), embedded.end_line.to_string());
12439    properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
12440    properties.insert(
12441        "markdown_block_kind".to_string(),
12442        markdown_ast_block_kind(&entry.kind),
12443    );
12444    if let Some(body_start_byte) = embedded.body_start_byte {
12445        properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12446    }
12447    if let Some(body_end_byte) = embedded.body_end_byte {
12448        properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12449    }
12450    if let Some(body_start_line) = embedded.body_start_line {
12451        properties.insert("body_start_line".to_string(), body_start_line.to_string());
12452    }
12453    if let Some(body_end_line) = embedded.body_end_line {
12454        properties.insert("body_end_line".to_string(), body_end_line.to_string());
12455    }
12456    if let Some(fence_language) = &markdown.fence_language {
12457        properties.insert("fence_language".to_string(), fence_language.clone());
12458    }
12459    if !markdown.section_path.is_empty() {
12460        properties.insert(
12461            "section_path".to_string(),
12462            markdown.section_path.join(" > "),
12463        );
12464    }
12465    if let Some(section_handle) = &markdown.section_handle {
12466        properties.insert("section_handle".to_string(), section_handle.clone());
12467    }
12468    let line_count = embedded
12469        .end_line
12470        .saturating_sub(embedded.start_line)
12471        .saturating_add(1)
12472        .max(1);
12473    TraversalNode {
12474        handle: embedded.handle.clone(),
12475        kind: "ast_span".to_string(),
12476        label: embedded.name.clone(),
12477        ref_id: Some(embedded.name.clone()),
12478        path: Some(entry.file.clone()),
12479        line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
12480        detail: Some(format!(
12481            "{} {} embedded in Markdown fence",
12482            embedded.language, embedded.kind
12483        )),
12484        properties,
12485        expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
12486    }
12487}
12488
12489fn link_markdown_embedded_code_edges(
12490    graph: &mut TraversalGraphBuild,
12491    root: &Path,
12492    entries: &[TraversalAstSpanIndexEntry],
12493) {
12494    for entry in entries {
12495        let Some(markdown) = &entry.markdown else {
12496            continue;
12497        };
12498        for embedded in &markdown.embedded_symbols {
12499            let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
12500            graph.add_node(node);
12501            graph.add_edge(
12502                &entry.handle,
12503                &embedded.handle,
12504                "contains",
12505                Some("Markdown fence contains embedded AST symbol".to_string()),
12506                1,
12507            );
12508            graph.add_edge(
12509                &entry.handle,
12510                &embedded.handle,
12511                "child",
12512                Some("embedded code symbol".to_string()),
12513                1,
12514            );
12515            graph.add_edge(
12516                &entry.handle,
12517                &embedded.handle,
12518                "contains_embedded_symbol",
12519                Some("Markdown fence contains embedded code symbol".to_string()),
12520                1,
12521            );
12522            graph.add_edge(
12523                &embedded.handle,
12524                &entry.handle,
12525                "parent",
12526                Some("Markdown fence parent span".to_string()),
12527                1,
12528            );
12529            graph.add_edge(
12530                &embedded.handle,
12531                &entry.handle,
12532                "embedded_in_fence",
12533                Some("embedded code symbol belongs to Markdown fence".to_string()),
12534                1,
12535            );
12536            if let Some(section_handle) = &markdown.section_handle
12537                && section_handle != &entry.handle
12538            {
12539                graph.add_edge(
12540                    section_handle,
12541                    &embedded.handle,
12542                    "contains_embedded_code",
12543                    Some("Markdown section contains embedded code symbol".to_string()),
12544                    1,
12545                );
12546                graph.add_edge(
12547                    &embedded.handle,
12548                    section_handle,
12549                    "enclosing_section",
12550                    Some("Markdown enclosing section".to_string()),
12551                    1,
12552                );
12553            }
12554        }
12555    }
12556}
12557
12558fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
12559    let mut tokens = traversal_tokens(&node.label);
12560    if let Some(ref_id) = &node.ref_id {
12561        tokens.extend(traversal_tokens(ref_id));
12562    }
12563    if let Some(path) = &node.path {
12564        tokens.extend(traversal_tokens(path));
12565    }
12566    if let Some(detail) = &node.detail {
12567        tokens.extend(traversal_tokens(detail));
12568    }
12569    tokens
12570}
12571
12572fn parse_agent_doc_session_id(content: &str) -> Option<String> {
12573    content.lines().find_map(|line| {
12574        let trimmed = line.trim();
12575        trimmed
12576            .strip_prefix("agent_doc_session:")
12577            .map(str::trim)
12578            .filter(|value| !value.is_empty())
12579            .map(str::to_string)
12580    })
12581}
12582
12583fn parse_backlog_line(line: &str) -> Option<(String, String)> {
12584    let trimmed = line.trim();
12585    if !trimmed.starts_with("- [") {
12586        return None;
12587    }
12588    let start = trimmed.find("[#")?;
12589    let after_start = start + 2;
12590    let rest = &trimmed[after_start..];
12591    let end = rest.find(']')?;
12592    let id = rest[..end].trim();
12593    if id.is_empty() {
12594        return None;
12595    }
12596    let text = rest[end + 1..].trim().to_string();
12597    Some((id.to_string(), text))
12598}
12599
12600fn parse_queue_dispatch_line(line: &str) -> Option<String> {
12601    let trimmed = line.trim();
12602    ["dispatch ", "preset "].iter().find_map(|prefix| {
12603        trimmed
12604            .strip_prefix(prefix)
12605            .map(str::trim)
12606            .filter(|value| !value.is_empty())
12607            .map(str::to_string)
12608    })
12609}
12610
12611fn parse_queue_do_line(line: &str) -> Option<String> {
12612    let trimmed = line.trim();
12613    let rest = trimmed.strip_prefix("- do [#")?;
12614    let end = rest.find(']')?;
12615    let id = rest[..end].trim();
12616    (!id.is_empty()).then(|| id.to_string())
12617}
12618
12619fn markdown_code_spans(input: &str) -> Vec<String> {
12620    input
12621        .split('`')
12622        .enumerate()
12623        .filter(|(idx, _)| idx % 2 == 1)
12624        .map(|(_, part)| part.trim().to_string())
12625        .filter(|part| !part.is_empty())
12626        .collect()
12627}
12628
12629fn push_traversal_token_index(
12630    index: &mut HashMap<String, Vec<usize>>,
12631    tokens: &BTreeSet<String>,
12632    entry_index: usize,
12633) {
12634    for token in tokens {
12635        index.entry(token.clone()).or_default().push(entry_index);
12636    }
12637}
12638
12639impl<'a> TraversalCodeLookup<'a> {
12640    fn new(
12641        symbols: &'a [TraversalSymbolIndexEntry],
12642        files: &'a [TraversalFileIndexEntry],
12643        routes: &'a [TraversalRouteIndexEntry],
12644        multiplicities: &'a [TraversalMultiplicityIndexEntry],
12645    ) -> Self {
12646        let mut symbol_index = HashMap::new();
12647        for (idx, entry) in symbols.iter().enumerate() {
12648            push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
12649        }
12650        let mut file_index = HashMap::new();
12651        let mut file_path_index = HashMap::new();
12652        for (idx, entry) in files.iter().enumerate() {
12653            push_traversal_token_index(&mut file_index, &entry.tokens, idx);
12654            if let Some(path) = entry.node.path.as_ref() {
12655                file_path_index.insert(path.clone(), path.clone());
12656            }
12657        }
12658        let mut route_index = HashMap::new();
12659        for (idx, entry) in routes.iter().enumerate() {
12660            push_traversal_token_index(&mut route_index, &entry.tokens, idx);
12661        }
12662        let mut multiplicity_index = HashMap::new();
12663        for (idx, entry) in multiplicities.iter().enumerate() {
12664            push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
12665        }
12666        Self {
12667            symbols,
12668            files,
12669            routes,
12670            multiplicities,
12671            symbol_index,
12672            file_index,
12673            route_index,
12674            multiplicity_index,
12675            file_path_index,
12676        }
12677    }
12678
12679    fn touched_files_for_line(&self, line: &str) -> Vec<String> {
12680        let mut touched_files = BTreeSet::new();
12681        for candidate in markdown_code_spans(line)
12682            .into_iter()
12683            .chain(line.split_whitespace().map(str::to_string))
12684        {
12685            for path in traversal_path_candidates(&candidate) {
12686                if let Some(file) = self.file_path_index.get(&path) {
12687                    touched_files.insert(file.clone());
12688                }
12689            }
12690        }
12691        touched_files.into_iter().collect()
12692    }
12693}
12694
12695fn traversal_path_candidates(candidate: &str) -> Vec<String> {
12696    let trimmed = candidate.trim_matches(|ch: char| {
12697        matches!(
12698            ch,
12699            '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
12700        )
12701    });
12702    if trimmed.is_empty() {
12703        return Vec::new();
12704    }
12705    let mut candidates = vec![trimmed.to_string()];
12706    if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
12707        && !path.is_empty()
12708        && line_suffix.chars().all(|ch| ch.is_ascii_digit())
12709    {
12710        candidates.push(path.to_string());
12711    }
12712    candidates
12713}
12714
12715fn parse_worker_result_line(
12716    line: &str,
12717    lookup: &TraversalCodeLookup<'_>,
12718) -> Vec<ParsedWorkerResult> {
12719    if line.trim_start().starts_with("- [") {
12720        return Vec::new();
12721    }
12722    let lower = line.to_ascii_lowercase();
12723    let status =
12724        if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
12725        {
12726            "completed"
12727        } else if lower.contains("blocked") || lower.contains("externally blocked") {
12728            "blocked"
12729        } else {
12730            return Vec::new();
12731        };
12732    let result_prefix_end = ["follow-up", "follow up", "next:"]
12733        .iter()
12734        .filter_map(|marker| lower.find(marker))
12735        .min()
12736        .unwrap_or(line.len());
12737    let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
12738    if ids.is_empty() {
12739        return Vec::new();
12740    }
12741    let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
12742    let all_ids = extract_conflict_target_refs(line);
12743
12744    let touched_files = lookup.touched_files_for_line(line);
12745    let tests = markdown_code_spans(line)
12746        .into_iter()
12747        .filter(|span| span.to_ascii_lowercase().contains("test"))
12748        .collect::<Vec<_>>();
12749
12750    ids.iter()
12751        .map(|id| ParsedWorkerResult {
12752            id: id.clone(),
12753            status: status.to_string(),
12754            touched_files: touched_files.clone(),
12755            tests: tests.clone(),
12756            follow_up_ids: all_ids
12757                .iter()
12758                .filter(|other| *other != id && !result_ids.contains(*other))
12759                .cloned()
12760                .collect(),
12761        })
12762        .collect()
12763}
12764
12765fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
12766    let hinted_path = if path_hint.is_absolute() {
12767        path_hint.to_path_buf()
12768    } else {
12769        root.join(path_hint)
12770    };
12771    if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
12772        return Some(hinted_path);
12773    }
12774    None
12775}
12776
12777fn traversal_markdown_content_looks_like_session(content: &str) -> bool {
12778    parse_agent_doc_session_id(content).is_some()
12779        || content.contains("<!-- agent:exchange")
12780        || content.contains("<!-- agent:backlog")
12781        || content.contains("## Backlog")
12782}
12783
12784fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
12785    let candidate = if path.is_absolute() {
12786        path.to_path_buf()
12787    } else {
12788        source_root.join(path)
12789    };
12790    if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
12791        return false;
12792    }
12793    if !matches!(
12794        candidate.extension().and_then(|ext| ext.to_str()),
12795        Some("md" | "mdx")
12796    ) {
12797        return false;
12798    }
12799    fs::read_to_string(&candidate)
12800        .map(|content| traversal_markdown_content_looks_like_session(&content))
12801        .unwrap_or(false)
12802}
12803
12804fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
12805    if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
12806        return Ok(vec![hinted_path]);
12807    }
12808    let mut files = Vec::new();
12809    let walker = ignore::WalkBuilder::new(root)
12810        .hidden(true)
12811        .git_ignore(true)
12812        .git_global(true)
12813        .git_exclude(true)
12814        .build();
12815    for result in walker {
12816        let entry =
12817            result.with_context(|| format!("walking markdown files under {}", root.display()))?;
12818        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
12819            continue;
12820        }
12821        if traversal_path_is_generated_artifact(root, root, entry.path()) {
12822            continue;
12823        }
12824        if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
12825            files.push(entry.path().to_path_buf());
12826        }
12827    }
12828    files.sort();
12829    Ok(files)
12830}
12831
12832fn traversal_watermark_path(root: &Path, path: &Path) -> String {
12833    path.strip_prefix(root)
12834        .unwrap_or(path)
12835        .to_string_lossy()
12836        .replace('\\', "/")
12837}
12838
12839fn push_traversal_metadata_watermark_part(
12840    root: &Path,
12841    path: &Path,
12842    label: &str,
12843    parts: &mut Vec<String>,
12844) {
12845    let display = traversal_watermark_path(root, path);
12846    match fs::metadata(path) {
12847        Ok(metadata) => {
12848            let (secs, nanos) = metadata
12849                .modified()
12850                .ok()
12851                .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
12852                .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
12853                .unwrap_or((0, 0));
12854            parts.push(format!(
12855                "{label}:{display}:len={}:mtime={secs}.{nanos}",
12856                metadata.len()
12857            ));
12858        }
12859        Err(_) => parts.push(format!("{label}:{display}:missing")),
12860    }
12861}
12862
12863#[derive(Serialize)]
12864struct TraversalSummaryWatermarkRow<'a> {
12865    symbol_name: &'a str,
12866    file_path: &'a str,
12867    entities: &'a Option<Vec<summarize::Entity>>,
12868    relationships: &'a Option<Vec<summarize::Relationship>>,
12869    concept_labels: &'a Option<Vec<String>>,
12870}
12871
12872fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
12873    let summaries_db = root.join(".tsift/summaries.db");
12874    if !summaries_db.exists() {
12875        parts.push("summaries_db:absent".to_string());
12876        return Ok(());
12877    }
12878
12879    match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
12880        .and_then(|summary_db| summary_db.all())
12881    {
12882        Ok(summaries) => {
12883            let rows = summaries
12884                .iter()
12885                .map(|summary| TraversalSummaryWatermarkRow {
12886                    symbol_name: &summary.symbol_name,
12887                    file_path: &summary.file_path,
12888                    entities: &summary.entities,
12889                    relationships: &summary.relationships,
12890                    concept_labels: &summary.concept_labels,
12891                })
12892                .collect::<Vec<_>>();
12893            parts.push(format!(
12894                "summaries_db:rows={}:semantic_hash={}",
12895                rows.len(),
12896                content_hash(&rows)?
12897            ));
12898        }
12899        Err(_) => {
12900            push_traversal_metadata_watermark_part(
12901                root,
12902                &summaries_db,
12903                "summaries_db_unreadable",
12904                parts,
12905            );
12906        }
12907    }
12908    Ok(())
12909}
12910
12911#[cfg(test)]
12912fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
12913    resolution::relative_path_is_generated_artifact(relative)
12914}
12915
12916fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
12917    resolution::path_is_generated_artifact(root, source_root, path)
12918}
12919
12920fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
12921    resolution::index_snapshot_part_is_generated(root, source_root, part)
12922}
12923
12924pub(crate) fn traversal_source_watermark(
12925    root: &Path,
12926    path_hint: &Path,
12927    scope: Option<&str>,
12928    session_only: bool,
12929) -> Result<Option<String>> {
12930    let mut parts = vec![
12931        format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
12932        format!("scope:{}", scope.unwrap_or("root")),
12933        format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
12934        format!("session_only:{session_only}"),
12935    ];
12936
12937    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
12938        let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
12939            Ok(targets) => targets,
12940            Err(_) => return Ok(None),
12941        };
12942        let Some(target) = targets.into_iter().next() else {
12943            return Ok(None);
12944        };
12945        let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
12946            Ok(db) => db,
12947            Err(_) => return Ok(None),
12948        };
12949        parts.push(format!("index_label:{}", target.label));
12950        parts.push(format!(
12951            "index_scope:{}",
12952            target.scope_name.as_deref().unwrap_or("root")
12953        ));
12954        parts.push(format!(
12955            "index_source_root:{}",
12956            traversal_watermark_path(root, &target.source_root)
12957        ));
12958        let mut snapshot_rows = 0usize;
12959        for part in db.source_snapshot_parts()? {
12960            if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
12961                continue;
12962            }
12963            snapshot_rows += 1;
12964            parts.push(format!("index_snapshot:{part}"));
12965        }
12966        parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
12967    }
12968
12969    let markdown_files = markdown_files_for_traversal(root, path_hint)?;
12970    parts.push(format!("markdown_count:{}", markdown_files.len()));
12971    for markdown_path in markdown_files {
12972        push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
12973    }
12974
12975    push_traversal_summaries_watermark_part(root, &mut parts)?;
12976
12977    Ok(Some(content_hash(&parts)?))
12978}
12979
12980fn ranked_symbol_matches<'a>(
12981    query_tokens: &BTreeSet<String>,
12982    entries: &'a [TraversalSymbolIndexEntry],
12983    index: &HashMap<String, Vec<usize>>,
12984) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
12985    let mut scores = BTreeMap::<usize, usize>::new();
12986    for token in query_tokens {
12987        if let Some(indices) = index.get(token) {
12988            for idx in indices {
12989                *scores.entry(*idx).or_default() += 1;
12990            }
12991        }
12992    }
12993    let mut matches = scores
12994        .into_iter()
12995        .map(|(idx, score)| (score, &entries[idx]))
12996        .collect::<Vec<_>>();
12997    matches.sort_by(|(left_score, left), (right_score, right)| {
12998        right_score
12999            .cmp(left_score)
13000            .then_with(|| left.node.label.cmp(&right.node.label))
13001            .then_with(|| left.handle.cmp(&right.handle))
13002    });
13003    matches
13004}
13005
13006fn ranked_file_matches<'a>(
13007    query_tokens: &BTreeSet<String>,
13008    entries: &'a [TraversalFileIndexEntry],
13009    index: &HashMap<String, Vec<usize>>,
13010) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13011    let mut scores = BTreeMap::<usize, usize>::new();
13012    for token in query_tokens {
13013        if let Some(indices) = index.get(token) {
13014            for idx in indices {
13015                *scores.entry(*idx).or_default() += 1;
13016            }
13017        }
13018    }
13019    let mut matches = scores
13020        .into_iter()
13021        .map(|(idx, score)| (score, &entries[idx]))
13022        .collect::<Vec<_>>();
13023    matches.sort_by(|(left_score, left), (right_score, right)| {
13024        right_score
13025            .cmp(left_score)
13026            .then_with(|| left.node.label.cmp(&right.node.label))
13027            .then_with(|| left.handle.cmp(&right.handle))
13028    });
13029    matches
13030}
13031
13032fn ranked_route_matches<'a>(
13033    query_tokens: &BTreeSet<String>,
13034    entries: &'a [TraversalRouteIndexEntry],
13035    index: &HashMap<String, Vec<usize>>,
13036) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13037    let mut scores = BTreeMap::<usize, usize>::new();
13038    for token in query_tokens {
13039        if let Some(indices) = index.get(token) {
13040            for idx in indices {
13041                *scores.entry(*idx).or_default() += 1;
13042            }
13043        }
13044    }
13045    let mut matches = scores
13046        .into_iter()
13047        .map(|(idx, score)| (score, &entries[idx]))
13048        .collect::<Vec<_>>();
13049    matches.sort_by(|(left_score, left), (right_score, right)| {
13050        right_score
13051            .cmp(left_score)
13052            .then_with(|| left.node.label.cmp(&right.node.label))
13053            .then_with(|| left.handle.cmp(&right.handle))
13054    });
13055    matches
13056}
13057
13058fn ranked_multiplicity_matches<'a>(
13059    query_tokens: &BTreeSet<String>,
13060    entries: &'a [TraversalMultiplicityIndexEntry],
13061    index: &HashMap<String, Vec<usize>>,
13062) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13063    let mut scores = BTreeMap::<usize, usize>::new();
13064    for token in query_tokens {
13065        if let Some(indices) = index.get(token) {
13066            for idx in indices {
13067                *scores.entry(*idx).or_default() += 1;
13068            }
13069        }
13070    }
13071    let mut matches = scores
13072        .into_iter()
13073        .map(|(idx, score)| (score, &entries[idx]))
13074        .collect::<Vec<_>>();
13075    matches.sort_by(|(left_score, left), (right_score, right)| {
13076        right_score
13077            .cmp(left_score)
13078            .then_with(|| left.node.kind.cmp(&right.node.kind))
13079            .then_with(|| left.node.label.cmp(&right.node.label))
13080            .then_with(|| left.handle.cmp(&right.handle))
13081    });
13082    matches
13083}
13084
13085fn link_backlog_to_code_nodes(
13086    graph: &mut TraversalGraphBuild,
13087    backlog: &TraversalNode,
13088    text: &str,
13089    lookup: &TraversalCodeLookup<'_>,
13090    limit: usize,
13091) {
13092    let mut query_tokens = traversal_tokens(text);
13093    if let Some(ref_id) = &backlog.ref_id {
13094        query_tokens.extend(traversal_tokens(ref_id));
13095    }
13096    if query_tokens.is_empty() {
13097        return;
13098    }
13099
13100    for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13101        .into_iter()
13102        .take(limit)
13103    {
13104        graph.add_edge(
13105            &backlog.handle,
13106            &entry.handle,
13107            "mentions",
13108            Some("backlog text matches symbol tokens".to_string()),
13109            score,
13110        );
13111    }
13112
13113    for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13114        .into_iter()
13115        .take(limit.min(5))
13116    {
13117        graph.add_edge(
13118            &backlog.handle,
13119            &entry.handle,
13120            "mentions",
13121            Some("backlog text matches file tokens".to_string()),
13122            score,
13123        );
13124    }
13125
13126    for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13127        .into_iter()
13128        .take(limit.min(5))
13129    {
13130        graph.add_edge(
13131            &backlog.handle,
13132            &entry.handle,
13133            "mentions",
13134            Some("backlog text matches route tokens".to_string()),
13135            score,
13136        );
13137    }
13138
13139    for (score, entry) in ranked_multiplicity_matches(
13140        &query_tokens,
13141        lookup.multiplicities,
13142        &lookup.multiplicity_index,
13143    )
13144    .into_iter()
13145    .take(limit.min(5))
13146    {
13147        graph.add_edge(
13148            &backlog.handle,
13149            &entry.handle,
13150            "mentions",
13151            Some("backlog text matches multiplicity tokens".to_string()),
13152            score,
13153        );
13154    }
13155}
13156
13157fn load_agent_doc_traversal_nodes(
13158    root: &Path,
13159    path_hint: &Path,
13160    graph: &mut TraversalGraphBuild,
13161    lookup: &TraversalCodeLookup<'_>,
13162) -> Result<()> {
13163    for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13164        let content = match fs::read_to_string(&markdown_path) {
13165            Ok(content) => content,
13166            Err(err) => {
13167                graph.warnings.push(format!(
13168                    "session artifact unavailable: {}: {err}",
13169                    markdown_path.display()
13170                ));
13171                continue;
13172            }
13173        };
13174        if !traversal_markdown_content_looks_like_session(&content) {
13175            continue;
13176        }
13177
13178        let session_id = parse_agent_doc_session_id(&content);
13179        let session = traversal_session_node(root, &markdown_path, session_id.as_deref());
13180        graph.add_node(session.clone());
13181        let lines = content.lines().collect::<Vec<_>>();
13182        let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13183        for (idx, line) in lines.iter().enumerate() {
13184            let Some((id, text)) = parse_backlog_line(line) else {
13185                continue;
13186            };
13187            let backlog = traversal_backlog_node(root, &markdown_path, &id, &text, idx as i64 + 1);
13188            graph.add_node(backlog.clone());
13189            backlog_by_id.insert(id.clone(), backlog.clone());
13190            graph.add_edge(
13191                &session.handle,
13192                &backlog.handle,
13193                "contains",
13194                Some("session backlog item".to_string()),
13195                1,
13196            );
13197            link_backlog_to_code_nodes(graph, &backlog, &text, lookup, 8);
13198        }
13199
13200        let mut in_queue = false;
13201        let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13202        for (idx, line) in lines.iter().enumerate() {
13203            let trimmed = line.trim();
13204            if trimmed.starts_with("<!-- agent:queue") {
13205                in_queue = true;
13206                continue;
13207            }
13208            if trimmed.starts_with("<!-- /agent:queue") {
13209                in_queue = false;
13210                continue;
13211            }
13212            if !in_queue {
13213                continue;
13214            }
13215            if let Some(dispatch) = parse_queue_dispatch_line(line) {
13216                let dispatch_ref = dispatch.strip_prefix('#').unwrap_or(dispatch.as_str());
13217                let node = traversal_job_packet_node(
13218                    root,
13219                    &markdown_path,
13220                    &format!("dispatch {dispatch}"),
13221                    Some(dispatch_ref),
13222                    "agent-doc dispatch preset",
13223                    idx as i64 + 1,
13224                );
13225                graph.add_node(node.clone());
13226                graph.add_edge(
13227                    &session.handle,
13228                    &node.handle,
13229                    "contains",
13230                    Some("session queued dispatch".to_string()),
13231                    1,
13232                );
13233                continue;
13234            }
13235            if let Some(id) = parse_queue_do_line(line) {
13236                let detail = backlog_by_id
13237                    .get(&id)
13238                    .and_then(|node| node.detail.clone())
13239                    .unwrap_or_else(|| "queued backlog item".to_string());
13240                let node = traversal_job_packet_node(
13241                    root,
13242                    &markdown_path,
13243                    &format!("do #{id}"),
13244                    Some(&id),
13245                    &detail,
13246                    idx as i64 + 1,
13247                );
13248                graph.add_node(node.clone());
13249                graph.add_edge(
13250                    &session.handle,
13251                    &node.handle,
13252                    "contains",
13253                    Some("session queued job packet".to_string()),
13254                    1,
13255                );
13256                if let Some(backlog) = backlog_by_id.get(&id) {
13257                    graph.add_edge(
13258                        &node.handle,
13259                        &backlog.handle,
13260                        "targets",
13261                        Some("queued backlog item".to_string()),
13262                        1,
13263                    );
13264                }
13265                job_by_id.insert(id, node);
13266            }
13267        }
13268
13269        let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13270        for (idx, line) in lines.iter().enumerate() {
13271            for parsed in parse_worker_result_line(line, lookup) {
13272                let line_no = idx as i64 + 1;
13273                if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13274                    continue;
13275                }
13276                let result =
13277                    traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13278                graph.add_node(result.clone());
13279                graph.add_edge(
13280                    &session.handle,
13281                    &result.handle,
13282                    "contains",
13283                    Some("session worker result".to_string()),
13284                    1,
13285                );
13286                if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13287                    graph.add_edge(
13288                        &backlog.handle,
13289                        &result.handle,
13290                        "has_result",
13291                        Some(format!("worker result {}", parsed.status)),
13292                        1,
13293                    );
13294                }
13295                if let Some(job) = job_by_id.get(&parsed.id) {
13296                    graph.add_edge(
13297                        &job.handle,
13298                        &result.handle,
13299                        "has_result",
13300                        Some(format!("queued worker result {}", parsed.status)),
13301                        1,
13302                    );
13303                }
13304                let mut result_text = line.to_string();
13305                if !parsed.touched_files.is_empty() {
13306                    result_text.push(' ');
13307                    result_text.push_str(&parsed.touched_files.join(" "));
13308                }
13309                link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
13310            }
13311        }
13312    }
13313    Ok(())
13314}
13315
13316#[derive(Debug, Clone)]
13317struct AgentDocIndexGate {
13318    db_path: Option<PathBuf>,
13319    source_root: PathBuf,
13320    diagnostics: Vec<String>,
13321}
13322
13323#[derive(Clone, Hash, PartialEq, Eq)]
13324struct AgentDocIndexGateCacheKey {
13325    root: PathBuf,
13326    path_hint: PathBuf,
13327    scope: Option<String>,
13328    packet_label: String,
13329}
13330
13331fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
13332    std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
13333> {
13334    static CACHE: std::sync::OnceLock<
13335        std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
13336    > = std::sync::OnceLock::new();
13337    CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
13338}
13339
13340fn prepare_agent_doc_index_gate_cached(
13341    root: &Path,
13342    path_hint: &Path,
13343    scope: Option<&str>,
13344    packet_label: &str,
13345) -> (AgentDocIndexGate, String) {
13346    let key = AgentDocIndexGateCacheKey {
13347        root: root.to_path_buf(),
13348        path_hint: path_hint.to_path_buf(),
13349        scope: scope.map(str::to_string),
13350        packet_label: packet_label.to_string(),
13351    };
13352    if let Ok(cache) = agent_doc_index_gate_cache().lock()
13353        && let Some(cached) = cache.get(&key)
13354    {
13355        return (
13356            cached.clone(),
13357            "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
13358        );
13359    }
13360    let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
13361    if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
13362        cache.insert(key, gate.clone());
13363    }
13364    (
13365        gate,
13366        "fresh inspection/refresh — cache miss on this preparation key".to_string(),
13367    )
13368}
13369
13370fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
13371    match state {
13372        SearchIndexState::Fresh => None,
13373        SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
13374        SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
13375    }
13376}
13377
13378fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
13379    rebuild_search_target_detail(&RebuildSearchTarget {
13380        label: target.label.clone(),
13381        reason,
13382        reindex_cmd: target.reindex_cmd.clone(),
13383    })
13384}
13385
13386fn index_refresh_diagnostic(
13387    target: &SearchIndexTarget,
13388    reason: RebuildSearchReason,
13389    summary: &index::IndexSummary,
13390    packet_label: &str,
13391) -> String {
13392    let changed = summary.new + summary.modified + summary.deleted;
13393    format!(
13394        "index refreshed: {}; updated {} changed file{} before {}",
13395        index_reason_detail(target, reason),
13396        changed,
13397        if changed == 1 { "" } else { "s" },
13398        packet_label
13399    )
13400}
13401
13402fn index_refresh_fallback_diagnostic(
13403    target: &SearchIndexTarget,
13404    reason: RebuildSearchReason,
13405    err: &anyhow::Error,
13406    packet_label: &str,
13407) -> String {
13408    format!(
13409        "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
13410        index_reason_detail(target, reason),
13411        packet_label
13412    )
13413}
13414
13415fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
13416    if let Some(scope_name) = scope
13417        && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
13418    {
13419        return scope.source_root;
13420    }
13421    if let Some(scope_name) = scope
13422        && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
13423    {
13424        return package.package_root;
13425    }
13426    if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
13427        return scope.source_root;
13428    }
13429    if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
13430        return package.package_root;
13431    }
13432    if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
13433        return scope.source_root;
13434    }
13435    root.to_path_buf()
13436}
13437
13438fn prepare_agent_doc_index_gate(
13439    root: &Path,
13440    path_hint: &Path,
13441    scope: Option<&str>,
13442    packet_label: &str,
13443) -> AgentDocIndexGate {
13444    let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
13445    let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13446        Ok(targets) => targets,
13447        Err(err) => {
13448            return AgentDocIndexGate {
13449                db_path: None,
13450                source_root: fallback_source_root,
13451                diagnostics: vec![format!(
13452                    "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13453                )],
13454            };
13455        }
13456    };
13457    let Some(target) = targets.into_iter().next() else {
13458        return AgentDocIndexGate {
13459            db_path: None,
13460            source_root: fallback_source_root,
13461            diagnostics: vec![format!(
13462                "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
13463            )],
13464        };
13465    };
13466
13467    let state = match inspect_search_index(&target) {
13468        Ok(state) => state,
13469        Err(err) => {
13470            return AgentDocIndexGate {
13471                db_path: None,
13472                source_root: target.source_root,
13473                diagnostics: vec![format!(
13474                    "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
13475                )],
13476            };
13477        }
13478    };
13479
13480    let Some(reason) = index_reason_for_state(state) else {
13481        return AgentDocIndexGate {
13482            db_path: Some(target.db_path),
13483            source_root: target.source_root,
13484            diagnostics: Vec::new(),
13485        };
13486    };
13487
13488    match apply_search_index_update(root, &target) {
13489        Ok(summary) => {
13490            // #gdbgatecold: the index was just rewritten, so any cached
13491            // pre-refresh inspection result for this scope (held by the
13492            // active lazily-backed `InspectScopeGuard`) is stale. Invalidate
13493            // the scope epoch so the next `inspect_read_only` re-reads the
13494            // fresh index.
13495            index::inspect_scope_invalidate_all();
13496            let diagnostics = vec![index_refresh_diagnostic(
13497                &target,
13498                reason,
13499                &summary,
13500                packet_label,
13501            )];
13502            AgentDocIndexGate {
13503                db_path: Some(target.db_path),
13504                source_root: target.source_root,
13505                diagnostics,
13506            }
13507        }
13508        Err(err) => {
13509            let diagnostics = vec![index_refresh_fallback_diagnostic(
13510                &target,
13511                reason,
13512                &err,
13513                packet_label,
13514            )];
13515            AgentDocIndexGate {
13516                db_path: None,
13517                source_root: target.source_root,
13518                diagnostics,
13519            }
13520        }
13521    }
13522}
13523
13524fn add_raw_source_file_nodes(
13525    root: &Path,
13526    source_root: &Path,
13527    graph: &mut TraversalGraphBuild,
13528    file_entries: &mut Vec<TraversalFileIndexEntry>,
13529) -> Result<()> {
13530    let mut entries = walk::walk_files(source_root)?;
13531    entries.sort_by(|left, right| left.path.cmp(&right.path));
13532    for entry in entries {
13533        let file = entry.path.to_string_lossy();
13534        let node = traversal_raw_source_file_node(root, file.as_ref());
13535        let entry = TraversalFileIndexEntry {
13536            handle: node.handle.clone(),
13537            tokens: traversal_node_tokens(&node),
13538            node: node.clone(),
13539        };
13540        graph.add_node(node);
13541        file_entries.push(entry);
13542    }
13543    Ok(())
13544}
13545
13546fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
13547    if scope_root.is_empty() {
13548        return true;
13549    }
13550    path == scope_root || path.starts_with(&format!("{scope_root}/"))
13551}
13552
13553fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
13554    let path = Path::new(file);
13555    if path.is_absolute() {
13556        return path.to_path_buf();
13557    }
13558    let source_candidate = source_root.join(path);
13559    if source_candidate.exists() {
13560        source_candidate
13561    } else {
13562        root.join(path)
13563    }
13564}
13565
13566fn cargo_import_alias_from_line(line: &str) -> Option<String> {
13567    let trimmed = line.trim();
13568    let rest = trimmed
13569        .strip_prefix("pub use ")
13570        .or_else(|| trimmed.strip_prefix("use "))
13571        .or_else(|| trimmed.strip_prefix("extern crate "))?;
13572    let alias = rest
13573        .split([':', ';', ' ', '\t'])
13574        .next()
13575        .unwrap_or_default()
13576        .trim();
13577    (!alias.is_empty()).then(|| alias.to_string())
13578}
13579
13580fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
13581    let mut aliases = BTreeSet::new();
13582    for entry in walk::walk_files(&package.package_root)? {
13583        if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
13584            continue;
13585        }
13586        let content = fs::read_to_string(&entry.path)
13587            .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
13588        aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
13589    }
13590    Ok(aliases)
13591}
13592
13593fn load_multiplicity_traversal_nodes(
13594    root: &Path,
13595    source_root: &Path,
13596    graph: &mut TraversalGraphBuild,
13597    file_handle_by_path: &HashMap<String, String>,
13598    multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
13599) -> Result<()> {
13600    let inventory = multiplicity::discover_cargo_inventory(source_root)?;
13601    let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
13602    for workspace in &inventory.workspaces {
13603        let node = traversal_cargo_workspace_node(root, workspace);
13604        workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
13605        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13606            handle: node.handle.clone(),
13607            tokens: traversal_node_tokens(&node),
13608            node: node.clone(),
13609        });
13610        graph.add_node(node);
13611    }
13612
13613    let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
13614    let mut package_nodes = Vec::new();
13615    for package in &inventory.packages {
13616        let node = traversal_cargo_package_node(root, package);
13617        package_handle_by_name
13618            .entry(package.name.clone())
13619            .or_default()
13620            .push(node.handle.clone());
13621        package_handle_by_name
13622            .entry(package.normalized_name.clone())
13623            .or_default()
13624            .push(node.handle.clone());
13625        multiplicity_entries.push(TraversalMultiplicityIndexEntry {
13626            handle: node.handle.clone(),
13627            tokens: traversal_node_tokens(&node),
13628            node: node.clone(),
13629        });
13630        graph.add_node(node.clone());
13631        package_nodes.push((package, node));
13632    }
13633
13634    for (package, node) in &package_nodes {
13635        if let Some(workspace_handle) =
13636            workspace_handle_by_root.get(&package.relative_workspace_root)
13637        {
13638            graph.add_edge(
13639                workspace_handle,
13640                &node.handle,
13641                "contains_package",
13642                Some("Cargo workspace member package".to_string()),
13643                1,
13644            );
13645        }
13646        let package_root = relativize_pathbuf(&package.package_root, root)
13647            .to_string_lossy()
13648            .replace('\\', "/");
13649        for (file, handle) in file_handle_by_path {
13650            if relative_path_inside_scope(file, &package_root) {
13651                graph.add_edge(
13652                    &node.handle,
13653                    handle,
13654                    "owns_file",
13655                    Some("Cargo package owns source file".to_string()),
13656                    1,
13657                );
13658            }
13659        }
13660        for dependency in &package.dependencies {
13661            if let Some(handles) = package_handle_by_name.get(&dependency.name)
13662                && handles.len() == 1
13663            {
13664                graph.add_edge(
13665                    &node.handle,
13666                    &handles[0],
13667                    "declares_dependency",
13668                    Some(format!("{} Cargo dependency", dependency.kind)),
13669                    1,
13670                );
13671            }
13672        }
13673        for alias in cargo_import_aliases(package)? {
13674            if let Some(handles) = package_handle_by_name.get(&alias)
13675                && handles.len() == 1
13676                && handles[0] != node.handle
13677            {
13678                graph.add_edge(
13679                    &node.handle,
13680                    &handles[0],
13681                    "uses_crate",
13682                    Some("Rust use/extern crate reference".to_string()),
13683                    1,
13684                );
13685                graph.add_edge(
13686                    &node.handle,
13687                    &handles[0],
13688                    "imports",
13689                    Some("Rust use/extern crate import".to_string()),
13690                    1,
13691                );
13692            }
13693        }
13694    }
13695
13696    Ok(())
13697}
13698
13699fn build_traversal_graph_source_with_options(
13700    root: &Path,
13701    path_hint: &Path,
13702    scope: Option<&str>,
13703    session_only: bool,
13704) -> Result<TraversalGraphBuild> {
13705    let mut graph = TraversalGraphBuild::default();
13706    let mut symbol_entries = Vec::new();
13707    let mut file_entries = Vec::new();
13708    let mut route_entries = Vec::new();
13709    let mut multiplicity_entries = Vec::new();
13710    let mut file_handle_by_path = HashMap::<String, String>::new();
13711    let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
13712    if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13713        let (gate, _cache_detail) =
13714            prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
13715        graph.warnings.extend(gate.diagnostics);
13716        let gate_source_root = gate.source_root.clone();
13717
13718        match gate.db_path {
13719            Some(db_path) if db_path.exists() => {
13720                let db = index::IndexDb::open_read_only_resilient(&db_path)?;
13721                let file_paths = db.file_paths()?;
13722                for file in file_paths {
13723                    if traversal_path_is_generated_artifact(
13724                        root,
13725                        &gate_source_root,
13726                        Path::new(&file),
13727                    ) {
13728                        continue;
13729                    }
13730                    let node = traversal_file_node(root, &file);
13731                    let entry = TraversalFileIndexEntry {
13732                        handle: node.handle.clone(),
13733                        tokens: traversal_node_tokens(&node),
13734                        node: node.clone(),
13735                    };
13736                    if let Some(path) = entry.node.path.as_ref() {
13737                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
13738                    }
13739                    graph.add_node(node);
13740                    file_entries.push(entry);
13741                }
13742
13743                let symbols = db.all_symbols()?;
13744                let mut symbol_by_file_name_line = HashMap::new();
13745                let mut span_by_file_name_line = HashMap::new();
13746                let mut first_symbol_by_name = BTreeMap::<String, String>::new();
13747                let mut first_span_by_name = BTreeMap::<String, String>::new();
13748                let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
13749                let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
13750                for symbol in symbols.iter().filter(|symbol| {
13751                    !traversal_path_is_generated_artifact(
13752                        root,
13753                        &gate_source_root,
13754                        Path::new(&symbol.file),
13755                    )
13756                }) {
13757                    let node = traversal_symbol_node(root, symbol);
13758                    let file = relativize(&symbol.file, root);
13759                    symbol_by_file_name_line.insert(
13760                        format!("{file}:{}:{}", symbol.line, symbol.name),
13761                        node.handle.clone(),
13762                    );
13763                    first_symbol_by_name
13764                        .entry(symbol.name.clone())
13765                        .or_insert_with(|| node.handle.clone());
13766                    let entry = TraversalSymbolIndexEntry {
13767                        handle: node.handle.clone(),
13768                        tokens: traversal_node_tokens(&node),
13769                        node: node.clone(),
13770                    };
13771                    graph.add_node(node.clone());
13772                    if let Some(file_handle) = file_handle_by_path.get(&file) {
13773                        graph.add_edge(
13774                            file_handle,
13775                            &node.handle,
13776                            "defines",
13777                            Some("file defines symbol".to_string()),
13778                            1,
13779                        );
13780                    }
13781                    if !source_by_file.contains_key(&symbol.file) {
13782                        let source_path =
13783                            traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
13784                        source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
13785                    }
13786                    if let Some(Some(source)) = source_by_file.get(&symbol.file)
13787                        && let Some((ast_node, mut ast_entry)) =
13788                            traversal_ast_span_node(root, symbol, source, &symbols)
13789                    {
13790                        ast_entry.symbol_handle = node.handle.clone();
13791                        ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
13792                        span_by_file_name_line.insert(
13793                            format!("{file}:{}:{}", symbol.line, symbol.name),
13794                            ast_node.handle.clone(),
13795                        );
13796                        first_span_by_name
13797                            .entry(symbol.name.clone())
13798                            .or_insert_with(|| ast_node.handle.clone());
13799                        graph.add_node(ast_node.clone());
13800                        graph.add_edge(
13801                            &node.handle,
13802                            &ast_node.handle,
13803                            "has_ast_span",
13804                            Some("symbol projects to indexed AST span".to_string()),
13805                            1,
13806                        );
13807                        graph.add_edge(
13808                            &ast_node.handle,
13809                            &node.handle,
13810                            "represents_symbol",
13811                            Some("AST span represents indexed symbol".to_string()),
13812                            1,
13813                        );
13814                        ast_entries.push(ast_entry);
13815                    }
13816                    symbol_entries.push(entry);
13817                }
13818                link_ast_navigation_edges(&mut graph, &ast_entries);
13819                link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
13820
13821                if !bounded_session_projection {
13822                    for edge in db.all_stored_edges()? {
13823                        if traversal_path_is_generated_artifact(
13824                            root,
13825                            &gate_source_root,
13826                            Path::new(&edge.caller_file),
13827                        ) {
13828                            continue;
13829                        }
13830                        let caller_file = relativize(&edge.caller_file, root);
13831                        let caller_key =
13832                            format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
13833                        let Some(caller_handle) =
13834                            symbol_by_file_name_line.get(&caller_key).cloned()
13835                        else {
13836                            continue;
13837                        };
13838                        let callee_handle = if let Some(handle) =
13839                            first_symbol_by_name.get(&edge.callee_name)
13840                        {
13841                            handle.clone()
13842                        } else {
13843                            let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
13844                            let handle = node.handle.clone();
13845                            graph.add_node(node);
13846                            handle
13847                        };
13848                        graph.add_edge(
13849                            &caller_handle,
13850                            &callee_handle,
13851                            "calls",
13852                            Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
13853                            1,
13854                        );
13855                        if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
13856                            && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
13857                        {
13858                            graph.add_edge(
13859                                caller_span,
13860                                callee_span,
13861                                "calls",
13862                                Some(format!(
13863                                    "AST call site {}:{}",
13864                                    caller_file, edge.call_site_line
13865                                )),
13866                                1,
13867                            );
13868                        }
13869                    }
13870                }
13871
13872                for route in db.all_routes()? {
13873                    if traversal_path_is_generated_artifact(
13874                        root,
13875                        &gate_source_root,
13876                        Path::new(&route.file),
13877                    ) {
13878                        continue;
13879                    }
13880                    let node = traversal_route_node(root, &route);
13881                    let entry = TraversalRouteIndexEntry {
13882                        handle: node.handle.clone(),
13883                        tokens: traversal_node_tokens(&node),
13884                        node: node.clone(),
13885                    };
13886                    graph.add_node(node.clone());
13887                    if let Some(path) = node.path.as_ref()
13888                        && let Some(file_handle) = file_handle_by_path.get(path)
13889                    {
13890                        graph.add_edge(
13891                            file_handle,
13892                            &node.handle,
13893                            "defines_route",
13894                            Some("file declares route".to_string()),
13895                            1,
13896                        );
13897                    }
13898                    let handler_handle =
13899                        if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
13900                            handle.clone()
13901                        } else {
13902                            let node = traversal_unresolved_symbol_node(root, &route.handler_name);
13903                            let handle = node.handle.clone();
13904                            graph.add_node(node);
13905                            handle
13906                        };
13907                    graph.add_edge(
13908                        &entry.handle,
13909                        &handler_handle,
13910                        "handled_by",
13911                        Some("route handler reference".to_string()),
13912                        1,
13913                    );
13914                    if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
13915                        graph.add_edge(
13916                            &entry.handle,
13917                            handler_span,
13918                            "handled_by",
13919                            Some("route handler AST span".to_string()),
13920                            1,
13921                        );
13922                        graph.add_edge(
13923                            handler_span,
13924                            &entry.handle,
13925                            "handles_route",
13926                            Some("AST span handles route".to_string()),
13927                            1,
13928                        );
13929                    }
13930                    route_entries.push(entry);
13931                }
13932            }
13933            _ => {
13934                add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
13935                    .with_context(|| {
13936                    format!(
13937                        "loading raw source fallback nodes from {}",
13938                        gate_source_root.display()
13939                    )
13940                })?;
13941                for entry in &file_entries {
13942                    if let Some(path) = entry.node.path.as_ref() {
13943                        file_handle_by_path.insert(path.clone(), entry.handle.clone());
13944                    }
13945                }
13946            }
13947        }
13948        load_multiplicity_traversal_nodes(
13949            root,
13950            &gate_source_root,
13951            &mut graph,
13952            &file_handle_by_path,
13953            &mut multiplicity_entries,
13954        )?;
13955    }
13956
13957    let code_lookup = TraversalCodeLookup::new(
13958        &symbol_entries,
13959        &file_entries,
13960        &route_entries,
13961        &multiplicity_entries,
13962    );
13963    load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
13964    Ok(graph)
13965}
13966
13967#[cfg(test)]
13968fn build_traversal_graph_source(
13969    root: &Path,
13970    path_hint: &Path,
13971    scope: Option<&str>,
13972) -> Result<TraversalGraphBuild> {
13973    build_traversal_graph_source_with_options(root, path_hint, scope, false)
13974}
13975
13976pub(crate) fn write_traversal_graph_store_with_options(
13977    root: &Path,
13978    path_hint: &Path,
13979    scope: Option<&str>,
13980    session_only: bool,
13981) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
13982    let source_graph =
13983        build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
13984    let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
13985    let graph_db = graph_substrate_db_path(root, scope);
13986    let mut store = SqliteGraphStore::open(&graph_db)?;
13987    let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
13988        .ok()
13989        .flatten()
13990        .or_else(|| graph_projection_content_hash(&projection));
13991    let refresh = store.replace_projection_with_version(
13992        scope.unwrap_or("root"),
13993        &projection,
13994        Some(GRAPH_PROJECTION_VERSION),
13995        source_watermark,
13996    )?;
13997    Ok((source_graph, refresh))
13998}
13999
14000pub(crate) fn write_traversal_graph_store(
14001    root: &Path,
14002    path_hint: &Path,
14003    scope: Option<&str>,
14004) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14005    write_traversal_graph_store_with_options(root, path_hint, scope, false)
14006}
14007
14008fn refresh_traversal_graph_store_with_options(
14009    root: &Path,
14010    path_hint: &Path,
14011    scope: Option<&str>,
14012    session_only: bool,
14013) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14014    let (source_graph, refresh) =
14015        write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14016    let graph_db = graph_substrate_db_path(root, scope);
14017    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14018    let mut graph = traversal_graph_from_store(root, &store)?;
14019    graph.warnings = source_graph.warnings;
14020    Ok((graph, refresh))
14021}
14022
14023fn refresh_traversal_graph_store(
14024    root: &Path,
14025    path_hint: &Path,
14026    scope: Option<&str>,
14027) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14028    refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14029}
14030
14031pub(crate) fn build_traversal_graph(
14032    root: &Path,
14033    path_hint: &Path,
14034    scope: Option<&str>,
14035) -> Result<TraversalGraphBuild> {
14036    let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14037    Ok(graph)
14038}
14039
14040fn traversal_query_kind_priority(kind: &str) -> usize {
14041    match kind {
14042        "backlog" => 0,
14043        "job_packet" => 1,
14044        "worker_result" => 2,
14045        "symbol" => 3,
14046        "ast_span" => 4,
14047        "file" => 5,
14048        "route" => 6,
14049        "cargo_package" => 7,
14050        "cargo_workspace" => 8,
14051        "session" => 9,
14052        "semantic_concept" => 10,
14053        "semantic_entity" => 11,
14054        _ => 12,
14055    }
14056}
14057
14058fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14059    let trimmed = query.trim();
14060    if trimmed.is_empty() {
14061        return None;
14062    }
14063    let kind_priority = traversal_query_kind_priority(&node.kind);
14064    if node.handle == trimmed {
14065        return Some((0, kind_priority, node.handle.clone()));
14066    }
14067    if node.path.as_deref() == Some(trimmed) {
14068        let path_priority = if node.kind == "file" {
14069            0
14070        } else {
14071            kind_priority.saturating_add(1)
14072        };
14073        return Some((1, path_priority, node.handle.clone()));
14074    }
14075    let normalized_backlog = trimmed.trim_start_matches('#');
14076    if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14077    {
14078        return Some((2, kind_priority, node.handle.clone()));
14079    }
14080    if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14081        return Some((3, kind_priority, node.handle.clone()));
14082    }
14083    None
14084}
14085
14086fn resolve_traversal_node<'a>(
14087    graph: &'a TraversalGraphBuild,
14088    query: &str,
14089) -> Option<&'a TraversalNode> {
14090    graph
14091        .nodes
14092        .values()
14093        .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14094        .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14095        .map(|(_, node)| node)
14096}
14097
14098fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14099    let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14100    for edge in edges {
14101        adj.entry(edge.from.clone())
14102            .or_default()
14103            .insert(edge.to.clone());
14104        adj.entry(edge.to.clone())
14105            .or_default()
14106            .insert(edge.from.clone());
14107    }
14108    adj.into_iter()
14109        .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14110        .collect()
14111}
14112
14113fn traversal_shortest_handles(
14114    edges: &[TraversalEdge],
14115    from: &str,
14116    to: &str,
14117) -> Option<Vec<String>> {
14118    if from == to {
14119        return Some(vec![from.to_string()]);
14120    }
14121    let adj = traversal_adjacency(edges);
14122    if !adj.contains_key(from) || !adj.contains_key(to) {
14123        return None;
14124    }
14125    let mut visited = BTreeSet::new();
14126    let mut queue = VecDeque::new();
14127    let mut parent = BTreeMap::<String, String>::new();
14128    visited.insert(from.to_string());
14129    queue.push_back(from.to_string());
14130    while let Some(current) = queue.pop_front() {
14131        if let Some(neighbors) = adj.get(&current) {
14132            for neighbor in neighbors {
14133                if visited.insert(neighbor.clone()) {
14134                    parent.insert(neighbor.clone(), current.clone());
14135                    if neighbor == to {
14136                        let mut path = vec![to.to_string()];
14137                        let mut cursor = to.to_string();
14138                        while let Some(prev) = parent.get(&cursor) {
14139                            path.push(prev.clone());
14140                            cursor = prev.clone();
14141                        }
14142                        path.reverse();
14143                        return Some(path);
14144                    }
14145                    queue.push_back(neighbor.clone());
14146                }
14147            }
14148        }
14149    }
14150    None
14151}
14152
14153fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14154    let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14155    for edge in edges {
14156        let neighbor = if edge.from == current {
14157            edge.to.as_str()
14158        } else if edge.to == current {
14159            edge.from.as_str()
14160        } else {
14161            continue;
14162        };
14163        let score = traversal_relation_score(edge, current);
14164        best_score_by_neighbor
14165            .entry(neighbor.to_string())
14166            .and_modify(|best| *best = (*best).max(score))
14167            .or_insert(score);
14168    }
14169    let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14170    ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14171        right_score
14172            .cmp(left_score)
14173            .then_with(|| left_handle.cmp(right_handle))
14174    });
14175    ranked.into_iter().map(|(handle, _)| handle).collect()
14176}
14177
14178fn traversal_neighborhood_handles(
14179    edges: &[TraversalEdge],
14180    origin: &str,
14181    depth: usize,
14182    limit: usize,
14183) -> BTreeSet<String> {
14184    let mut seen = BTreeSet::new();
14185    let mut queue = VecDeque::new();
14186    seen.insert(origin.to_string());
14187    queue.push_back((origin.to_string(), 0usize));
14188    while let Some((current, current_depth)) = queue.pop_front() {
14189        if current_depth >= depth {
14190            continue;
14191        }
14192        for neighbor in traversal_scored_neighbors(edges, &current) {
14193            if limit > 0 && seen.len() >= limit {
14194                return seen;
14195            }
14196            if seen.insert(neighbor.clone()) {
14197                queue.push_back((neighbor, current_depth + 1));
14198            }
14199        }
14200    }
14201    seen
14202}
14203
14204fn traversal_edges_between(
14205    handles: &BTreeSet<String>,
14206    edges: &[TraversalEdge],
14207) -> Vec<TraversalEdge> {
14208    edges
14209        .iter()
14210        .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14211        .cloned()
14212        .collect()
14213}
14214
14215fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14216    let mut result = Vec::new();
14217    for pair in path.windows(2) {
14218        if let Some(edge) = edges.iter().find(|edge| {
14219            (edge.from == pair[0] && edge.to == pair[1])
14220                || (edge.from == pair[1] && edge.to == pair[0])
14221        }) {
14222            result.push(edge.clone());
14223        }
14224    }
14225    result
14226}
14227
14228fn sorted_traversal_nodes<'a>(
14229    nodes: impl IntoIterator<Item = &'a TraversalNode>,
14230) -> Vec<TraversalNode> {
14231    let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14232    nodes.sort_by(|left, right| {
14233        left.kind
14234            .cmp(&right.kind)
14235            .then_with(|| left.label.cmp(&right.label))
14236            .then_with(|| left.path.cmp(&right.path))
14237            .then_with(|| left.handle.cmp(&right.handle))
14238    });
14239    nodes
14240}
14241
14242fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14243    let base = match edge.relation.as_str() {
14244        "mentions" => 100,
14245        "contains" => 80,
14246        "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14247        "contains_embedded_symbol" | "embedded_in_fence" => 77,
14248        "contains_markdown_block"
14249        | "contains_embedded_code"
14250        | "enclosing_module"
14251        | "enclosing_section" => 76,
14252        "calls" => {
14253            if edge.from == origin {
14254                70
14255            } else {
14256                65
14257            }
14258        }
14259        "handled_by" | "handles_route" => 68,
14260        "defines_route" => 62,
14261        "imports" => 62,
14262        "previous_sibling" | "next_sibling" => 54,
14263        "mentions_concept" | "mentions_entity" => 66,
14264        "semantic_relation" => 64,
14265        "tagged_concept" | "related_concept" => 58,
14266        "defines" => {
14267            if edge.from == origin {
14268                60
14269            } else {
14270                55
14271            }
14272        }
14273        _ => 10,
14274    };
14275    base + edge.weight
14276}
14277
14278fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
14279    match edge.relation.as_str() {
14280        "mentions" => "matched from backlog/session text".to_string(),
14281        "contains" => "contained in the selected session artifact".to_string(),
14282        "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
14283        "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
14284        "parent" => "parent AST span".to_string(),
14285        "child" => "child AST span".to_string(),
14286        "previous_sibling" => "previous AST sibling".to_string(),
14287        "next_sibling" => "next AST sibling".to_string(),
14288        "contains_markdown_block" => "Markdown section block".to_string(),
14289        "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
14290        "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
14291        "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
14292        "enclosing_module" => "nearest enclosing module".to_string(),
14293        "enclosing_section" => "nearest enclosing Markdown section".to_string(),
14294        "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
14295        "defines" => "file that defines the selected symbol".to_string(),
14296        "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
14297        "defines_route" => "file that declares the selected route".to_string(),
14298        "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
14299        "handled_by" => "route handled by the selected symbol".to_string(),
14300        "handles_route" => "route handled by the selected AST span".to_string(),
14301        "imports" => "import dependency from the selected package".to_string(),
14302        "mentions_concept" => "cached summary concept for the selected source".to_string(),
14303        "mentions_entity" => "cached summary entity for the selected source".to_string(),
14304        "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
14305        "tagged_concept" => "concept label attached to the selected entity".to_string(),
14306        "related_concept" => "co-occurring cached summary concept".to_string(),
14307        "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
14308        "calls" => "caller of the selected symbol".to_string(),
14309        other => format!("connected by {other}"),
14310    }
14311}
14312
14313fn traversal_recommendations(
14314    graph: &TraversalGraphBuild,
14315    origin: Option<&str>,
14316    shortest_path: Option<&[String]>,
14317    limit: usize,
14318) -> Vec<TraversalRecommendation> {
14319    let Some(origin) = origin else {
14320        return Vec::new();
14321    };
14322    let mut recommendations = Vec::new();
14323    let mut seen = BTreeSet::new();
14324
14325    if let Some(path) = shortest_path
14326        && path.len() > 1
14327        && path.first().is_some_and(|handle| handle == origin)
14328        && let Some(next) = graph.nodes.get(&path[1])
14329    {
14330        seen.insert(next.handle.clone());
14331        recommendations.push(TraversalRecommendation {
14332            handle: next.handle.clone(),
14333            kind: next.kind.clone(),
14334            label: next.label.clone(),
14335            reason: "next hop on shortest path".to_string(),
14336            score: 1_000,
14337            expand: next.expand.clone(),
14338        });
14339    }
14340
14341    let mut candidates = graph
14342        .edges
14343        .iter()
14344        .filter_map(|edge| {
14345            let neighbor = if edge.from == origin {
14346                edge.to.as_str()
14347            } else if edge.to == origin {
14348                edge.from.as_str()
14349            } else {
14350                return None;
14351            };
14352            let node = graph.nodes.get(neighbor)?;
14353            Some((traversal_relation_score(edge, origin), edge, node))
14354        })
14355        .collect::<Vec<_>>();
14356    candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
14357        right_score
14358            .cmp(left_score)
14359            .then_with(|| left.kind.cmp(&right.kind))
14360            .then_with(|| left.label.cmp(&right.label))
14361            .then_with(|| left.handle.cmp(&right.handle))
14362    });
14363
14364    let max = if limit == 0 { usize::MAX } else { limit };
14365    for (score, edge, node) in candidates {
14366        if recommendations.len() >= max {
14367            break;
14368        }
14369        if seen.insert(node.handle.clone()) {
14370            recommendations.push(TraversalRecommendation {
14371                handle: node.handle.clone(),
14372                kind: node.kind.clone(),
14373                label: node.label.clone(),
14374                reason: traversal_recommendation_reason(edge, origin),
14375                score,
14376                expand: node.expand.clone(),
14377            });
14378        }
14379    }
14380
14381    recommendations
14382}
14383
14384fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
14385    let scale = nodes.saturating_add(edges);
14386    if scale <= 80 {
14387        ExplorationBudget {
14388            project_size: "small".to_string(),
14389            max_source_windows: 8,
14390            lines_per_window: 96,
14391            relationship_limit: 40,
14392        }
14393    } else if scale <= 800 {
14394        ExplorationBudget {
14395            project_size: "medium".to_string(),
14396            max_source_windows: 6,
14397            lines_per_window: 80,
14398            relationship_limit: 32,
14399        }
14400    } else {
14401        ExplorationBudget {
14402            project_size: "large".to_string(),
14403            max_source_windows: 4,
14404            lines_per_window: 64,
14405            relationship_limit: 24,
14406        }
14407    }
14408}
14409
14410fn exploration_node_label(node: &TraversalNode) -> String {
14411    format!("{}:{}", node.kind, node.label)
14412}
14413
14414fn exploration_source_window_for_node(
14415    root: &Path,
14416    node: &TraversalNode,
14417    budget: &ExplorationBudget,
14418) -> Option<ExplorationSourceWindow> {
14419    let file = node.path.as_ref()?;
14420    let anchor = node
14421        .line
14422        .and_then(|line| usize::try_from(line).ok())
14423        .and_then(|line| line.checked_add(1))
14424        .unwrap_or(1);
14425    let context_before = budget.lines_per_window / 3;
14426    let start = anchor.saturating_sub(context_before).max(1);
14427    let end = start
14428        .saturating_add(budget.lines_per_window)
14429        .saturating_sub(1);
14430    let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
14431    Some(ExplorationSourceWindow {
14432        handle,
14433        file: file.clone(),
14434        start,
14435        end,
14436        reason: format!("cluster around {}", exploration_node_label(node)),
14437        expand: source_read_command(root, file, start, budget.lines_per_window),
14438    })
14439}
14440
14441fn build_exploration_packet(
14442    root: &Path,
14443    totals: &TraversalTotals,
14444    selected_nodes: &[TraversalNode],
14445    selected_edges: &[TraversalEdge],
14446) -> ExplorationPacket {
14447    let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
14448    let node_by_handle = selected_nodes
14449        .iter()
14450        .map(|node| (node.handle.as_str(), node))
14451        .collect::<BTreeMap<_, _>>();
14452    let relationship_map = selected_edges
14453        .iter()
14454        .take(budget.relationship_limit)
14455        .filter_map(|edge| {
14456            let from = node_by_handle.get(edge.from.as_str())?;
14457            let to = node_by_handle.get(edge.to.as_str())?;
14458            Some(ExplorationRelation {
14459                from: exploration_node_label(from),
14460                relation: edge.relation.clone(),
14461                to: exploration_node_label(to),
14462                label: edge.label.clone(),
14463            })
14464        })
14465        .collect::<Vec<_>>();
14466
14467    let mut seen_windows = BTreeSet::new();
14468    let mut source_windows = Vec::new();
14469    for node in selected_nodes {
14470        if source_windows.len() >= budget.max_source_windows {
14471            break;
14472        }
14473        let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
14474            continue;
14475        };
14476        let key = (window.file.clone(), window.start, window.end);
14477        if seen_windows.insert(key) {
14478            source_windows.push(window);
14479        }
14480    }
14481
14482    ExplorationPacket {
14483        budget,
14484        relationship_map,
14485        source_windows,
14486        worker_context: Vec::new(),
14487        no_reread_guidance:
14488            "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
14489                .to_string(),
14490    }
14491}
14492
14493pub(crate) fn traversal_report(
14494    root: &Path,
14495    scope: Option<&str>,
14496    graph: TraversalGraphBuild,
14497    query: Option<&str>,
14498    target: Option<&str>,
14499    depth: usize,
14500    limit: usize,
14501) -> Result<TraversalReport> {
14502    let totals = TraversalTotals {
14503        nodes: graph.nodes.len(),
14504        edges: graph.edges.len(),
14505    };
14506    let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
14507    let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
14508    if let Some(query) = query
14509        && origin_node.is_none()
14510    {
14511        bail!("traversal node not found: {}", query);
14512    }
14513    if let Some(target) = target
14514        && target_node.is_none()
14515    {
14516        bail!("traversal target not found: {}", target);
14517    }
14518
14519    let (mode, selected_nodes, selected_edges, shortest_path) =
14520        if let (Some(origin), Some(target)) = (origin_node, target_node) {
14521            if let Some(handles) =
14522                traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
14523            {
14524                let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
14525                let nodes = handles
14526                    .iter()
14527                    .filter_map(|handle| graph.nodes.get(handle).cloned())
14528                    .collect::<Vec<_>>();
14529                let edges = traversal_path_edges(&handles, &graph.edges);
14530                let path = TraversalPathReport {
14531                    from: origin.clone(),
14532                    to: target.clone(),
14533                    hops: handles.len().saturating_sub(1),
14534                    nodes: nodes.clone(),
14535                    edges: edges.clone(),
14536                };
14537                (
14538                    "path".to_string(),
14539                    nodes,
14540                    traversal_edges_between(&handle_set, &graph.edges),
14541                    Some(path),
14542                )
14543            } else {
14544                (
14545                    "path".to_string(),
14546                    vec![origin.clone(), target.clone()],
14547                    Vec::new(),
14548                    None,
14549                )
14550            }
14551        } else if let Some(origin) = origin_node {
14552            let handles =
14553                traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
14554            let nodes =
14555                sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
14556            let edges = traversal_edges_between(&handles, &graph.edges);
14557            ("neighborhood".to_string(), nodes, edges, None)
14558        } else {
14559            let mut nodes = sorted_traversal_nodes(graph.nodes.values());
14560            let truncated_nodes = limit > 0 && nodes.len() > limit;
14561            if truncated_nodes {
14562                nodes.truncate(limit);
14563            }
14564            let handles = nodes
14565                .iter()
14566                .map(|node| node.handle.clone())
14567                .collect::<BTreeSet<_>>();
14568            let mut edges = traversal_edges_between(&handles, &graph.edges);
14569            let truncated_edges = limit > 0 && edges.len() > limit;
14570            if truncated_edges {
14571                edges.truncate(limit);
14572            }
14573            ("export".to_string(), nodes, edges, None)
14574        };
14575
14576    let shortest_handles = shortest_path.as_ref().map(|path| {
14577        path.nodes
14578            .iter()
14579            .map(|node| node.handle.clone())
14580            .collect::<Vec<_>>()
14581    });
14582    let recommendations = traversal_recommendations(
14583        &graph,
14584        origin_node.map(|node| node.handle.as_str()),
14585        shortest_handles.as_deref(),
14586        if limit == 0 { 10 } else { limit.min(10) },
14587    );
14588    let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
14589    let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
14590
14591    Ok(TraversalReport {
14592        root: root.to_string_lossy().to_string(),
14593        scope: scope.map(str::to_string),
14594        mode,
14595        totals,
14596        query: query.map(str::to_string),
14597        target: target.map(str::to_string),
14598        nodes: selected_nodes,
14599        edges: selected_edges,
14600        shortest_path,
14601        recommendations,
14602        exploration,
14603        truncated,
14604        warnings: graph.warnings,
14605    })
14606}
14607
14608fn html_escape(input: &str) -> String {
14609    input
14610        .replace('&', "&amp;")
14611        .replace('<', "&lt;")
14612        .replace('>', "&gt;")
14613        .replace('"', "&quot;")
14614        .replace('\'', "&#39;")
14615}
14616
14617pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
14618    let json = serde_json::to_string(report)?.replace("</", "<\\/");
14619    let mut html = String::new();
14620    html.push_str(
14621        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
14622    );
14623    html.push_str(
14624        r#"<style>
14625:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
14626@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
14627*{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}}
14628</style>"#,
14629    );
14630    html.push_str("</head><body>");
14631    html.push_str("<div class=\"page\">");
14632    html.push_str(&format!(
14633        "<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>",
14634        html_escape(&report.mode),
14635        report.nodes.len(),
14636        report.totals.nodes,
14637        report.edges.len(),
14638        report.totals.edges
14639    ));
14640    html.push_str(
14641        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>"#,
14642    );
14643    html.push_str("<script id=\"graph-data\" type=\"application/json\">");
14644    html.push_str(&json);
14645    html.push_str(
14646        r##"</script><script>
14647const report = JSON.parse(document.getElementById("graph-data").textContent);
14648const svg = document.getElementById("graph-canvas");
14649const list = document.getElementById("node-list");
14650const selected = document.getElementById("selected");
14651const filter = document.getElementById("filter");
14652const legend = document.getElementById("legend");
14653const nodes = report.nodes.map((node, index) => ({...node, index}));
14654const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
14655const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
14656const colorByKind = new Map([
14657  ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
14658  ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
14659  ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
14660  ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
14661]);
14662function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
14663function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
14664function text(value){ return value == null ? "" : String(value); }
14665function matches(node, query){
14666  if (!query) return true;
14667  const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
14668  return haystack.includes(query);
14669}
14670function layout(){
14671  const rect = svg.getBoundingClientRect();
14672  const width = rect.width || 900;
14673  const height = rect.height || 650;
14674  const cx = width / 2;
14675  const cy = height / 2;
14676  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14677  const counts = new Map();
14678  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
14679  const offsets = new Map();
14680  for (const node of nodes) {
14681    const group = kinds.indexOf(node.kind);
14682    const index = offsets.get(node.kind) || 0;
14683    offsets.set(node.kind, index + 1);
14684    const groupCount = counts.get(node.kind) || 1;
14685    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
14686    const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
14687    node.x = cx + Math.cos(angle) * ring;
14688    node.y = cy + Math.sin(angle) * ring;
14689  }
14690}
14691function draw(){
14692  const query = filter.value.trim().toLowerCase();
14693  const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
14694  svg.innerHTML = "";
14695  for (const edge of edges) {
14696    if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
14697    const from = nodeByHandle.get(edge.from);
14698    const to = nodeByHandle.get(edge.to);
14699    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
14700    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
14701    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
14702    line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
14703    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
14704    svg.appendChild(line);
14705  }
14706  for (const node of nodes) {
14707    if (!visible.has(node.handle)) continue;
14708    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
14709    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
14710    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
14711    circle.setAttribute("fill", color(node.kind));
14712    circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
14713    circle.addEventListener("click", () => selectNode(node));
14714    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
14715    svg.appendChild(circle);
14716    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
14717    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
14718    label.setAttribute("class", "node-label");
14719    label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
14720    svg.appendChild(label);
14721  }
14722  renderList(query);
14723}
14724function renderLegend(){
14725  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
14726  legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">&#9679;</b> ${kind}</span>`).join("");
14727}
14728function renderList(query){
14729  const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
14730  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("");
14731  for (const row of list.querySelectorAll(".row")) {
14732    row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
14733  }
14734}
14735function selectNode(node){
14736  const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
14737  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>`;
14738}
14739function escapeHtml(value){
14740  return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));
14741}
14742filter.addEventListener("input", draw);
14743window.addEventListener("resize", () => { layout(); draw(); });
14744renderLegend();
14745layout();
14746draw();
14747if (nodes.length) selectNode(nodes[0]);
14748</script></div></body></html>"##,
14749    );
14750    Ok(html)
14751}
14752
14753fn semantic_related_report_from_store(
14754    root: &Path,
14755    scope: Option<&str>,
14756    query: &str,
14757    limit: usize,
14758    kind: SemanticRelatedKind,
14759    store: &impl GraphStore,
14760) -> Result<SemanticRelatedReport> {
14761    if query.trim().is_empty() {
14762        bail!("semantic query cannot be empty");
14763    }
14764
14765    let query_embedding = semantic_embedding(query);
14766    let node_kinds: &[&str] = match kind {
14767        SemanticRelatedKind::Concept => &["semantic_concept"],
14768        SemanticRelatedKind::Entity => &["semantic_entity"],
14769        SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
14770    };
14771
14772    let mut items = Vec::new();
14773    for node_kind in node_kinds {
14774        for node in store.nodes_by_kind(node_kind)? {
14775            let Some(embedding) = node
14776                .properties
14777                .get("embedding")
14778                .and_then(|value| parse_semantic_embedding_property(value))
14779            else {
14780                continue;
14781            };
14782            let score = semantic_cosine(&query_embedding, &embedding);
14783            items.push(SemanticRelatedItem {
14784                handle: node
14785                    .properties
14786                    .get("handle")
14787                    .cloned()
14788                    .unwrap_or_else(|| node.id.clone()),
14789                kind: node.kind,
14790                label: node.label,
14791                score,
14792                file_path: node
14793                    .properties
14794                    .get("source_file")
14795                    .or_else(|| node.properties.get("path"))
14796                    .cloned(),
14797                source_symbol: node.properties.get("source_symbol").cloned(),
14798                detail: node
14799                    .properties
14800                    .get("description")
14801                    .or_else(|| node.properties.get("detail"))
14802                    .cloned(),
14803                expand: node
14804                    .properties
14805                    .get("expand")
14806                    .cloned()
14807                    .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
14808            });
14809        }
14810    }
14811
14812    items.sort_by(|left, right| {
14813        right
14814            .score
14815            .partial_cmp(&left.score)
14816            .unwrap_or(Ordering::Equal)
14817            .then_with(|| left.kind.cmp(&right.kind))
14818            .then_with(|| left.label.cmp(&right.label))
14819            .then_with(|| left.handle.cmp(&right.handle))
14820    });
14821    if limit > 0 && items.len() > limit {
14822        items.truncate(limit);
14823    }
14824
14825    let mut warnings = Vec::new();
14826    if items.is_empty() {
14827        warnings.push(
14828            "no semantic graph rows found; run `tsift summarize --extract <path>` first"
14829                .to_string(),
14830        );
14831    }
14832
14833    Ok(SemanticRelatedReport {
14834        root: root.to_string_lossy().to_string(),
14835        scope: scope.map(str::to_string),
14836        query: query.to_string(),
14837        embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
14838        count: items.len(),
14839        items,
14840        warnings,
14841    })
14842}
14843
14844fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
14845    Ok(store.nodes_by_kind("semantic_concept")?.len()
14846        + store.nodes_by_kind("semantic_entity")?.len())
14847}
14848
14849fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
14850    if limit == 0 {
14851        return 0;
14852    }
14853    limit.saturating_mul(4).clamp(
14854        GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
14855        GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
14856    )
14857}
14858
14859fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
14860    if limit == 0 {
14861        return usize::MAX;
14862    }
14863    limit.saturating_mul(3).max(limit).max(seed_count)
14864}
14865
14866fn graph_db_semantic_edge_other_id<'a>(
14867    edge: &'a SubstrateGraphEdge,
14868    current_id: &str,
14869) -> Option<&'a str> {
14870    if edge.from_id == current_id {
14871        Some(edge.to_id.as_str())
14872    } else if edge.to_id == current_id {
14873        Some(edge.from_id.as_str())
14874    } else {
14875        None
14876    }
14877}
14878
14879fn graph_db_semantic_edge_score(edge: &SubstrateGraphEdge, current_id: &str) -> i64 {
14880    let mut score = resolution::edge_kind_rank_score(&edge.kind).saturating_mul(10);
14881    score += if edge.from_id == current_id { 8 } else { 4 };
14882    score += match edge.kind.as_str() {
14883        "mentions_concept" | "mentions_entity" | "tagged_concept" | "tagged_entity"
14884        | "related_concept" => 30,
14885        "semantic_relation" => 28,
14886        "calls" => 24,
14887        "mentions" => 22,
14888        "requests_context" | "scopes_context" | "scopes_source" | "explains_result" => 18,
14889        "defines" | "contains" | "belongs_to" => 12,
14890        _ => 0,
14891    };
14892    score
14893}
14894
14895fn graph_db_semantic_seeded_neighborhood(
14896    store: &impl GraphStore,
14897    seed_ids: &[String],
14898    depth: usize,
14899    limit: usize,
14900) -> Result<GraphDbSemanticSeededSubgraph> {
14901    let seed_rank = seed_ids
14902        .iter()
14903        .enumerate()
14904        .map(|(idx, seed)| (seed.clone(), idx))
14905        .collect::<BTreeMap<_, _>>();
14906    let mut nodes = BTreeMap::<String, SubstrateGraphNode>::new();
14907    let mut edges = BTreeMap::<String, SubstrateGraphEdge>::new();
14908    let mut node_score_by_id = BTreeMap::<String, i64>::new();
14909    let mut queue = VecDeque::<(String, usize)>::new();
14910    let mut seen_at_depth = BTreeMap::<String, usize>::new();
14911    let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
14912    let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
14913    let mut skipped_by_edge_cap = 0usize;
14914    let mut skipped_by_node_cap = 0usize;
14915    let mut diagnostics = vec![
14916        "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
14917        "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(),
14918        format!(
14919            "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
14920            if edge_scan_cap == 0 {
14921                "unbounded".to_string()
14922            } else {
14923                edge_scan_cap.to_string()
14924            },
14925            if node_discovery_cap == usize::MAX {
14926                "unbounded".to_string()
14927            } else {
14928                node_discovery_cap.to_string()
14929            }
14930        ),
14931    ];
14932
14933    for (idx, seed_id) in seed_ids.iter().enumerate() {
14934        if let Some(node) = store.node(seed_id)? {
14935            nodes.entry(seed_id.clone()).or_insert(node);
14936            node_score_by_id
14937                .entry(seed_id.clone())
14938                .or_insert(1_000_000i64.saturating_sub(idx as i64));
14939            queue.push_back((seed_id.clone(), 0));
14940            seen_at_depth.entry(seed_id.clone()).or_insert(0);
14941        } else {
14942            diagnostics.push(format!(
14943                "semantic seed {seed_id} was not present in the graph store"
14944            ));
14945        }
14946    }
14947
14948    while let Some((current_id, current_depth)) = queue.pop_front() {
14949        if current_depth >= depth {
14950            continue;
14951        }
14952
14953        let mut expansion_edges_by_key = BTreeMap::<String, SubstrateGraphEdge>::new();
14954        for edge in store.outgoing_edges(&current_id, None)? {
14955            expansion_edges_by_key
14956                .entry(graph_db_edge_key(&edge))
14957                .or_insert(edge);
14958        }
14959        for edge in store.incident_edges(&current_id, None)? {
14960            expansion_edges_by_key
14961                .entry(graph_db_edge_key(&edge))
14962                .or_insert(edge);
14963        }
14964        let mut expansion_edges = expansion_edges_by_key.into_values().collect::<Vec<_>>();
14965        expansion_edges.sort_by(|left, right| {
14966            graph_db_semantic_edge_score(right, &current_id)
14967                .cmp(&graph_db_semantic_edge_score(left, &current_id))
14968                .then_with(|| graph_db_edge_key(left).cmp(&graph_db_edge_key(right)))
14969        });
14970        if edge_scan_cap > 0 && expansion_edges.len() > edge_scan_cap {
14971            skipped_by_edge_cap += expansion_edges.len() - edge_scan_cap;
14972            expansion_edges.truncate(edge_scan_cap);
14973        }
14974
14975        for edge in expansion_edges {
14976            let Some(other_id) = graph_db_semantic_edge_other_id(&edge, &current_id) else {
14977                continue;
14978            };
14979            let other_known = nodes.contains_key(other_id);
14980            if !other_known && nodes.len() >= node_discovery_cap {
14981                skipped_by_node_cap += 1;
14982                continue;
14983            }
14984            let other_id = other_id.to_string();
14985            let edge_score = graph_db_semantic_edge_score(&edge, &current_id)
14986                .saturating_add((depth.saturating_sub(current_depth) as i64).saturating_mul(5));
14987            node_score_by_id
14988                .entry(other_id.clone())
14989                .and_modify(|score| *score = (*score).max(edge_score))
14990                .or_insert(edge_score);
14991            let edge_key = graph_db_edge_key(&edge);
14992            edges.entry(edge_key).or_insert_with(|| edge.clone());
14993            if let std::collections::btree_map::Entry::Vacant(entry) = nodes.entry(other_id.clone())
14994                && let Some(node) = store.node(&other_id)?
14995            {
14996                entry.insert(node);
14997            }
14998            if !nodes.contains_key(&other_id) {
14999                continue;
15000            }
15001            let next_depth = current_depth + 1;
15002            let should_queue = seen_at_depth
15003                .get(&other_id)
15004                .is_none_or(|seen_depth| next_depth < *seen_depth);
15005            if should_queue {
15006                seen_at_depth.insert(other_id.clone(), next_depth);
15007                queue.push_back((other_id, next_depth));
15008            }
15009        }
15010    }
15011
15012    if skipped_by_edge_cap > 0 {
15013        diagnostics.push(format!(
15014            "semantic-seeded expansion skipped {skipped_by_edge_cap} lower-scoring incident/outgoing edge(s) after per-node caps"
15015        ));
15016    }
15017    if skipped_by_node_cap > 0 {
15018        diagnostics.push(format!(
15019            "semantic-seeded expansion skipped {skipped_by_node_cap} lower-scoring node discovery edge(s) after the discovery cap"
15020        ));
15021    }
15022
15023    let mut nodes = nodes.into_values().collect::<Vec<_>>();
15024    nodes.sort_by(|left, right| {
15025        seed_rank
15026            .get(&left.id)
15027            .copied()
15028            .unwrap_or(usize::MAX)
15029            .cmp(&seed_rank.get(&right.id).copied().unwrap_or(usize::MAX))
15030            .then_with(|| {
15031                node_score_by_id
15032                    .get(&right.id)
15033                    .copied()
15034                    .unwrap_or_default()
15035                    .cmp(&node_score_by_id.get(&left.id).copied().unwrap_or_default())
15036            })
15037            .then(left.id.cmp(&right.id))
15038    });
15039
15040    let before_limit = nodes.len();
15041    let truncated = limit > 0 && nodes.len() > limit;
15042    if truncated {
15043        nodes.truncate(limit);
15044        diagnostics.push(format!(
15045            "semantic-seeded neighborhood truncated from {before_limit} to {limit} node(s)"
15046        ));
15047    }
15048
15049    let node_ids = nodes
15050        .iter()
15051        .map(|node| node.id.as_str())
15052        .collect::<BTreeSet<_>>();
15053    let mut edges = edges
15054        .into_values()
15055        .filter(|edge| {
15056            node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
15057        })
15058        .collect::<Vec<_>>();
15059    edges.sort_by_key(graph_db_edge_key);
15060
15061    Ok(GraphDbSemanticSeededSubgraph {
15062        nodes,
15063        edges,
15064        truncated,
15065        diagnostics,
15066    })
15067}
15068
15069#[allow(clippy::too_many_arguments)]
15070fn cmd_semantic_related(
15071    query: &str,
15072    path: &Path,
15073    scope: Option<&str>,
15074    limit: usize,
15075    kind: SemanticRelatedKind,
15076    json_output: bool,
15077    compact: bool,
15078    pretty: bool,
15079    terse: bool,
15080    schema: bool,
15081) -> Result<()> {
15082    let root = lint::resolve_project_root_or_canonical_path(path)?;
15083    write_traversal_graph_store(&root, path, scope)?;
15084    let graph_db = graph_substrate_db_path(&root, scope);
15085    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
15086    let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
15087    if let Some(recovery) = store.read_only_recovery() {
15088        report
15089            .warnings
15090            .push(graph_db_read_recovery_diagnostic(recovery));
15091    }
15092
15093    if json_output {
15094        println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
15095    } else if compact {
15096        for item in &report.items {
15097            println!(
15098                "{:.3}\t{}\t{}\t{}",
15099                item.score, item.kind, item.label, item.handle
15100            );
15101        }
15102        for warning in &report.warnings {
15103            eprintln!("warning: {warning}");
15104        }
15105    } else {
15106        println!(
15107            "Related semantic graph rows for {:?} ({})",
15108            report.query, report.embedding_model
15109        );
15110        for item in &report.items {
15111            println!(
15112                "  {:.3} [{}] {} ({})",
15113                item.score, item.kind, item.label, item.handle
15114            );
15115            if let Some(detail) = &item.detail {
15116                println!("      {}", detail);
15117            }
15118            if let Some(file_path) = &item.file_path {
15119                println!("      file: {}", file_path);
15120            }
15121            println!("      expand: {}", item.expand);
15122        }
15123        for warning in &report.warnings {
15124            eprintln!("warning: {warning}");
15125        }
15126    }
15127
15128    Ok(())
15129}
15130
15131#[derive(Serialize)]
15132struct SourceLinePreview {
15133    line: usize,
15134    text: String,
15135}
15136
15137#[derive(Serialize)]
15138pub(crate) struct SourceRangePreview {
15139    start: usize,
15140    end: usize,
15141    total_lines: usize,
15142    truncated_before: bool,
15143    truncated_after: bool,
15144}
15145
15146#[derive(Serialize)]
15147struct SourceExpandCommands {
15148    #[serde(skip_serializing_if = "Option::is_none")]
15149    before: Option<String>,
15150    #[serde(skip_serializing_if = "Option::is_none")]
15151    after: Option<String>,
15152    #[serde(skip_serializing_if = "Option::is_none")]
15153    body: Option<String>,
15154    file: String,
15155    #[serde(skip_serializing_if = "Option::is_none")]
15156    markdown_ast: Option<String>,
15157}
15158
15159#[derive(Serialize)]
15160struct SourceSymbolRef {
15161    handle: String,
15162    name: String,
15163    kind: String,
15164    language: String,
15165    file: String,
15166    line: usize,
15167    #[serde(skip_serializing_if = "Option::is_none")]
15168    end_line: Option<usize>,
15169    #[serde(skip_serializing_if = "Option::is_none")]
15170    signature: Option<String>,
15171    #[serde(skip_serializing_if = "Option::is_none")]
15172    span: Option<AstSpanPreview>,
15173    expand: String,
15174}
15175
15176#[derive(Serialize)]
15177struct SourceSummaryRef {
15178    handle: String,
15179    symbol_name: String,
15180    file_path: String,
15181    summary: String,
15182    expand: String,
15183}
15184
15185#[derive(Serialize)]
15186struct SourceReadReport {
15187    handle: String,
15188    root: String,
15189    file: String,
15190    range: SourceRangePreview,
15191    preview: Vec<SourceLinePreview>,
15192    symbols: Vec<SourceSymbolRef>,
15193    summaries: Vec<SourceSummaryRef>,
15194    #[serde(skip_serializing_if = "Option::is_none")]
15195    markdown: Option<SourceReadMarkdownProjection>,
15196    expand: SourceExpandCommands,
15197    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15198    warnings: Vec<String>,
15199}
15200
15201#[derive(Serialize)]
15202struct SourceReadAstExpandCommands {
15203    window: String,
15204    file_window: String,
15205    #[serde(skip_serializing_if = "Option::is_none")]
15206    markdown_ast: Option<String>,
15207}
15208
15209#[derive(Serialize)]
15210struct SourceReadAstReport {
15211    handle: String,
15212    root: String,
15213    file: String,
15214    range: SourceRangePreview,
15215    symbols: Vec<SourceSymbolRef>,
15216    summaries: Vec<SourceSummaryRef>,
15217    #[serde(skip_serializing_if = "Option::is_none")]
15218    markdown: Option<SourceReadMarkdownProjection>,
15219    expand: SourceReadAstExpandCommands,
15220    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15221    warnings: Vec<String>,
15222}
15223
15224#[derive(Serialize)]
15225struct SymbolReadTarget {
15226    handle: String,
15227    name: String,
15228    kind: String,
15229    language: String,
15230    file: String,
15231    line: usize,
15232    #[serde(skip_serializing_if = "Option::is_none")]
15233    end_line: Option<usize>,
15234    #[serde(skip_serializing_if = "Option::is_none")]
15235    signature: Option<String>,
15236    #[serde(skip_serializing_if = "Option::is_none")]
15237    parent_module: Option<String>,
15238    #[serde(skip_serializing_if = "Option::is_none")]
15239    visibility: Option<String>,
15240    #[serde(skip_serializing_if = "Option::is_none")]
15241    span: Option<AstSpanPreview>,
15242}
15243
15244#[derive(Serialize)]
15245struct SymbolReadExpandCommands {
15246    source_window: String,
15247    #[serde(skip_serializing_if = "Option::is_none")]
15248    body: Option<String>,
15249    file: String,
15250    explain: String,
15251    callers: String,
15252    callees: String,
15253    #[serde(skip_serializing_if = "Option::is_none")]
15254    markdown_ast: Option<String>,
15255}
15256
15257#[derive(Serialize)]
15258struct SymbolReadReport {
15259    handle: String,
15260    root: String,
15261    query: String,
15262    symbol: SymbolReadTarget,
15263    range: SourceRangePreview,
15264    body: Vec<SourceLinePreview>,
15265    child_symbols: Vec<SourceSymbolRef>,
15266    summaries: Vec<SourceSummaryRef>,
15267    expand: SymbolReadExpandCommands,
15268    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15269    warnings: Vec<String>,
15270}
15271
15272#[derive(Clone)]
15273pub(crate) struct MarkdownAstRawNode {
15274    handle: String,
15275    span_handle: String,
15276    name: String,
15277    kind: String,
15278    block_kind: String,
15279    node_kind: String,
15280    start_byte: usize,
15281    end_byte: usize,
15282    body_start_byte: Option<usize>,
15283    body_end_byte: Option<usize>,
15284}
15285
15286#[derive(Clone)]
15287pub(crate) struct MarkdownAstProjection {
15288    source_hash: String,
15289    nodes: Vec<MarkdownAstRawNode>,
15290    parse_duration_micros: u128,
15291    cache_hit: bool,
15292}
15293
15294#[derive(Clone)]
15295struct MarkdownAstCacheEntry {
15296    source_hash: String,
15297    nodes: Vec<MarkdownAstRawNode>,
15298    parse_duration_micros: u128,
15299}
15300
15301static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15302    OnceLock::new();
15303
15304#[derive(Serialize, Clone)]
15305struct MarkdownAstNodeMetadata {
15306    #[serde(skip_serializing_if = "Option::is_none")]
15307    heading_level: Option<usize>,
15308    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15309    section_path: Vec<String>,
15310    #[serde(skip_serializing_if = "Option::is_none")]
15311    section_handle: Option<String>,
15312    #[serde(skip_serializing_if = "Option::is_none")]
15313    list_depth: Option<usize>,
15314    #[serde(skip_serializing_if = "Option::is_none")]
15315    list_marker: Option<String>,
15316    #[serde(skip_serializing_if = "Option::is_none")]
15317    list_order: Option<usize>,
15318    #[serde(skip_serializing_if = "Option::is_none")]
15319    fence_language: Option<String>,
15320    #[serde(skip_serializing_if = "Option::is_none")]
15321    fence_marker: Option<String>,
15322    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15323    embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15324}
15325
15326#[derive(Serialize, Clone)]
15327struct MarkdownAstNodeExpand {
15328    source_window: String,
15329    source_body: String,
15330    symbol_read: String,
15331    edit_intents: String,
15332}
15333
15334#[derive(Serialize, Clone)]
15335struct MarkdownAstCacheReport {
15336    source_hash: String,
15337    cache_hit: bool,
15338    parse_duration_micros: u128,
15339    node_count: usize,
15340    section_count: usize,
15341    list_item_count: usize,
15342    code_block_count: usize,
15343}
15344
15345#[derive(Serialize, Clone)]
15346struct MarkdownAstPhaseTiming {
15347    name: String,
15348    duration_micros: u128,
15349    detail: String,
15350}
15351
15352#[derive(Serialize, Clone)]
15353struct MarkdownAstOutlineEntry {
15354    handle: String,
15355    span_handle: String,
15356    name: String,
15357    kind: String,
15358    block_kind: String,
15359    line: usize,
15360    end_line: usize,
15361    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15362    section_path: Vec<String>,
15363    child_count: usize,
15364    expand: String,
15365}
15366
15367#[derive(Serialize, Clone)]
15368struct MarkdownAstProjectionPreview {
15369    mode: String,
15370    total_nodes: usize,
15371    returned_nodes: usize,
15372    omitted_nodes: usize,
15373    selected_node: Option<String>,
15374    cache: MarkdownAstCacheReport,
15375    outline: Vec<MarkdownAstOutlineEntry>,
15376    phase_timings: Vec<MarkdownAstPhaseTiming>,
15377}
15378
15379#[derive(Serialize)]
15380struct SourceReadMarkdownProjection {
15381    handle: String,
15382    mode: String,
15383    total_nodes: usize,
15384    visible_nodes: usize,
15385    outline: Vec<MarkdownAstOutlineEntry>,
15386    expand: String,
15387}
15388
15389#[derive(Serialize, Clone)]
15390struct SourceByteRangePreview {
15391    start: usize,
15392    end: usize,
15393}
15394
15395#[derive(Serialize, Clone)]
15396struct MarkdownAstNode {
15397    handle: String,
15398    span_handle: String,
15399    name: String,
15400    kind: String,
15401    block_kind: String,
15402    node_kind: String,
15403    line: usize,
15404    end_line: usize,
15405    byte_span: SourceByteRangePreview,
15406    #[serde(skip_serializing_if = "Option::is_none")]
15407    body_byte_span: Option<SourceByteRangePreview>,
15408    parent_handle: Option<String>,
15409    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15410    child_handles: Vec<String>,
15411    metadata: MarkdownAstNodeMetadata,
15412    expand: MarkdownAstNodeExpand,
15413}
15414
15415#[derive(Serialize)]
15416struct MarkdownAstExpandCommands {
15417    file: String,
15418    source_read: String,
15419    edit_intents: String,
15420}
15421
15422#[derive(Serialize)]
15423struct MarkdownAstReport {
15424    handle: String,
15425    root: String,
15426    file: String,
15427    range: SourceRangePreview,
15428    projection: MarkdownAstProjectionPreview,
15429    nodes: Vec<MarkdownAstNode>,
15430    expand: MarkdownAstExpandCommands,
15431    #[serde(skip_serializing_if = "Vec::is_empty", default)]
15432    warnings: Vec<String>,
15433}
15434
15435pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
15436    let candidate = if file.is_absolute() {
15437        file.to_path_buf()
15438    } else {
15439        root.join(file)
15440    };
15441    let canonical = candidate
15442        .canonicalize()
15443        .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
15444    if !canonical.is_file() {
15445        bail!("source file is not a regular file: {}", canonical.display());
15446    }
15447    let canonical_root = root
15448        .canonicalize()
15449        .with_context(|| format!("canonicalizing project root {}", root.display()))?;
15450    if !canonical.starts_with(&canonical_root) {
15451        bail!(
15452            "source file {} is outside project root {}",
15453            canonical.display(),
15454            canonical_root.display()
15455        );
15456    }
15457    Ok(canonical)
15458}
15459
15460pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
15461    source_read_window_command(root, file, start, lines)
15462}
15463
15464pub(crate) fn source_read_window_command(
15465    root: &Path,
15466    file: &str,
15467    start: usize,
15468    lines: usize,
15469) -> String {
15470    format!(
15471        "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
15472        shell_quote(file),
15473        shell_quote(&root.to_string_lossy()),
15474        start,
15475        lines
15476    )
15477}
15478
15479pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
15480    format!(
15481        "tsift --envelope source-read {} --path {} --budget normal",
15482        shell_quote(file),
15483        shell_quote(&root.to_string_lossy())
15484    )
15485}
15486
15487pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
15488    format!(
15489        "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
15490        shell_quote(symbol),
15491        shell_quote(&root.to_string_lossy()),
15492        shell_quote(file)
15493    )
15494}
15495
15496fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
15497    format!(
15498        "tsift --envelope explain {} --path {} --budget normal",
15499        shell_quote(symbol),
15500        shell_quote(&root.to_string_lossy())
15501    )
15502}
15503
15504fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
15505    format!(
15506        "tsift graph {} --path {} --{} --json",
15507        shell_quote(symbol),
15508        shell_quote(&root.to_string_lossy()),
15509        relation
15510    )
15511}
15512
15513fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
15514    format!(
15515        "tsift summarize {} --path {} --json",
15516        shell_quote(symbol),
15517        shell_quote(&root.to_string_lossy())
15518    )
15519}
15520
15521pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
15522    let mut command = format!(
15523        "tsift --envelope markdown-ast {} --path {} --budget normal",
15524        shell_quote(file),
15525        shell_quote(&root.to_string_lossy())
15526    );
15527    if let Some(node) = node {
15528        command.push_str(" --node ");
15529        command.push_str(&shell_quote(node));
15530    }
15531    command
15532}
15533
15534fn markdown_edit_intents_command(root: &Path) -> String {
15535    format!(
15536        "tsift --envelope edit-intents --path {} --budget normal",
15537        shell_quote(&root.to_string_lossy())
15538    )
15539}
15540
15541pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
15542    usize::try_from(symbol.line)
15543        .ok()
15544        .and_then(|line| line.checked_add(1))
15545        .unwrap_or(1)
15546}
15547
15548fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
15549    symbol
15550        .end_line
15551        .and_then(|line| usize::try_from(line).ok())
15552        .and_then(|line| line.checked_add(1))
15553}
15554
15555fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
15556    value.and_then(|byte| usize::try_from(byte).ok())
15557}
15558
15559fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
15560    let byte = byte.min(source.len());
15561    source[..byte]
15562        .iter()
15563        .filter(|value| **value == b'\n')
15564        .count()
15565        .saturating_add(1)
15566}
15567
15568fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
15569    source_line_for_byte(source, end_byte.saturating_sub(1))
15570}
15571
15572fn ast_span_handle(
15573    file: &str,
15574    name: &str,
15575    kind: &str,
15576    start_byte: usize,
15577    end_byte: usize,
15578) -> String {
15579    stable_handle(
15580        "span",
15581        &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
15582    )
15583}
15584
15585pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
15586    Some((
15587        symbol_span_byte(symbol.start_byte)?,
15588        symbol_span_byte(symbol.end_byte)?,
15589    ))
15590}
15591
15592pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
15593    Some((
15594        symbol_span_byte(symbol.start_byte)?,
15595        symbol_span_byte(symbol.end_byte)?,
15596    ))
15597}
15598
15599pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
15600    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15601    Some(ast_span_handle(
15602        &symbol.file,
15603        &symbol.name,
15604        &symbol.kind,
15605        start_byte,
15606        end_byte,
15607    ))
15608}
15609
15610fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
15611    left.file == right.file
15612        && left.name == right.name
15613        && left.kind == right.kind
15614        && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
15615}
15616
15617fn stored_symbol_parent_span_handle(
15618    symbol: &index::StoredSymbol,
15619    symbols: &[index::StoredSymbol],
15620) -> Option<String> {
15621    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15622    symbols
15623        .iter()
15624        .filter(|candidate| {
15625            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15626                return false;
15627            }
15628            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15629            else {
15630                return false;
15631            };
15632            candidate_start <= start_byte && candidate_end >= end_byte
15633        })
15634        .min_by_key(|candidate| {
15635            stored_symbol_span_bounds(candidate)
15636                .map(|(start, end)| end.saturating_sub(start))
15637                .unwrap_or(usize::MAX)
15638        })
15639        .and_then(stored_symbol_span_handle)
15640}
15641
15642fn stored_symbol_child_span_handles(
15643    symbol: &index::StoredSymbol,
15644    symbols: &[index::StoredSymbol],
15645    limit: usize,
15646) -> Vec<String> {
15647    let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
15648        return Vec::new();
15649    };
15650    symbols
15651        .iter()
15652        .filter(|candidate| {
15653            if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
15654                return false;
15655            }
15656            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15657            else {
15658                return false;
15659            };
15660            candidate_start >= start_byte && candidate_end <= end_byte
15661        })
15662        .take(limit)
15663        .filter_map(stored_symbol_span_handle)
15664        .collect()
15665}
15666
15667fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
15668    let start = start_byte.min(source.len());
15669    let line_end = source[start..]
15670        .iter()
15671        .position(|value| *value == b'\n')
15672        .map(|pos| start + pos)
15673        .unwrap_or(source.len());
15674    let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
15675    let marker = line.trim_start();
15676    let level = marker.chars().take_while(|ch| *ch == '#').count();
15677    (1..=6).contains(&level).then_some(level)
15678}
15679
15680fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
15681    let start = start_byte.min(source.len());
15682    let line_start = source[..start]
15683        .iter()
15684        .rposition(|value| *value == b'\n')
15685        .map(|pos| pos + 1)
15686        .unwrap_or(0);
15687    source[line_start..start]
15688        .iter()
15689        .map(|byte| match byte {
15690            b'\t' => 4,
15691            b' ' => 1,
15692            _ => 0,
15693        })
15694        .sum::<usize>()
15695        / 2
15696}
15697
15698fn markdown_enclosing_heading_symbols<'a>(
15699    file: &str,
15700    start_byte: usize,
15701    end_byte: usize,
15702    symbols: &'a [index::StoredSymbol],
15703) -> Vec<&'a index::StoredSymbol> {
15704    let mut headings = symbols
15705        .iter()
15706        .filter(|candidate| candidate.file == file && candidate.kind == "heading")
15707        .filter(|candidate| {
15708            let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
15709            else {
15710                return false;
15711            };
15712            candidate_start <= start_byte && candidate_end >= end_byte
15713        })
15714        .collect::<Vec<_>>();
15715    headings.sort_by(|left, right| {
15716        stored_symbol_span_bounds(left)
15717            .map(|(start, _)| start)
15718            .unwrap_or(usize::MAX)
15719            .cmp(
15720                &stored_symbol_span_bounds(right)
15721                    .map(|(start, _)| start)
15722                    .unwrap_or(usize::MAX),
15723            )
15724            .then(left.name.cmp(&right.name))
15725    });
15726    headings
15727}
15728
15729fn markdown_stored_symbol_metadata(
15730    symbol: &index::StoredSymbol,
15731    source: &[u8],
15732    symbols: &[index::StoredSymbol],
15733) -> Option<MarkdownSpanMetadata> {
15734    if symbol.language != "markdown" {
15735        return None;
15736    }
15737    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
15738    let section_symbols =
15739        markdown_enclosing_heading_symbols(&symbol.file, start_byte, end_byte, symbols);
15740    let section_path = section_symbols
15741        .iter()
15742        .map(|heading| heading.name.clone())
15743        .collect::<Vec<_>>();
15744    let section_handle = section_symbols
15745        .last()
15746        .and_then(|heading| stored_symbol_span_handle(heading));
15747    let heading_level = (symbol.kind == "heading")
15748        .then(|| markdown_heading_level(source, start_byte))
15749        .flatten();
15750    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15751    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15752    let embedded_symbols = if symbol.kind == "code_block" {
15753        markdown_embedded_symbols(
15754            &symbol.file,
15755            source,
15756            symbol_span_byte(symbol.body_start_byte),
15757            symbol_span_byte(symbol.body_end_byte),
15758            fence_language.as_deref(),
15759        )
15760    } else {
15761        Vec::new()
15762    };
15763
15764    (heading_level.is_some()
15765        || !section_path.is_empty()
15766        || section_handle.is_some()
15767        || list_depth.is_some()
15768        || fence_language.is_some()
15769        || !embedded_symbols.is_empty())
15770    .then_some(MarkdownSpanMetadata {
15771        heading_level,
15772        section_path,
15773        section_handle,
15774        list_depth,
15775        fence_language,
15776        embedded_symbols,
15777    })
15778}
15779
15780fn markdown_symbol_hit_metadata(
15781    symbol: &index::SymbolHit,
15782    source: &[u8],
15783    start_byte: usize,
15784) -> Option<MarkdownSpanMetadata> {
15785    if symbol.language != "markdown" {
15786        return None;
15787    }
15788    let heading_level = (symbol.kind == "heading")
15789        .then(|| markdown_heading_level(source, start_byte))
15790        .flatten();
15791    let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
15792    let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
15793    let embedded_symbols = if symbol.kind == "code_block" {
15794        markdown_embedded_symbols(
15795            &symbol.file,
15796            source,
15797            symbol_span_byte(symbol.body_start_byte),
15798            symbol_span_byte(symbol.body_end_byte),
15799            fence_language.as_deref(),
15800        )
15801    } else {
15802        Vec::new()
15803    };
15804    (heading_level.is_some()
15805        || list_depth.is_some()
15806        || fence_language.is_some()
15807        || !embedded_symbols.is_empty())
15808    .then_some(MarkdownSpanMetadata {
15809        heading_level,
15810        section_path: Vec::new(),
15811        section_handle: None,
15812        list_depth,
15813        fence_language,
15814        embedded_symbols,
15815    })
15816}
15817
15818fn is_markdown_path(path: &Path) -> bool {
15819    path.extension()
15820        .and_then(|ext| ext.to_str())
15821        .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
15822        .unwrap_or(false)
15823}
15824
15825fn markdown_ast_block_kind(kind: &str) -> String {
15826    match kind {
15827        "heading" => "section",
15828        "code_block" => "fenced_code_block",
15829        "list_item" => "list_item",
15830        other => other,
15831    }
15832    .to_string()
15833}
15834
15835fn markdown_embedded_language_key(language: &str) -> Option<String> {
15836    let key = language
15837        .split_whitespace()
15838        .next()
15839        .unwrap_or("")
15840        .trim()
15841        .trim_start_matches("language-")
15842        .trim_start_matches("lang-")
15843        .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
15844        .to_ascii_lowercase();
15845    (!key.is_empty()).then_some(key)
15846}
15847
15848fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
15849    let key = markdown_embedded_language_key(language)?;
15850    let extension = match key.as_str() {
15851        "rust" => "rs",
15852        "python" => "py",
15853        "typescript" => "ts",
15854        "javascript" => "js",
15855        "kotlin" => "kt",
15856        "shell" | "sh" | "zsh" => "bash",
15857        other => other,
15858    };
15859    let lang = graph::Lang::from_extension(extension)?;
15860    (lang.name() != "markdown").then_some(lang)
15861}
15862
15863fn markdown_embedded_ast_span_handle(
15864    file: &str,
15865    language: &str,
15866    name: &str,
15867    kind: &str,
15868    start_byte: usize,
15869    end_byte: usize,
15870) -> String {
15871    stable_handle(
15872        "span",
15873        &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
15874    )
15875}
15876
15877fn markdown_embedded_symbols(
15878    file: &str,
15879    source: &[u8],
15880    body_start_byte: Option<usize>,
15881    body_end_byte: Option<usize>,
15882    fence_language: Option<&str>,
15883) -> Vec<MarkdownEmbeddedSymbol> {
15884    let Some(fence_language) = fence_language else {
15885        return Vec::new();
15886    };
15887    let Some(lang) = markdown_embedded_lang(fence_language) else {
15888        return Vec::new();
15889    };
15890    let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
15891        return Vec::new();
15892    };
15893    let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
15894    else {
15895        return Vec::new();
15896    };
15897    if body.is_empty() {
15898        return Vec::new();
15899    }
15900
15901    let Ok(symbols) = lang.extract_symbols(body) else {
15902        return Vec::new();
15903    };
15904    let language = lang.name().to_string();
15905    symbols
15906        .into_iter()
15907        .map(|symbol| {
15908            let start_byte = body_start_byte.saturating_add(symbol.start_byte);
15909            let end_byte = body_start_byte.saturating_add(symbol.end_byte);
15910            let body_start = symbol
15911                .body_start_byte
15912                .map(|byte| body_start_byte.saturating_add(byte));
15913            let body_end = symbol
15914                .body_end_byte
15915                .map(|byte| body_start_byte.saturating_add(byte));
15916            let start_line = source_line_for_byte(source, start_byte);
15917            let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
15918            MarkdownEmbeddedSymbol {
15919                handle: markdown_embedded_ast_span_handle(
15920                    file,
15921                    &language,
15922                    &symbol.name,
15923                    &symbol.kind,
15924                    start_byte,
15925                    end_byte,
15926                ),
15927                name: symbol.name,
15928                kind: symbol.kind,
15929                language: language.clone(),
15930                node_kind: symbol.node_kind,
15931                start_byte,
15932                end_byte,
15933                start_line,
15934                end_line,
15935                body_start_byte: body_start,
15936                body_end_byte: body_end,
15937                body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
15938                body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
15939            }
15940        })
15941        .collect()
15942}
15943
15944fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
15945    let start = start_byte.min(source.len());
15946    let line_start = source[..start]
15947        .iter()
15948        .rposition(|value| *value == b'\n')
15949        .map(|pos| pos + 1)
15950        .unwrap_or(0);
15951    let line_end = source[start..]
15952        .iter()
15953        .position(|value| *value == b'\n')
15954        .map(|pos| start + pos)
15955        .unwrap_or(source.len());
15956    std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
15957}
15958
15959fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
15960    let line = markdown_source_line(source, start_byte);
15961    let trimmed = line.trim_start();
15962    for marker in ["-", "*", "+"] {
15963        if trimmed
15964            .strip_prefix(marker)
15965            .and_then(|rest| rest.strip_prefix(' '))
15966            .is_some()
15967        {
15968            return (Some(marker.to_string()), None);
15969        }
15970    }
15971
15972    let digit_end = trimmed
15973        .find(|ch: char| !ch.is_ascii_digit())
15974        .unwrap_or(trimmed.len());
15975    let (digits, rest) = trimmed.split_at(digit_end);
15976    if !digits.is_empty() {
15977        for marker in [".", ")"] {
15978            if rest
15979                .strip_prefix(marker)
15980                .and_then(|value| value.strip_prefix(' '))
15981                .is_some()
15982            {
15983                return (
15984                    Some(format!("{digits}{marker}")),
15985                    digits.parse::<usize>().ok(),
15986                );
15987            }
15988        }
15989    }
15990    (None, None)
15991}
15992
15993fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
15994    let line = markdown_source_line(source, start_byte);
15995    let trimmed = line.trim_start();
15996    ["```", "~~~"]
15997        .into_iter()
15998        .find(|marker| trimmed.starts_with(marker))
15999        .map(str::to_string)
16000}
16001
16002fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16003    let mut nodes = graph::Lang::Markdown
16004        .extract_symbols(source)
16005        .context("extracting Markdown AST nodes")?
16006        .into_iter()
16007        .map(|symbol| {
16008            let body_start_byte = symbol.body_start_byte;
16009            let body_end_byte = symbol.body_end_byte;
16010            let span_handle = ast_span_handle(
16011                file,
16012                &symbol.name,
16013                &symbol.kind,
16014                symbol.start_byte,
16015                symbol.end_byte,
16016            );
16017            MarkdownAstRawNode {
16018                handle: stable_handle(
16019                    "mdast",
16020                    &format!(
16021                        "{}:{}:{}:{}:{}",
16022                        file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16023                    ),
16024                ),
16025                span_handle,
16026                name: symbol.name,
16027                kind: symbol.kind.clone(),
16028                block_kind: markdown_ast_block_kind(&symbol.kind),
16029                node_kind: symbol.node_kind,
16030                start_byte: symbol.start_byte,
16031                end_byte: symbol.end_byte,
16032                body_start_byte,
16033                body_end_byte,
16034            }
16035        })
16036        .collect::<Vec<_>>();
16037    nodes.sort_by(|left, right| {
16038        left.start_byte
16039            .cmp(&right.start_byte)
16040            .then(left.end_byte.cmp(&right.end_byte))
16041            .then(left.kind.cmp(&right.kind))
16042            .then(left.name.cmp(&right.name))
16043    });
16044    Ok(nodes)
16045}
16046
16047pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16048    let source_hash = blake3::hash(source).to_hex().to_string();
16049    let cache_key = format!("{file}:{source_hash}");
16050    let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16051    if let Some(entry) = cache
16052        .lock()
16053        .expect("markdown ast cache poisoned")
16054        .get(&cache_key)
16055    {
16056        return Ok(MarkdownAstProjection {
16057            source_hash: entry.source_hash.clone(),
16058            nodes: entry.nodes.clone(),
16059            parse_duration_micros: entry.parse_duration_micros,
16060            cache_hit: true,
16061        });
16062    }
16063
16064    let started = Instant::now();
16065    let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16066    let parse_duration_micros = started.elapsed().as_micros();
16067    cache.lock().expect("markdown ast cache poisoned").insert(
16068        cache_key,
16069        MarkdownAstCacheEntry {
16070            source_hash: source_hash.clone(),
16071            nodes: nodes.clone(),
16072            parse_duration_micros,
16073        },
16074    );
16075    Ok(MarkdownAstProjection {
16076        source_hash,
16077        nodes,
16078        parse_duration_micros,
16079        cache_hit: false,
16080    })
16081}
16082
16083fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16084    MarkdownAstCacheReport {
16085        source_hash: projection.source_hash.clone(),
16086        cache_hit: projection.cache_hit,
16087        parse_duration_micros: projection.parse_duration_micros,
16088        node_count: projection.nodes.len(),
16089        section_count: projection
16090            .nodes
16091            .iter()
16092            .filter(|node| node.kind == "heading")
16093            .count(),
16094        list_item_count: projection
16095            .nodes
16096            .iter()
16097            .filter(|node| node.kind == "list_item")
16098            .count(),
16099        code_block_count: projection
16100            .nodes
16101            .iter()
16102            .filter(|node| node.kind == "code_block")
16103            .count(),
16104    }
16105}
16106
16107fn markdown_ast_node_direct_child_count(
16108    node: &MarkdownAstRawNode,
16109    nodes: &[MarkdownAstRawNode],
16110) -> usize {
16111    nodes
16112        .iter()
16113        .filter(|candidate| {
16114            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16115        })
16116        .count()
16117}
16118
16119fn markdown_ast_outline_entry(
16120    root: &Path,
16121    file: &str,
16122    source: &[u8],
16123    nodes: &[MarkdownAstRawNode],
16124    node: &MarkdownAstRawNode,
16125    max_bytes: usize,
16126) -> MarkdownAstOutlineEntry {
16127    let line = source_line_for_byte(source, node.start_byte);
16128    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16129    MarkdownAstOutlineEntry {
16130        handle: node.handle.clone(),
16131        span_handle: node.span_handle.clone(),
16132        name: truncate_for_budget(&node.name, max_bytes),
16133        kind: node.kind.clone(),
16134        block_kind: node.block_kind.clone(),
16135        line,
16136        end_line,
16137        section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16138        child_count: markdown_ast_node_direct_child_count(node, nodes),
16139        expand: markdown_ast_command(root, file, Some(&node.handle)),
16140    }
16141}
16142
16143fn markdown_ast_outline_entries(
16144    root: &Path,
16145    file: &str,
16146    source: &[u8],
16147    nodes: &[MarkdownAstRawNode],
16148    limit: usize,
16149    max_bytes: usize,
16150) -> Vec<MarkdownAstOutlineEntry> {
16151    let mut headings = nodes
16152        .iter()
16153        .filter(|node| node.kind == "heading")
16154        .collect::<Vec<_>>();
16155    let mut blocks = nodes
16156        .iter()
16157        .filter(|node| node.kind != "heading")
16158        .collect::<Vec<_>>();
16159    headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16160    blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16161    headings
16162        .into_iter()
16163        .chain(blocks)
16164        .take(limit)
16165        .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16166        .collect()
16167}
16168
16169fn markdown_ast_node_intersects_lines(
16170    source: &[u8],
16171    node: &MarkdownAstRawNode,
16172    start: usize,
16173    end: usize,
16174) -> bool {
16175    let line = source_line_for_byte(source, node.start_byte);
16176    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16177    line <= end && end_line >= start
16178}
16179
16180fn source_read_markdown_projection(
16181    root: &Path,
16182    file: &str,
16183    source: &[u8],
16184    start: usize,
16185    end: usize,
16186    budget: ResponseBudget,
16187) -> Result<SourceReadMarkdownProjection> {
16188    let projection = markdown_ast_projection(file, source)?;
16189    let visible_nodes = projection
16190        .nodes
16191        .iter()
16192        .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16193        .collect::<Vec<_>>();
16194    let mut outline_nodes = visible_nodes.clone();
16195    outline_nodes.sort_by_key(|node| {
16196        (
16197            node.kind != "heading",
16198            node.start_byte,
16199            node.end_byte,
16200            node.name.as_str(),
16201        )
16202    });
16203    let outline = outline_nodes
16204        .into_iter()
16205        .take(budget.preview_items())
16206        .map(|node| {
16207            markdown_ast_outline_entry(
16208                root,
16209                file,
16210                source,
16211                &projection.nodes,
16212                node,
16213                budget.preview_bytes(),
16214            )
16215        })
16216        .collect::<Vec<_>>();
16217    Ok(SourceReadMarkdownProjection {
16218        handle: stable_handle(
16219            "mdproj",
16220            &format!("{file}:{start}:{end}:{}", projection.source_hash),
16221        ),
16222        mode: "window_outline".to_string(),
16223        total_nodes: projection.nodes.len(),
16224        visible_nodes: visible_nodes.len(),
16225        outline,
16226        expand: markdown_ast_command(root, file, None),
16227    })
16228}
16229
16230fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16231    if parent.handle == child.handle {
16232        return false;
16233    }
16234    parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16235}
16236
16237fn markdown_ast_parent_handle(
16238    node: &MarkdownAstRawNode,
16239    nodes: &[MarkdownAstRawNode],
16240) -> Option<String> {
16241    nodes
16242        .iter()
16243        .filter(|candidate| markdown_ast_contains(candidate, node))
16244        .min_by_key(|candidate| {
16245            (
16246                candidate.end_byte.saturating_sub(candidate.start_byte),
16247                candidate.start_byte,
16248            )
16249        })
16250        .map(|candidate| candidate.handle.clone())
16251}
16252
16253fn markdown_ast_child_handles(
16254    node: &MarkdownAstRawNode,
16255    nodes: &[MarkdownAstRawNode],
16256    limit: usize,
16257) -> Vec<String> {
16258    nodes
16259        .iter()
16260        .filter(|candidate| {
16261            markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16262        })
16263        .take(limit)
16264        .map(|candidate| candidate.handle.clone())
16265        .collect()
16266}
16267
16268fn markdown_ast_section_nodes<'a>(
16269    node: &MarkdownAstRawNode,
16270    nodes: &'a [MarkdownAstRawNode],
16271) -> Vec<&'a MarkdownAstRawNode> {
16272    let mut headings = nodes
16273        .iter()
16274        .filter(|candidate| candidate.kind == "heading")
16275        .filter(|candidate| {
16276            candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16277        })
16278        .collect::<Vec<_>>();
16279    headings.sort_by(|left, right| {
16280        left.start_byte
16281            .cmp(&right.start_byte)
16282            .then(left.end_byte.cmp(&right.end_byte))
16283            .then(left.name.cmp(&right.name))
16284    });
16285    headings
16286}
16287
16288fn markdown_ast_node_metadata(
16289    file: &str,
16290    node: &MarkdownAstRawNode,
16291    source: &[u8],
16292    nodes: &[MarkdownAstRawNode],
16293) -> MarkdownAstNodeMetadata {
16294    let section_nodes = markdown_ast_section_nodes(node, nodes);
16295    let section_path = section_nodes
16296        .iter()
16297        .map(|heading| heading.name.clone())
16298        .collect::<Vec<_>>();
16299    let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16300    let heading_level = (node.kind == "heading")
16301        .then(|| markdown_heading_level(source, node.start_byte))
16302        .flatten();
16303    let (list_marker, list_order) = if node.kind == "list_item" {
16304        markdown_list_attributes(source, node.start_byte)
16305    } else {
16306        (None, None)
16307    };
16308    let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16309    let embedded_symbols = if node.kind == "code_block" {
16310        markdown_embedded_symbols(
16311            file,
16312            source,
16313            node.body_start_byte,
16314            node.body_end_byte,
16315            fence_language.as_deref(),
16316        )
16317    } else {
16318        Vec::new()
16319    };
16320    MarkdownAstNodeMetadata {
16321        heading_level,
16322        section_path,
16323        section_handle,
16324        list_depth: (node.kind == "list_item")
16325            .then(|| markdown_list_depth(source, node.start_byte)),
16326        list_marker,
16327        list_order,
16328        fence_language,
16329        fence_marker: (node.kind == "code_block")
16330            .then(|| markdown_fence_marker(source, node.start_byte))
16331            .flatten(),
16332        embedded_symbols,
16333    }
16334}
16335
16336fn markdown_ast_node_expand(
16337    root: &Path,
16338    file: &str,
16339    node: &MarkdownAstRawNode,
16340    source: &[u8],
16341) -> MarkdownAstNodeExpand {
16342    let start_line = source_line_for_byte(source, node.start_byte);
16343    let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16344    let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16345    let body_start_line = node
16346        .body_start_byte
16347        .map(|byte| source_line_for_byte(source, byte))
16348        .unwrap_or(start_line);
16349    let body_end_line = node
16350        .body_end_byte
16351        .map(|byte| source_line_for_end_byte(source, byte))
16352        .unwrap_or(end_line)
16353        .max(body_start_line);
16354    let body_line_count = body_end_line
16355        .saturating_sub(body_start_line)
16356        .saturating_add(1)
16357        .max(1);
16358    MarkdownAstNodeExpand {
16359        source_window: source_read_command(root, file, start_line, line_count),
16360        source_body: source_read_command(root, file, body_start_line, body_line_count),
16361        symbol_read: source_symbol_read_command(root, &node.name, file),
16362        edit_intents: markdown_edit_intents_command(root),
16363    }
16364}
16365
16366fn markdown_ast_node(
16367    root: &Path,
16368    file: &str,
16369    node: &MarkdownAstRawNode,
16370    source: &[u8],
16371    nodes: &[MarkdownAstRawNode],
16372    child_limit: usize,
16373) -> MarkdownAstNode {
16374    let line = source_line_for_byte(source, node.start_byte);
16375    let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16376    let body_byte_span = node
16377        .body_start_byte
16378        .zip(node.body_end_byte)
16379        .map(|(start, end)| SourceByteRangePreview { start, end });
16380    MarkdownAstNode {
16381        handle: node.handle.clone(),
16382        span_handle: node.span_handle.clone(),
16383        name: node.name.clone(),
16384        kind: node.kind.clone(),
16385        block_kind: node.block_kind.clone(),
16386        node_kind: node.node_kind.clone(),
16387        line,
16388        end_line,
16389        byte_span: SourceByteRangePreview {
16390            start: node.start_byte,
16391            end: node.end_byte,
16392        },
16393        body_byte_span,
16394        parent_handle: markdown_ast_parent_handle(node, nodes),
16395        child_handles: markdown_ast_child_handles(node, nodes, child_limit),
16396        metadata: markdown_ast_node_metadata(file, node, source, nodes),
16397        expand: markdown_ast_node_expand(root, file, node, source),
16398    }
16399}
16400
16401pub(crate) fn stored_symbol_ast_span(
16402    symbol: &index::StoredSymbol,
16403    source: &[u8],
16404    symbols: &[index::StoredSymbol],
16405    child_limit: usize,
16406) -> Option<AstSpanPreview> {
16407    let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16408    let node_kind = symbol.node_kind.clone()?;
16409    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16410    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16411    Some(AstSpanPreview {
16412        handle: ast_span_handle(
16413            &symbol.file,
16414            &symbol.name,
16415            &symbol.kind,
16416            start_byte,
16417            end_byte,
16418        ),
16419        node_kind,
16420        start_byte,
16421        end_byte,
16422        start_line: source_line_for_byte(source, start_byte),
16423        end_line: source_line_for_end_byte(source, end_byte),
16424        body_start_byte,
16425        body_end_byte,
16426        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16427        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16428        parent_handle: stored_symbol_parent_span_handle(symbol, symbols),
16429        child_handles: stored_symbol_child_span_handles(symbol, symbols, child_limit),
16430        markdown: markdown_stored_symbol_metadata(symbol, source, symbols),
16431    })
16432}
16433
16434pub(crate) fn symbol_hit_ast_span(symbol: &index::SymbolHit, source: &[u8]) -> Option<AstSpanPreview> {
16435    let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
16436    let node_kind = symbol.node_kind.clone()?;
16437    let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16438    let body_end_byte = symbol_span_byte(symbol.body_end_byte);
16439    Some(AstSpanPreview {
16440        handle: ast_span_handle(
16441            &symbol.file,
16442            &symbol.name,
16443            &symbol.kind,
16444            start_byte,
16445            end_byte,
16446        ),
16447        node_kind,
16448        start_byte,
16449        end_byte,
16450        start_line: source_line_for_byte(source, start_byte),
16451        end_line: source_line_for_end_byte(source, end_byte),
16452        body_start_byte,
16453        body_end_byte,
16454        body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
16455        body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
16456        parent_handle: None,
16457        child_handles: Vec::new(),
16458        markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
16459    })
16460}
16461
16462pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
16463    usize::try_from(symbol.line)
16464        .ok()
16465        .and_then(|line| line.checked_add(1))
16466        .unwrap_or(1)
16467}
16468
16469pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
16470    symbol
16471        .end_line
16472        .and_then(|line| usize::try_from(line).ok())
16473        .and_then(|line| line.checked_add(1))
16474}
16475
16476fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
16477    if end == 0 {
16478        return false;
16479    }
16480    let symbol_start = source_symbol_line(symbol);
16481    let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
16482    symbol_start <= end && symbol_end >= start
16483}
16484
16485#[allow(clippy::too_many_arguments)]
16486fn load_source_symbols(
16487    root: &Path,
16488    file_abs: &Path,
16489    file_display: &str,
16490    source: &[u8],
16491    scope: Option<&str>,
16492    start: usize,
16493    end: usize,
16494    limit: usize,
16495    max_bytes: usize,
16496    warnings: &mut Vec<String>,
16497) -> Vec<SourceSymbolRef> {
16498    let db_path = match resolve_query_db_path(root, file_abs, scope) {
16499        Ok(path) => path,
16500        Err(err) => {
16501            warnings.push(format!("index refs unavailable: {err:#}"));
16502            return Vec::new();
16503        }
16504    };
16505    if !db_path.exists() {
16506        warnings.push(format!(
16507            "index refs unavailable: no index found at {}",
16508            db_path.display()
16509        ));
16510        return Vec::new();
16511    }
16512
16513    let db = match index::IndexDb::open_read_only_resilient(&db_path) {
16514        Ok(db) => db,
16515        Err(err) => {
16516            warnings.push(format!("index refs unavailable: {err:#}"));
16517            return Vec::new();
16518        }
16519    };
16520
16521    let file_key = file_abs.to_string_lossy().to_string();
16522    let symbols = match db.symbols_for_file(&file_key) {
16523        Ok(symbols) => symbols,
16524        Err(err) => {
16525            warnings.push(format!("symbol refs unavailable: {err:#}"));
16526            return Vec::new();
16527        }
16528    };
16529
16530    symbols
16531        .iter()
16532        .filter(|symbol| source_symbol_intersects(symbol, start, end))
16533        .take(limit)
16534        .map(|symbol| {
16535            let line = source_symbol_line(symbol);
16536            let end_line = source_symbol_end_line(symbol);
16537            let handle = stable_handle(
16538                "ssym",
16539                &format!("{}:{}:{}", file_display, symbol.name, line),
16540            );
16541            SourceSymbolRef {
16542                handle,
16543                name: truncate_for_budget(&symbol.name, max_bytes),
16544                kind: symbol.kind.clone(),
16545                language: symbol.language.clone(),
16546                file: file_display.to_string(),
16547                line,
16548                end_line,
16549                signature: symbol
16550                    .signature
16551                    .clone()
16552                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
16553                span: stored_symbol_ast_span(symbol, source, &symbols, limit),
16554                expand: source_symbol_read_command(root, &symbol.name, file_display),
16555            }
16556        })
16557        .collect()
16558}
16559
16560fn load_source_summaries(
16561    root: &Path,
16562    file_display: &str,
16563    limit: usize,
16564    max_bytes: usize,
16565    warnings: &mut Vec<String>,
16566) -> Vec<SourceSummaryRef> {
16567    let db_path = root.join(".tsift/summaries.db");
16568    if !db_path.exists() {
16569        return Vec::new();
16570    }
16571    let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
16572        Ok(db) => db,
16573        Err(err) => {
16574            warnings.push(format!("summary refs unavailable: {err:#}"));
16575            return Vec::new();
16576        }
16577    };
16578    let summaries = match db.get_by_file(file_display) {
16579        Ok(summaries) => summaries,
16580        Err(err) => {
16581            warnings.push(format!("summary refs unavailable: {err:#}"));
16582            return Vec::new();
16583        }
16584    };
16585
16586    summaries
16587        .into_iter()
16588        .take(limit)
16589        .map(|summary| SourceSummaryRef {
16590            handle: stable_handle(
16591                "sum",
16592                &format!(
16593                    "{}:{}:{}",
16594                    summary.file_path, summary.symbol_name, summary.id
16595                ),
16596            ),
16597            symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
16598            file_path: summary.file_path,
16599            summary: truncate_for_budget(&summary.summary, max_bytes),
16600            expand: source_summary_expand_command(root, &summary.symbol_name),
16601        })
16602        .collect()
16603}
16604
16605fn cmd_markdown_ast(
16606    file: &Path,
16607    path: &Path,
16608    node: Option<&str>,
16609    format: OutputFormat,
16610    absolute: bool,
16611    budget: ResponseBudget,
16612) -> Result<()> {
16613    let root = lint::resolve_project_root_or_canonical_path(path)?;
16614    let file_abs = resolve_source_file(&root, file)?;
16615    if !is_markdown_path(&file_abs) {
16616        bail!(
16617            "markdown-ast only supports Markdown files (.md/.mdx): {}",
16618            file_abs.display()
16619        );
16620    }
16621    let file_display = if absolute {
16622        file_abs.to_string_lossy().to_string()
16623    } else {
16624        relativize_pathbuf(&file_abs, &root)
16625            .to_string_lossy()
16626            .to_string()
16627    };
16628    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16629    let text = String::from_utf8_lossy(&source);
16630    let total_lines = text.lines().count();
16631    let projection = markdown_ast_projection(&file_display, &source)?;
16632    let raw_nodes = &projection.nodes;
16633    let max_items = budget.preview_items();
16634    let max_bytes = budget.preview_bytes();
16635
16636    let selected_nodes = if let Some(handle) = node {
16637        let matches = raw_nodes
16638            .iter()
16639            .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
16640            .collect::<Vec<_>>();
16641        if matches.is_empty() {
16642            bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
16643        }
16644        matches
16645    } else {
16646        raw_nodes.iter().take(max_items).collect::<Vec<_>>()
16647    };
16648    let nodes = selected_nodes
16649        .into_iter()
16650        .map(|raw| {
16651            let mut node =
16652                markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
16653            node.name = truncate_for_budget(&node.name, max_bytes);
16654            node
16655        })
16656        .collect::<Vec<_>>();
16657    let outline_started = Instant::now();
16658    let outline = markdown_ast_outline_entries(
16659        &root,
16660        &file_display,
16661        &source,
16662        raw_nodes,
16663        max_items,
16664        max_bytes,
16665    );
16666    let outline_duration_micros = outline_started.elapsed().as_micros();
16667    let projection_preview = MarkdownAstProjectionPreview {
16668        mode: if node.is_some() {
16669            "selected_node".to_string()
16670        } else {
16671            "outline_first".to_string()
16672        },
16673        total_nodes: raw_nodes.len(),
16674        returned_nodes: nodes.len(),
16675        omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
16676        selected_node: node.map(str::to_string),
16677        cache: markdown_ast_cache_report(&projection),
16678        outline,
16679        phase_timings: vec![
16680            MarkdownAstPhaseTiming {
16681                name: "parse_extract".to_string(),
16682                duration_micros: projection.parse_duration_micros,
16683                detail: if projection.cache_hit {
16684                    "reused cached tree-sitter Markdown symbol extraction".to_string()
16685                } else {
16686                    "tree-sitter Markdown symbol extraction".to_string()
16687                },
16688            },
16689            MarkdownAstPhaseTiming {
16690                name: "outline_projection".to_string(),
16691                duration_micros: outline_duration_micros,
16692                detail: "outline-first section/block preview construction".to_string(),
16693            },
16694        ],
16695    };
16696    let report = MarkdownAstReport {
16697        handle: stable_handle("mdastrep", &file_display),
16698        root: root.to_string_lossy().to_string(),
16699        file: file_display.clone(),
16700        range: SourceRangePreview {
16701            start: 1,
16702            end: total_lines,
16703            total_lines,
16704            truncated_before: false,
16705            truncated_after: false,
16706        },
16707        projection: projection_preview,
16708        nodes,
16709        expand: MarkdownAstExpandCommands {
16710            file: markdown_ast_command(&root, &file_display, None),
16711            source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
16712            edit_intents: markdown_edit_intents_command(&root),
16713        },
16714        warnings: Vec::new(),
16715    };
16716
16717    if format.json_output {
16718        let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
16719        let mut follow_up = vec![
16720            report.expand.file.clone(),
16721            report.expand.source_read.clone(),
16722            report.expand.edit_intents.clone(),
16723        ];
16724        follow_up.extend(
16725            report
16726                .nodes
16727                .iter()
16728                .map(|node| node.expand.source_window.clone()),
16729        );
16730        print_json_or_envelope(
16731            &report,
16732            &format,
16733            "markdown-ast",
16734            "ast",
16735            ToolEnvelopeSummary {
16736                text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
16737                metrics: vec![
16738                    envelope_metric("nodes", report.nodes.len()),
16739                    envelope_metric("total_nodes", report.projection.total_nodes),
16740                    envelope_metric(
16741                        "parse_duration_micros",
16742                        report.projection.cache.parse_duration_micros,
16743                    ),
16744                    envelope_metric("total_lines", report.range.total_lines),
16745                ],
16746            },
16747            truncated,
16748            follow_up,
16749        )?;
16750    } else if format.compact {
16751        println!(
16752            "markdown-ast {} nodes:{} handle:{}",
16753            report.file,
16754            report.nodes.len(),
16755            report.handle
16756        );
16757        for node in &report.nodes {
16758            println!(
16759                "  {} {} {}:{}-{}",
16760                node.handle, node.kind, node.name, node.line, node.end_line
16761            );
16762        }
16763        if node.is_none() && raw_nodes.len() > report.nodes.len() {
16764            println!("expand: {}", report.expand.file);
16765        }
16766    } else {
16767        println!(
16768            "Markdown AST `{}` nodes {} of {} ({})",
16769            report.file,
16770            report.nodes.len(),
16771            raw_nodes.len(),
16772            report.handle
16773        );
16774        for node in &report.nodes {
16775            println!(
16776                "  {} `{}` {}:{}-{} — {}",
16777                node.handle,
16778                node.name,
16779                node.kind,
16780                node.line,
16781                node.end_line,
16782                node.expand.source_window
16783            );
16784        }
16785        if node.is_none() && raw_nodes.len() > report.nodes.len() {
16786            println!();
16787            println!("Expand:");
16788            println!("  file: {}", report.expand.file);
16789        }
16790    }
16791
16792    Ok(())
16793}
16794
16795#[allow(clippy::too_many_arguments)]
16796fn cmd_source_read(
16797    file: &Path,
16798    path: &Path,
16799    style: SourceReadStyle,
16800    start: usize,
16801    lines: usize,
16802    end: Option<usize>,
16803    scope: Option<&str>,
16804    format: OutputFormat,
16805    absolute: bool,
16806    budget: ResponseBudget,
16807) -> Result<()> {
16808    if start == 0 {
16809        bail!("--start is 1-based and must be greater than zero");
16810    }
16811    if lines == 0 {
16812        bail!("--lines must be greater than zero");
16813    }
16814    if let Some(end) = end
16815        && end < start
16816    {
16817        bail!("--end must be greater than or equal to --start");
16818    }
16819
16820    let root = lint::resolve_project_root_or_canonical_path(path)?;
16821    let file_abs = resolve_source_file(&root, file)?;
16822    let file_display = if absolute {
16823        file_abs.to_string_lossy().to_string()
16824    } else {
16825        relativize_pathbuf(&file_abs, &root)
16826            .to_string_lossy()
16827            .to_string()
16828    };
16829
16830    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
16831    let text = String::from_utf8_lossy(&source);
16832    let all_lines: Vec<&str> = text.lines().collect();
16833    let total_lines = all_lines.len();
16834    if total_lines > 0 && start > total_lines {
16835        bail!(
16836            "--start {} is beyond end of {} ({} lines)",
16837            start,
16838            file_display,
16839            total_lines
16840        );
16841    }
16842    let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
16843    let end_line = requested_end.min(total_lines);
16844    let mut warnings = Vec::new();
16845    let max_items = budget.preview_items();
16846    let max_bytes = budget.preview_bytes();
16847    if style == SourceReadStyle::Ast {
16848        let symbols = load_source_symbols(
16849            &root,
16850            &file_abs,
16851            &file_display,
16852            &source,
16853            scope,
16854            start,
16855            end_line,
16856            max_items,
16857            max_bytes,
16858            &mut warnings,
16859        );
16860        let summaries =
16861            load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
16862        let markdown = if is_markdown_path(&file_abs) {
16863            match source_read_markdown_projection(
16864                &root,
16865                &file_display,
16866                &source,
16867                start,
16868                end_line,
16869                budget,
16870            ) {
16871                Ok(markdown) => Some(markdown),
16872                Err(err) => {
16873                    warnings.push(format!("markdown projection unavailable: {err:#}"));
16874                    None
16875                }
16876            }
16877        } else {
16878            None
16879        };
16880        let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
16881        let report = SourceReadAstReport {
16882            handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
16883            root: root.to_string_lossy().to_string(),
16884            file: file_display.clone(),
16885            range: SourceRangePreview {
16886                start,
16887                end: end_line,
16888                total_lines,
16889                truncated_before: start > 1,
16890                truncated_after: end_line < total_lines,
16891            },
16892            symbols,
16893            summaries,
16894            markdown,
16895            expand: SourceReadAstExpandCommands {
16896                window: source_read_window_command(&root, &file_display, start, window_lines),
16897                file_window: source_read_window_command(
16898                    &root,
16899                    &file_display,
16900                    1,
16901                    total_lines.max(window_lines),
16902                ),
16903                markdown_ast: is_markdown_path(&file_abs)
16904                    .then(|| markdown_ast_command(&root, &file_display, None)),
16905            },
16906            warnings,
16907        };
16908
16909        if format.json_output {
16910            let truncated = report.range.truncated_before
16911                || report.range.truncated_after
16912                || report.symbols.len() >= max_items
16913                || report.summaries.len() >= max_items;
16914            let follow_up = [
16915                Some(report.expand.window.clone()),
16916                Some(report.expand.file_window.clone()),
16917                report.expand.markdown_ast.clone(),
16918            ]
16919            .into_iter()
16920            .flatten()
16921            .collect::<Vec<_>>();
16922            print_json_or_envelope(
16923                &report,
16924                &format,
16925                "source-read",
16926                "ast",
16927                ToolEnvelopeSummary {
16928                    text: format!(
16929                        "source ast {}:{}-{}",
16930                        report.file, report.range.start, report.range.end
16931                    ),
16932                    metrics: vec![
16933                        envelope_metric("symbols", report.symbols.len()),
16934                        envelope_metric("summaries", report.summaries.len()),
16935                        envelope_metric(
16936                            "markdown_nodes",
16937                            report
16938                                .markdown
16939                                .as_ref()
16940                                .map_or(0, |markdown| markdown.visible_nodes),
16941                        ),
16942                    ],
16943                },
16944                truncated,
16945                follow_up,
16946            )?;
16947        } else if format.compact {
16948            println!(
16949                "source-ast {}:{}-{} / {} handle:{}",
16950                report.file,
16951                report.range.start,
16952                report.range.end,
16953                report.range.total_lines,
16954                report.handle
16955            );
16956            for symbol in &report.symbols {
16957                println!(
16958                    "  {} {}:{} {}",
16959                    symbol.name, symbol.file, symbol.line, symbol.expand
16960                );
16961            }
16962            if !report.summaries.is_empty() {
16963                println!("summaries[{}]", report.summaries.len());
16964            }
16965            for warning in &report.warnings {
16966                eprintln!("warning: {warning}");
16967            }
16968        } else {
16969            println!(
16970                "Source AST `{}` lines {}-{} of {} ({})",
16971                report.file,
16972                report.range.start,
16973                report.range.end,
16974                report.range.total_lines,
16975                report.handle
16976            );
16977            if !report.symbols.is_empty() {
16978                println!();
16979                println!("Symbol refs:");
16980                for symbol in &report.symbols {
16981                    println!(
16982                        "  {} `{}` {}:{} — {}",
16983                        symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
16984                    );
16985                }
16986            }
16987            if !report.summaries.is_empty() {
16988                println!();
16989                println!("Summary refs:");
16990                for summary in &report.summaries {
16991                    println!(
16992                        "  {} `{}` — {}",
16993                        summary.handle, summary.symbol_name, summary.expand
16994                    );
16995                }
16996            }
16997            println!();
16998            println!("Expand:");
16999            println!("  window:      {}", report.expand.window);
17000            println!("  file window: {}", report.expand.file_window);
17001            if let Some(markdown_ast) = &report.expand.markdown_ast {
17002                println!("  markdown:    {}", markdown_ast);
17003            }
17004            for warning in &report.warnings {
17005                eprintln!("warning: {warning}");
17006            }
17007        }
17008
17009        return Ok(());
17010    }
17011    let max_bytes = budget.preview_bytes();
17012    let token_cap = budget.body_token_cap();
17013    let (preview, preview_end, body_truncated) = if total_lines == 0 {
17014        (Vec::new(), end_line, false)
17015    } else {
17016        let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17017        (capped.preview, capped.capped_end, capped.was_capped)
17018    };
17019    let effective_end = if body_truncated { preview_end } else { end_line };
17020
17021    if body_truncated {
17022        warnings.push(format!(
17023            "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17024        ));
17025    }
17026    let symbols = load_source_symbols(
17027        &root,
17028        &file_abs,
17029        &file_display,
17030        &source,
17031        scope,
17032        start,
17033        effective_end,
17034        max_items,
17035        max_bytes,
17036        &mut warnings,
17037    );
17038    let summaries =
17039        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17040    let markdown = if is_markdown_path(&file_abs) {
17041        match source_read_markdown_projection(
17042            &root,
17043            &file_display,
17044            &source,
17045            start,
17046            effective_end,
17047            budget,
17048        ) {
17049            Ok(markdown) => Some(markdown),
17050            Err(err) => {
17051                warnings.push(format!("markdown projection unavailable: {err:#}"));
17052                None
17053            }
17054        }
17055    } else {
17056        None
17057    };
17058
17059    let expand = SourceExpandCommands {
17060        before: (start > 1).then(|| {
17061            let before_start = start.saturating_sub(lines).max(1);
17062            source_read_window_command(&root, &file_display, before_start, start - before_start)
17063        }),
17064        after: (effective_end < total_lines)
17065            .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17066        body: body_truncated.then(|| {
17067            let remaining = end_line.saturating_sub(effective_end);
17068            source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17069        }),
17070        file: source_read_ast_command(&root, &file_display),
17071        markdown_ast: is_markdown_path(&file_abs)
17072            .then(|| markdown_ast_command(&root, &file_display, None)),
17073    };
17074
17075    let report = SourceReadReport {
17076        handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17077        root: root.to_string_lossy().to_string(),
17078        file: file_display,
17079        range: SourceRangePreview {
17080            start,
17081            end: effective_end,
17082            total_lines,
17083            truncated_before: start > 1,
17084            truncated_after: effective_end < total_lines,
17085        },
17086        preview,
17087        symbols,
17088        summaries,
17089        markdown,
17090        expand,
17091        warnings,
17092    };
17093
17094    if format.json_output {
17095        let truncated = report.range.truncated_before || report.range.truncated_after;
17096        let follow_up = [
17097            report.expand.before.clone(),
17098            report.expand.after.clone(),
17099            report.expand.body.clone(),
17100            Some(report.expand.file.clone()),
17101            report.expand.markdown_ast.clone(),
17102        ]
17103        .into_iter()
17104        .flatten()
17105        .collect::<Vec<_>>();
17106        print_json_or_envelope(
17107            &report,
17108            &format,
17109            "source-read",
17110            "window",
17111            ToolEnvelopeSummary {
17112                text: format!(
17113                    "source window {}:{}-{}",
17114                    report.file, report.range.start, report.range.end
17115                ),
17116                metrics: vec![
17117                    envelope_metric("lines", report.preview.len()),
17118                    envelope_metric("symbols", report.symbols.len()),
17119                    envelope_metric("summaries", report.summaries.len()),
17120                    envelope_metric(
17121                        "markdown_nodes",
17122                        report
17123                            .markdown
17124                            .as_ref()
17125                            .map_or(0, |markdown| markdown.visible_nodes),
17126                    ),
17127                ],
17128            },
17129            truncated,
17130            follow_up,
17131        )?;
17132    } else if format.compact {
17133        println!(
17134            "source {}:{}-{} / {} handle:{}",
17135            report.file,
17136            report.range.start,
17137            report.range.end,
17138            report.range.total_lines,
17139            report.handle
17140        );
17141        for line in &report.preview {
17142            println!("{:>5} {}", line.line, line.text);
17143        }
17144        if !report.symbols.is_empty() {
17145            println!("syms[{}]:", report.symbols.len());
17146            for symbol in &report.symbols {
17147                println!("  {} {}:{}", symbol.name, symbol.file, symbol.line);
17148            }
17149        }
17150        if report.range.truncated_before || report.range.truncated_after {
17151            println!("expand: {}", report.expand.file);
17152        }
17153    } else {
17154        println!(
17155            "Source window `{}` lines {}-{} of {} ({})",
17156            report.file,
17157            report.range.start,
17158            report.range.end,
17159            report.range.total_lines,
17160            report.handle
17161        );
17162        for line in &report.preview {
17163            println!("{:>5} | {}", line.line, line.text);
17164        }
17165        if !report.symbols.is_empty() {
17166            println!();
17167            println!("Symbol refs:");
17168            for symbol in &report.symbols {
17169                println!(
17170                    "  {} `{}` {}:{} — {}",
17171                    symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17172                );
17173            }
17174        }
17175        if !report.summaries.is_empty() {
17176            println!();
17177            println!("Summary refs:");
17178            for summary in &report.summaries {
17179                println!(
17180                    "  {} `{}` — {}",
17181                    summary.handle, summary.symbol_name, summary.expand
17182                );
17183            }
17184        }
17185        if report.range.truncated_before || report.range.truncated_after {
17186            println!();
17187            println!("Expand:");
17188            if let Some(before) = &report.expand.before {
17189                println!("  before: {}", before);
17190            }
17191            if let Some(after) = &report.expand.after {
17192                println!("  after: {}", after);
17193            }
17194            println!("  file:   {}", report.expand.file);
17195        }
17196        for warning in &report.warnings {
17197            eprintln!("warning: {warning}");
17198        }
17199    }
17200
17201    Ok(())
17202}
17203
17204#[allow(clippy::too_many_arguments)]
17205fn cmd_symbol_read(
17206    symbol: &str,
17207    file_hint: Option<&Path>,
17208    path: &Path,
17209    scope: Option<&str>,
17210    format: OutputFormat,
17211    absolute: bool,
17212    budget: ResponseBudget,
17213) -> Result<()> {
17214    let root = lint::resolve_project_root_or_canonical_path(path)?;
17215    let hinted_file_abs = file_hint
17216        .map(|file| resolve_source_file(&root, file))
17217        .transpose()?;
17218    let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17219    let db_path = resolve_query_db_path(&root, path_hint, scope)?;
17220    if !db_path.exists() {
17221        bail!(
17222            "index refs unavailable: no index found at {}",
17223            db_path.display()
17224        );
17225    }
17226    let db = index::IndexDb::open_read_only_resilient(&db_path)
17227        .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17228    let search_limit = budget.follow_up_items().max(10);
17229    let hits = db
17230        .symbol_search(symbol, search_limit)
17231        .with_context(|| format!("searching symbols for {symbol:?}"))?;
17232    let selected = hits
17233        .into_iter()
17234        .find(|hit| {
17235            let Some(hinted_file_abs) = &hinted_file_abs else {
17236                return true;
17237            };
17238            resolve_source_file(&root, Path::new(&hit.file))
17239                .map(|hit_file| hit_file == *hinted_file_abs)
17240                .unwrap_or(false)
17241        })
17242        .with_context(|| {
17243            let hint = file_hint
17244                .map(|file| format!(" in {}", file.display()))
17245                .unwrap_or_default();
17246            format!("no indexed symbol matched {symbol:?}{hint}")
17247        })?;
17248
17249    let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17250    let file_display = if absolute {
17251        file_abs.to_string_lossy().to_string()
17252    } else {
17253        relativize_pathbuf(&file_abs, &root)
17254            .to_string_lossy()
17255            .to_string()
17256    };
17257    let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17258    let content_hash = blake3::hash(&source).to_hex().to_string();
17259    let text = String::from_utf8_lossy(&source);
17260    let all_lines: Vec<&str> = text.lines().collect();
17261    let total_lines = all_lines.len();
17262    let file_symbols = db
17263        .symbols_for_file(&file_abs.to_string_lossy())
17264        .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17265    let max_items = budget.preview_items();
17266    let max_bytes = budget.preview_bytes();
17267    let selected_start = symbol_hit_line(&selected);
17268    let selected_end = symbol_hit_end_line(&selected)
17269        .unwrap_or(selected_start)
17270        .max(selected_start);
17271    let stored_target = file_symbols.iter().find(|candidate| {
17272        candidate.name == selected.name
17273            && candidate.kind == selected.kind
17274            && source_symbol_line(candidate) == selected_start
17275    });
17276    let target_span = stored_target
17277        .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17278        .or_else(|| symbol_hit_ast_span(&selected, &source));
17279    let target_start = target_span
17280        .as_ref()
17281        .map(|span| span.start_line)
17282        .unwrap_or(selected_start);
17283    let target_end = target_span
17284        .as_ref()
17285        .map(|span| span.end_line)
17286        .or_else(|| stored_target.and_then(source_symbol_end_line))
17287        .unwrap_or(selected_end)
17288        .max(target_start);
17289    let target_bounds = stored_target
17290        .and_then(stored_symbol_span_bounds)
17291        .or_else(|| symbol_hit_span_bounds(&selected));
17292    let target_end = stored_target
17293        .and_then(source_symbol_end_line)
17294        .unwrap_or(target_end)
17295        .max(target_start);
17296    let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17297    let line_capped_end = target_start
17298        .saturating_add(body_line_budget)
17299        .saturating_sub(1)
17300        .min(target_end)
17301        .min(total_lines.max(target_start));
17302    let token_cap = budget.body_token_cap();
17303    let (body, effective_preview_end, body_truncated) = if total_lines == 0 || target_start > total_lines {
17304        (Vec::new(), line_capped_end, false)
17305    } else {
17306        let capped = build_token_capped_preview(&all_lines, target_start, line_capped_end, max_bytes, token_cap);
17307        (capped.preview, capped.capped_end, capped.was_capped)
17308    };
17309    let preview_end = if body_truncated { effective_preview_end } else { line_capped_end };
17310    let child_symbols = file_symbols
17311        .iter()
17312        .filter(|candidate| {
17313            if let Some((target_start_byte, target_end_byte)) = target_bounds {
17314                let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17315                else {
17316                    return false;
17317                };
17318                return candidate_start >= target_start_byte
17319                    && candidate_end <= target_end_byte
17320                    && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17321            }
17322            let line = source_symbol_line(candidate);
17323            line > target_start && line <= target_end
17324        })
17325        .take(max_items)
17326        .map(|symbol| {
17327            let line = source_symbol_line(symbol);
17328            let end_line = source_symbol_end_line(symbol);
17329            SourceSymbolRef {
17330                handle: stable_handle(
17331                    "ssym",
17332                    &format!("{}:{}:{}", file_display, symbol.name, line),
17333                ),
17334                name: truncate_for_budget(&symbol.name, max_bytes),
17335                kind: symbol.kind.clone(),
17336                language: symbol.language.clone(),
17337                file: file_display.clone(),
17338                line,
17339                end_line,
17340                signature: symbol
17341                    .signature
17342                    .clone()
17343                    .map(|signature| truncate_for_budget(&signature, max_bytes)),
17344                span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
17345                expand: source_symbol_read_command(&root, &symbol.name, &file_display),
17346            }
17347        })
17348        .collect::<Vec<_>>();
17349    let mut warnings = Vec::new();
17350    if body_truncated {
17351        warnings.push(format!(
17352            "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
17353        ));
17354    }
17355    let summaries =
17356        load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17357    let symbol_handle = stable_handle(
17358        "sread",
17359        &format!("{}:{}:{}", file_display, selected.name, target_start),
17360    );
17361    let source_lines = preview_end
17362        .saturating_sub(target_start)
17363        .saturating_add(1)
17364        .max(1);
17365    let expand = SymbolReadExpandCommands {
17366        source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
17367        body: body_truncated.then(|| {
17368            let remaining = target_end.saturating_sub(preview_end);
17369            source_read_window_command(&root, &file_display, preview_end + 1, remaining)
17370        }),
17371        file: source_read_ast_command(&root, &file_display),
17372        explain: source_symbol_expand_command(&root, &selected.name),
17373        callers: source_symbol_graph_command(&root, &selected.name, "callers"),
17374        callees: source_symbol_graph_command(&root, &selected.name, "callees"),
17375        markdown_ast: (selected.language == "markdown").then(|| {
17376            markdown_ast_command(
17377                &root,
17378                &file_display,
17379                target_span.as_ref().map(|span| span.handle.as_str()),
17380            )
17381        }),
17382    };
17383    let report = SymbolReadReport {
17384        handle: symbol_handle.clone(),
17385        root: root.to_string_lossy().to_string(),
17386        query: symbol.to_string(),
17387        symbol: SymbolReadTarget {
17388            handle: symbol_handle,
17389            name: selected.name.clone(),
17390            kind: selected.kind.clone(),
17391            language: selected.language.clone(),
17392            file: file_display.clone(),
17393            line: target_start,
17394            end_line: Some(target_end),
17395            signature: stored_target
17396                .and_then(|stored| stored.signature.clone())
17397                .map(|signature| truncate_for_budget(&signature, max_bytes)),
17398            parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
17399            visibility: stored_target.and_then(|stored| stored.visibility.clone()),
17400            span: target_span,
17401        },
17402        range: SourceRangePreview {
17403            start: target_start,
17404            end: preview_end,
17405            total_lines,
17406            truncated_before: false,
17407            truncated_after: preview_end < target_end,
17408        },
17409        body,
17410        child_symbols,
17411        summaries,
17412        expand,
17413        warnings,
17414    };
17415
17416    if format.json_output {
17417        let truncated = report.range.truncated_after
17418            || report.body.iter().any(|line| line.text.len() >= max_bytes)
17419            || report.child_symbols.len() >= max_items;
17420        let follow_up = [
17421            Some(report.expand.source_window.clone()),
17422            report.expand.body.clone(),
17423            Some(report.expand.file.clone()),
17424            Some(report.expand.explain.clone()),
17425            Some(report.expand.callers.clone()),
17426            Some(report.expand.callees.clone()),
17427        ]
17428        .into_iter()
17429        .flatten()
17430        .chain(report.expand.markdown_ast.clone())
17431        .collect::<Vec<_>>();
17432        print_json_or_envelope(
17433            &report,
17434            &format,
17435            "symbol-read",
17436            "symbol",
17437            ToolEnvelopeSummary {
17438                text: format!(
17439                    "symbol {} {}:{}-{}",
17440                    report.symbol.name, report.symbol.file, report.range.start, report.range.end
17441                ),
17442                metrics: vec![
17443                    envelope_metric("body_lines", report.body.len()),
17444                    envelope_metric("child_symbols", report.child_symbols.len()),
17445                    envelope_metric("summaries", report.summaries.len()),
17446                ],
17447            },
17448            truncated,
17449            follow_up,
17450        )?;
17451    } else if format.compact {
17452        println!(
17453            "symbol {} {}:{}-{} handle:{} hash:{}",
17454            report.symbol.name,
17455            report.symbol.file,
17456            report.range.start,
17457            report.range.end,
17458            report.handle,
17459            content_hash
17460        );
17461        for line in &report.body {
17462            println!("{:>5} {}", line.line, line.text);
17463        }
17464        if !report.child_symbols.is_empty() {
17465            println!("children[{}]:", report.child_symbols.len());
17466            for child in &report.child_symbols {
17467                println!("  {} {}:{}", child.name, child.file, child.line);
17468            }
17469        }
17470    } else {
17471        println!(
17472            "Symbol `{}` in `{}` lines {}-{} ({})",
17473            report.symbol.name,
17474            report.symbol.file,
17475            report.range.start,
17476            report.range.end,
17477            report.handle
17478        );
17479        for line in &report.body {
17480            println!("{:>5} | {}", line.line, line.text);
17481        }
17482        if !report.child_symbols.is_empty() {
17483            println!();
17484            println!("Child symbols:");
17485            for child in &report.child_symbols {
17486                println!(
17487                    "  {} `{}` {}:{} — {}",
17488                    child.handle, child.name, child.file, child.line, child.expand
17489                );
17490            }
17491        }
17492        println!();
17493        println!("Expand:");
17494        println!("  source:  {}", report.expand.source_window);
17495        println!("  file:    {}", report.expand.file);
17496        println!("  explain: {}", report.expand.explain);
17497        println!("  callers: {}", report.expand.callers);
17498        println!("  callees: {}", report.expand.callees);
17499        for warning in &report.warnings {
17500            eprintln!("warning: {warning}");
17501        }
17502    }
17503
17504    Ok(())
17505}
17506
17507#[allow(clippy::too_many_arguments)]
17508#[derive(Serialize)]
17509struct ExplainBudgetDefinitionPreview {
17510    handle: String,
17511    #[serde(skip_serializing_if = "Option::is_none")]
17512    tag_alias: Option<String>,
17513    kind: String,
17514    name: String,
17515    file: String,
17516    line: i64,
17517    expand: String,
17518}
17519
17520#[derive(Serialize)]
17521struct ExplainBudgetEdgePreview {
17522    handle: String,
17523    #[serde(skip_serializing_if = "Option::is_none")]
17524    tag_alias: Option<String>,
17525    name: String,
17526    file: String,
17527    line: i64,
17528    expand: String,
17529}
17530
17531#[derive(Serialize)]
17532struct ExplainBudgetCommunityPreview {
17533    size: usize,
17534    members: Vec<String>,
17535}
17536
17537#[derive(Serialize)]
17538struct ExplainBudgetReport {
17539    symbol: String,
17540    max_items: usize,
17541    max_bytes: usize,
17542    definition_total: usize,
17543    callers_total: usize,
17544    callers_truncated_by_limit: bool,
17545    callees_total: usize,
17546    callees_truncated_by_limit: bool,
17547    truncated: bool,
17548    definitions: Vec<ExplainBudgetDefinitionPreview>,
17549    callers: Vec<ExplainBudgetEdgePreview>,
17550    callees: Vec<ExplainBudgetEdgePreview>,
17551    #[serde(skip_serializing_if = "Option::is_none")]
17552    community: Option<ExplainBudgetCommunityPreview>,
17553}
17554
17555#[allow(clippy::too_many_arguments)]
17556pub(crate) fn build_explain_budget_report(
17557    symbol: &str,
17558    _root: &Path,
17559    symbols: &[index::StoredSymbol],
17560    callers: &[index::StoredEdge],
17561    callers_total: usize,
17562    callers_truncated_by_limit: bool,
17563    callees: &[index::StoredEdge],
17564    callees_total: usize,
17565    callees_truncated_by_limit: bool,
17566    community: Option<&graph::Community>,
17567    budget: ResponseBudget,
17568) -> ExplainBudgetReport {
17569    let max_items = budget.preview_items();
17570    let max_bytes = budget.preview_bytes();
17571    let definitions = symbols
17572        .iter()
17573        .take(max_items)
17574        .map(|entry| {
17575            let symbol_ref = build_compact_symbol_ref(
17576                "edef",
17577                &format!(
17578                    "{}:{}:{}:{}",
17579                    entry.kind, entry.name, entry.file, entry.line
17580                ),
17581                &entry.name,
17582                entry.tags.as_deref(),
17583                max_bytes,
17584            );
17585            ExplainBudgetDefinitionPreview {
17586                handle: symbol_ref.handle,
17587                tag_alias: symbol_ref.tag_alias,
17588                kind: entry.kind.clone(),
17589                name: symbol_ref.name,
17590                file: truncate_for_budget(&entry.file, max_bytes),
17591                line: entry.line,
17592                expand: format!(
17593                    "tsift search {} --exact --path {} --limit 20",
17594                    shell_quote(&entry.name),
17595                    shell_quote(&entry.file)
17596                ),
17597            }
17598        })
17599        .collect();
17600    let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
17601        .iter()
17602        .take(max_items)
17603        .map(|entry| {
17604            let symbol_ref = build_compact_symbol_ref(
17605                "ecall",
17606                &format!(
17607                    "{}:{}:{}:{}",
17608                    entry.caller_name, entry.caller_file, entry.call_site_line, symbol
17609                ),
17610                &entry.caller_name,
17611                None,
17612                max_bytes,
17613            );
17614            ExplainBudgetEdgePreview {
17615                handle: symbol_ref.handle,
17616                tag_alias: symbol_ref.tag_alias,
17617                name: symbol_ref.name,
17618                file: truncate_for_budget(&entry.caller_file, max_bytes),
17619                line: entry.call_site_line,
17620                expand: format!(
17621                    "tsift explain {} --path {} --limit 0",
17622                    shell_quote(&entry.caller_name),
17623                    shell_quote(&entry.caller_file)
17624                ),
17625            }
17626        })
17627        .collect();
17628    let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
17629        .iter()
17630        .take(max_items)
17631        .map(|entry| {
17632            let symbol_ref = build_compact_symbol_ref(
17633                "eces",
17634                &format!(
17635                    "{}:{}:{}:{}",
17636                    entry.callee_name, entry.caller_file, entry.call_site_line, symbol
17637                ),
17638                &entry.callee_name,
17639                None,
17640                max_bytes,
17641            );
17642            ExplainBudgetEdgePreview {
17643                handle: symbol_ref.handle,
17644                tag_alias: symbol_ref.tag_alias,
17645                name: symbol_ref.name,
17646                file: truncate_for_budget(&entry.caller_file, max_bytes),
17647                line: entry.call_site_line,
17648                expand: format!(
17649                    "tsift explain {} --path {} --limit 0",
17650                    shell_quote(&entry.callee_name),
17651                    shell_quote(&entry.caller_file)
17652                ),
17653            }
17654        })
17655        .collect();
17656    let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
17657        size: entry.members.len(),
17658        members: entry
17659            .members
17660            .iter()
17661            .take(max_items)
17662            .map(|member| truncate_for_budget(&member.name, max_bytes))
17663            .collect(),
17664    });
17665
17666    ExplainBudgetReport {
17667        symbol: symbol.to_string(),
17668        max_items,
17669        max_bytes,
17670        definition_total: symbols.len(),
17671        callers_total,
17672        callers_truncated_by_limit,
17673        callees_total,
17674        callees_truncated_by_limit,
17675        truncated: symbols.len() > max_items
17676            || callers_total > callers_preview.len()
17677            || callees_total > callees_preview.len()
17678            || community
17679                .map(|entry| entry.members.len() > max_items)
17680                .unwrap_or(false),
17681        definitions,
17682        callers: callers_preview,
17683        callees: callees_preview,
17684        community: community_preview,
17685    }
17686}
17687
17688pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
17689    println!(
17690        "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
17691        shell_quote(&report.symbol),
17692        report.definitions.len(),
17693        report.definition_total,
17694        report.callers.len(),
17695        report.callers_total,
17696        report.callees.len(),
17697        report.callees_total
17698    );
17699    for entry in &report.definitions {
17700        println!(
17701            "def {} {} {}:{} expand:{}",
17702            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17703            entry.kind,
17704            entry.file,
17705            entry.line,
17706            entry.expand
17707        );
17708    }
17709    for entry in &report.callers {
17710        println!(
17711            "caller {} {}:{} expand:{}",
17712            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17713            entry.file,
17714            entry.line,
17715            entry.expand
17716        );
17717    }
17718    for entry in &report.callees {
17719        println!(
17720            "callee {} {}:{} expand:{}",
17721            format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
17722            entry.file,
17723            entry.line,
17724            entry.expand
17725        );
17726    }
17727    if let Some(community) = &report.community {
17728        println!(
17729            "community size:{} members:{}",
17730            community.size,
17731            community.members.join(", ")
17732        );
17733    }
17734    if report.truncated {
17735        println!(
17736            "budget truncated items:{} bytes:{}",
17737            report.max_items, report.max_bytes
17738        );
17739    }
17740}
17741
17742/// Reconcile the tsift symbol index against the tagpath `.naming/index.json`
17743/// source set and report files covered by one but not the other.
17744///
17745/// Today silent recall loss happens when tagpath's `[exclude]` / `extends`
17746/// chain or its hard-coded `SKIP_DIRS` skip files or languages that tsift
17747/// still indexes — the tsift symbols in those files cannot resolve a
17748/// `tagpath_handle` even with a fresh tagpath index. This audit surfaces
17749/// the diff so operators can decide whether to broaden the tagpath walk,
17750/// add an `[exclude]` to tsift, or accept the gap.
17751const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
17752    ".git",
17753    "node_modules",
17754    "target",
17755    "__pycache__",
17756    ".venv",
17757    "vendor",
17758];
17759
17760const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
17761    "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
17762    "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
17763    "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
17764    "sh", "bash", "zsh", "sql", "css", "tsx",
17765];
17766
17767pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
17768    let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
17769        .iter()
17770        .map(|ext| (*ext).to_string())
17771        .collect::<BTreeSet<_>>();
17772
17773    let config_path = root.join(".naming.toml");
17774    if !config_path.exists() {
17775        return extensions;
17776    }
17777
17778    match tagpath::config::resolve(&config_path) {
17779        Ok(config) => {
17780            if let Some(grammars) = config.grammars {
17781                for grammar in grammars.languages.values() {
17782                    for ext in &grammar.extensions {
17783                        if let Some(normalized) = normalize_extension(ext) {
17784                            extensions.insert(normalized);
17785                        }
17786                    }
17787                }
17788            }
17789        }
17790        Err(err) => {
17791            eprintln!("tagpath_policy_hint_config_unreadable: {err}");
17792        }
17793    }
17794    extensions
17795}
17796
17797pub(crate) fn tagpath_audit_policy_hints(
17798    rel_path: &str,
17799    supported_extensions: &BTreeSet<String>,
17800) -> Vec<String> {
17801    let path = Path::new(rel_path);
17802    let mut hints = BTreeSet::new();
17803    if let Some(parent) = path.parent() {
17804        for component in parent.components() {
17805            if let std::path::Component::Normal(name) = component {
17806                let name = name.to_string_lossy();
17807                if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
17808                    hints.insert(format!("skip_dir:{name}"));
17809                }
17810            }
17811        }
17812    }
17813    if path
17814        .extension()
17815        .and_then(|ext| ext.to_str())
17816        .and_then(normalize_extension)
17817        .is_some_and(|ext| !supported_extensions.contains(&ext))
17818    {
17819        hints.insert("extension_unsupported".to_string());
17820    }
17821    hints.into_iter().collect()
17822}
17823
17824fn normalize_extension(ext: &str) -> Option<String> {
17825    let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
17826    if normalized.is_empty() {
17827        None
17828    } else {
17829        Some(normalized)
17830    }
17831}
17832
17833pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
17834    match status {
17835        diff_digest::DiffDigestFileStatus::Added => "added",
17836        diff_digest::DiffDigestFileStatus::Modified => "modified",
17837        diff_digest::DiffDigestFileStatus::Deleted => "deleted",
17838    }
17839}
17840
17841pub(crate) fn diff_digest_summary_label(
17842    state: diff_digest::DiffDigestSummaryState,
17843) -> &'static str {
17844    match state {
17845        diff_digest::DiffDigestSummaryState::Current => "current",
17846        diff_digest::DiffDigestSummaryState::Stale => "stale",
17847        diff_digest::DiffDigestSummaryState::Missing => "missing",
17848        diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
17849    }
17850}
17851
17852fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
17853    match state {
17854        test_digest::TestDigestSummaryState::Current => "current",
17855        test_digest::TestDigestSummaryState::Stale => "stale",
17856        test_digest::TestDigestSummaryState::Missing => "missing",
17857        test_digest::TestDigestSummaryState::Unavailable => "unavailable",
17858    }
17859}
17860
17861fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
17862    match state {
17863        log_digest::LogDigestSummaryState::Current => "current",
17864        log_digest::LogDigestSummaryState::Stale => "stale",
17865        log_digest::LogDigestSummaryState::Missing => "missing",
17866        log_digest::LogDigestSummaryState::Unavailable => "unavailable",
17867    }
17868}
17869
17870pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
17871    match mode {
17872        diff_digest::DiffDigestMode::WorkingTree => "worktree",
17873        diff_digest::DiffDigestMode::Cached => "cached",
17874        diff_digest::DiffDigestMode::Revision => "revision",
17875    }
17876}
17877
17878pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
17879    match (&report.mode, &report.revision) {
17880        (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
17881        (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
17882        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17883            format!("revision {revision}")
17884        }
17885        (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
17886    }
17887}
17888
17889pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
17890    match (&report.mode, &report.revision) {
17891        (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
17892        (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
17893        (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
17894            format!("No diff found for revision {revision}.")
17895        }
17896        (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
17897    }
17898}
17899
17900fn cmd_impact(
17901    path: &Path,
17902    cached: bool,
17903    revision: Option<&str>,
17904    scope: Option<&str>,
17905    limit: usize,
17906    format: OutputFormat,
17907) -> Result<()> {
17908    let report = impact::compute(
17909        path,
17910        impact::ImpactOptions {
17911            cached,
17912            revision,
17913            scope,
17914            limit,
17915        },
17916    )?;
17917    if format.json_output {
17918        println!(
17919            "{}",
17920            to_json_schema(
17921                &report,
17922                format.pretty,
17923                format.terse,
17924                format.ultra_terse,
17925                format.schema
17926            )?
17927        );
17928        return Ok(());
17929    }
17930
17931    if format.compact {
17932        println!(
17933            "impact mode:{} changed:{} symbols:{} tests:{}/{}",
17934            diff_digest_mode_label(report.mode),
17935            report.changed_files.len(),
17936            report.changed_symbols.len(),
17937            report.affected_tests.len(),
17938            report.affected_tests_total
17939        );
17940        for target in &report.affected_tests {
17941            println!(
17942                "{} reasons:{} command:{}",
17943                target.path,
17944                target.reasons.len(),
17945                target.commands.join(" && ")
17946            );
17947        }
17948        for warning in &report.warnings {
17949            println!("warning {warning}");
17950        }
17951        return Ok(());
17952    }
17953
17954    println!("Impact ({})", diff_digest_mode_label(report.mode));
17955    println!("  changed files:          {}", report.changed_files.len());
17956    println!("  changed symbols:        {}", report.changed_symbols.len());
17957    println!(
17958        "  affected tests:         {}/{}",
17959        report.affected_tests.len(),
17960        report.affected_tests_total
17961    );
17962    for target in &report.affected_tests {
17963        println!();
17964        println!("{}", target.path);
17965        for reason in &target.reasons {
17966            println!("  - {reason}");
17967        }
17968        if !target.symbols.is_empty() {
17969            println!("  symbols: {}", target.symbols.join(", "));
17970        }
17971        for command in &target.commands {
17972            println!("  run: {}", command);
17973        }
17974    }
17975    for warning in &report.warnings {
17976        println!("warning: {warning}");
17977    }
17978    Ok(())
17979}
17980
17981pub(crate) fn render_test_digest_from_input(
17982    path: &Path,
17983    input: &str,
17984    runner: Option<&str>,
17985    format: OutputFormat,
17986) -> Result<()> {
17987    let report = test_digest::compute(path, input, runner)?;
17988    if format.json_output {
17989        println!(
17990            "{}",
17991            to_json_schema(
17992                &report,
17993                format.pretty,
17994                format.terse,
17995                format.ultra_terse,
17996                format.schema
17997            )?
17998        );
17999        return Ok(());
18000    }
18001
18002    if report.failure_groups.is_empty() {
18003        println!("No failures detected (runner: {}).", report.runner);
18004        for warning in &report.warnings {
18005            println!("warning: {warning}");
18006        }
18007        return Ok(());
18008    }
18009
18010    if format.compact {
18011        println!(
18012            "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18013            report.runner,
18014            report.failures,
18015            report.grouped_failures,
18016            report.counts.passed.unwrap_or(0),
18017            report.counts.failed.unwrap_or(report.grouped_failures),
18018            report.counts.skipped.unwrap_or(0),
18019        );
18020        for failure in &report.failure_groups {
18021            let tests = truncate_for_compact(&failure.tests.join(","), 60);
18022            let location = match (&failure.path, failure.line) {
18023                (Some(path), Some(line)) => format!("{path}:{line}"),
18024                (Some(path), None) => path.clone(),
18025                _ => "-".to_string(),
18026            };
18027            println!(
18028                "{} tests:{} count:{} summaries:{} msg:{}",
18029                location,
18030                tests,
18031                failure.occurrences,
18032                test_digest_summary_label(failure.summary_state),
18033                truncate_for_compact(&failure.message, 80)
18034            );
18035        }
18036        for warning in &report.warnings {
18037            println!("warning: {warning}");
18038        }
18039        return Ok(());
18040    }
18041
18042    println!("Test digest ({})", report.runner);
18043    println!("  failures:        {}", report.failures);
18044    println!("  failure groups:  {}", report.grouped_failures);
18045    if let Some(passed) = report.counts.passed {
18046        println!("  passed:          {}", passed);
18047    }
18048    if let Some(failed) = report.counts.failed {
18049        println!("  failed:          {}", failed);
18050    }
18051    if let Some(skipped) = report.counts.skipped {
18052        println!("  skipped:         {}", skipped);
18053    }
18054
18055    for failure in &report.failure_groups {
18056        println!();
18057        match (&failure.path, failure.line, failure.column) {
18058            (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18059            (Some(path), Some(line), None) => println!("{path}:{line}"),
18060            (Some(path), None, _) => println!("{path}"),
18061            (None, _, _) => println!("(no file anchor)"),
18062        }
18063        println!("  tests: {}", failure.tests.join(", "));
18064        println!("  occurrences: {}", failure.occurrences);
18065        println!("  message: {}", failure.message);
18066        println!(
18067            "  cached summaries: {}",
18068            test_digest_summary_label(failure.summary_state)
18069        );
18070        for summary in &failure.current_summaries {
18071            println!(
18072                "    - {}: {}",
18073                summary.symbol,
18074                truncate_for_compact(&summary.summary, 160)
18075            );
18076        }
18077    }
18078    for warning in &report.warnings {
18079        println!("warning: {warning}");
18080    }
18081    Ok(())
18082}
18083
18084#[derive(Clone, Serialize, Deserialize)]
18085struct DispatchTraceSummary {
18086    backlog: usize,
18087    job_packet: usize,
18088    worker_result: usize,
18089    worker_context: usize,
18090    source_handle: usize,
18091    semantic_rows: usize,
18092}
18093
18094#[derive(Clone, Serialize, Deserialize)]
18095struct DispatchTraceReport {
18096    contract_version: String,
18097    root: String,
18098    #[serde(skip_serializing_if = "Option::is_none")]
18099    scope: Option<String>,
18100    targets: Vec<String>,
18101    projection_freshness: GraphDbFreshnessReport,
18102    projection_hashes: Vec<String>,
18103    evidence_packet_ids: Vec<String>,
18104    shared_preparation: ConflictMatrixSharedPreparationSummary,
18105    worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18106    worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18107    summary: DispatchTraceSummary,
18108    nodes: Vec<SubstrateTerseGraphNode>,
18109    edges: Vec<SubstrateTerseGraphEdge>,
18110    conflict_matrix_decisions: Vec<String>,
18111    replay_commands: Vec<String>,
18112    repair_commands: Vec<String>,
18113    truncated: bool,
18114    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18115    warnings: Vec<String>,
18116}
18117
18118fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18119    matches!(
18120        kind,
18121        "session"
18122            | "backlog"
18123            | "job_packet"
18124            | "worker_result"
18125            | "worker_context"
18126            | "source_handle"
18127            | "semantic_concept"
18128            | "semantic_entity"
18129            | "file"
18130            | "symbol"
18131            | "route"
18132    )
18133}
18134
18135fn dispatch_trace_kind_rank(kind: &str) -> usize {
18136    match kind {
18137        "backlog" => 0,
18138        "job_packet" => 1,
18139        "worker_result" => 2,
18140        "worker_context" => 3,
18141        "source_handle" => 4,
18142        "file" => 5,
18143        "symbol" => 6,
18144        "route" => 7,
18145        "semantic_concept" => 8,
18146        "semantic_entity" => 9,
18147        "session" => 10,
18148        _ => 99,
18149    }
18150}
18151
18152fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18153    DispatchTraceSummary {
18154        backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18155        job_packet: nodes
18156            .iter()
18157            .filter(|node| node.kind == "job_packet")
18158            .count(),
18159        worker_result: nodes
18160            .iter()
18161            .filter(|node| node.kind == "worker_result")
18162            .count(),
18163        worker_context: nodes
18164            .iter()
18165            .filter(|node| node.kind == "worker_context")
18166            .count(),
18167        source_handle: nodes
18168            .iter()
18169            .filter(|node| node.kind == "source_handle")
18170            .count(),
18171        semantic_rows: nodes
18172            .iter()
18173            .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18174            .count(),
18175    }
18176}
18177
18178fn dispatch_trace_shared_preparation_summary(
18179    graph_nodes: &[SubstrateGraphNode],
18180    graph_edges: &[SubstrateGraphEdge],
18181    conflict: &ConflictMatrixReport,
18182) -> ConflictMatrixSharedPreparationSummary {
18183    ConflictMatrixSharedPreparationSummary {
18184        evidence_cache_status: conflict
18185            .inputs
18186            .shared_preparation
18187            .evidence_cache_status
18188            .clone(),
18189        graph_nodes: graph_nodes.len(),
18190        graph_edges: graph_edges.len(),
18191        evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18192        source_handles: conflict
18193            .candidates
18194            .iter()
18195            .map(|candidate| candidate.source_handles.len())
18196            .sum(),
18197        worker_context: conflict
18198            .candidates
18199            .iter()
18200            .map(|candidate| candidate.worker_context_handles.len())
18201            .sum(),
18202        worker_results: conflict
18203            .candidates
18204            .iter()
18205            .map(|candidate| candidate.worker_feedback.total)
18206            .sum(),
18207        semantic_rows: conflict
18208            .candidates
18209            .iter()
18210            .map(|candidate| candidate.semantic_related.len())
18211            .sum(),
18212        dispatch_trace_snapshot_nodes: graph_nodes.len(),
18213        dispatch_trace_snapshot_edges: graph_edges.len(),
18214    }
18215}
18216
18217fn dispatch_trace_collect_ids(
18218    targets: &[String],
18219    candidates: &[ConflictMatrixCandidate],
18220    graph_nodes: &[SubstrateGraphNode],
18221    graph_edges: &[SubstrateGraphEdge],
18222    depth: usize,
18223    limit: usize,
18224) -> (BTreeSet<String>, bool) {
18225    let target_refs = targets
18226        .iter()
18227        .map(|target| target.trim_start_matches('#').to_string())
18228        .collect::<BTreeSet<_>>();
18229    let mut ids = BTreeSet::new();
18230    for candidate in candidates {
18231        ids.insert(candidate.target_node_id.clone());
18232        for source in &candidate.source_handles {
18233            ids.insert(source.handle.clone());
18234        }
18235        for handle in &candidate.worker_context_handles {
18236            ids.insert(handle.clone());
18237        }
18238        for semantic in &candidate.semantic_related {
18239            ids.insert(semantic.handle.clone());
18240        }
18241    }
18242    for node in graph_nodes {
18243        if !dispatch_trace_allowed_node_kind(&node.kind) {
18244            continue;
18245        }
18246        if node
18247            .properties
18248            .get("ref_id")
18249            .is_some_and(|ref_id| target_refs.contains(ref_id))
18250        {
18251            ids.insert(node.id.clone());
18252        }
18253    }
18254
18255    let node_by_id = graph_nodes
18256        .iter()
18257        .map(|node| (node.id.as_str(), node))
18258        .collect::<BTreeMap<_, _>>();
18259    let max_nodes = if limit == 0 {
18260        usize::MAX
18261    } else {
18262        limit
18263            .saturating_mul(targets.len().max(1))
18264            .saturating_mul(12)
18265            .max(64)
18266    };
18267    let mut truncated = false;
18268    for _ in 0..depth.max(1) {
18269        let before = ids.len();
18270        let current_ids = ids.clone();
18271        for edge in graph_edges {
18272            if ids.len() >= max_nodes {
18273                truncated = true;
18274                break;
18275            }
18276            let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18277            if !touches {
18278                continue;
18279            }
18280            for endpoint in [&edge.from_id, &edge.to_id] {
18281                let Some(node) = node_by_id.get(endpoint.as_str()) else {
18282                    continue;
18283                };
18284                if dispatch_trace_allowed_node_kind(&node.kind) {
18285                    ids.insert(endpoint.clone());
18286                }
18287            }
18288        }
18289        if ids.len() == before || truncated {
18290            break;
18291        }
18292    }
18293    (ids, truncated)
18294}
18295
18296#[allow(clippy::too_many_arguments)]
18297fn build_dispatch_trace_report_from_conflict_snapshot(
18298    root: &Path,
18299    scope: Option<&str>,
18300    conflict: ConflictMatrixReport,
18301    graph_nodes: Vec<SubstrateGraphNode>,
18302    graph_edges: Vec<SubstrateGraphEdge>,
18303    depth: usize,
18304    limit: usize,
18305    extra_warnings: Vec<String>,
18306) -> Result<DispatchTraceReport> {
18307    let shared_preparation =
18308        dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18309    let (ids, truncated) = dispatch_trace_collect_ids(
18310        &conflict.targets,
18311        &conflict.candidates,
18312        &graph_nodes,
18313        &graph_edges,
18314        depth,
18315        limit,
18316    );
18317    let mut nodes = graph_nodes
18318        .into_iter()
18319        .filter(|node| ids.contains(&node.id))
18320        .collect::<Vec<_>>();
18321    nodes.sort_by(|left, right| {
18322        dispatch_trace_kind_rank(&left.kind)
18323            .cmp(&dispatch_trace_kind_rank(&right.kind))
18324            .then(left.id.cmp(&right.id))
18325    });
18326    let node_ids = nodes
18327        .iter()
18328        .map(|node| node.id.as_str())
18329        .collect::<BTreeSet<_>>();
18330    let mut edges = graph_edges
18331        .into_iter()
18332        .filter(|edge| {
18333            node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18334        })
18335        .collect::<Vec<_>>();
18336    edges.sort_by(|left, right| {
18337        left.from_id
18338            .cmp(&right.from_id)
18339            .then(left.kind.cmp(&right.kind))
18340            .then(left.to_id.cmp(&right.to_id))
18341    });
18342    let mut warnings = conflict.warnings;
18343    warnings.extend(extra_warnings);
18344
18345    Ok(DispatchTraceReport {
18346        contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
18347        root: conflict.root,
18348        scope: conflict.scope,
18349        targets: conflict.targets,
18350        projection_freshness: conflict.orchestration.projection_freshness,
18351        projection_hashes: conflict.orchestration.projection_hashes,
18352        evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
18353        shared_preparation,
18354        worker_prompt_packets: conflict.worker_prompt_packets,
18355        worker_feedback: conflict
18356            .candidates
18357            .iter()
18358            .map(|candidate| candidate.worker_feedback.clone())
18359            .collect(),
18360        summary: dispatch_trace_summary(&nodes),
18361        nodes: nodes.into_iter().map(Into::into).collect(),
18362        edges: edges.into_iter().map(Into::into).collect(),
18363        conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
18364        replay_commands: conflict.next_commands,
18365        repair_commands: graph_db_repair_commands(root, scope),
18366        truncated,
18367        warnings,
18368    })
18369}
18370
18371fn build_dispatch_trace_report(
18372    path: &Path,
18373    scope: Option<&str>,
18374    raw_targets: &[String],
18375    depth: usize,
18376    limit: usize,
18377    impact_limit: usize,
18378) -> Result<DispatchTraceReport> {
18379    let root = lint::resolve_project_root_or_canonical_path(path)?;
18380    let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
18381    if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
18382        write_traversal_graph_store(&root, path, scope)
18383            .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
18384    }
18385    let graph_db = graph_substrate_db_path(&root, scope);
18386    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
18387        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
18388    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
18389    let extra_warnings = store
18390        .read_only_recovery()
18391        .map(graph_db_read_recovery_diagnostic)
18392        .into_iter()
18393        .collect::<Vec<_>>();
18394    let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
18395    let graph_prepared = prepare_conflict_matrix_graph_orchestration(
18396        &root,
18397        scope,
18398        "sqlite",
18399        raw_targets,
18400        &prepared,
18401        depth,
18402        limit,
18403        &store,
18404        freshness.clone(),
18405    )?;
18406    let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
18407        &prepared.preparation_cache.source_watermark,
18408        &prepared.preparation_cache.document_watermark,
18409        &prepared.preparation_cache.staged_diff_watermark,
18410        &[
18411            &format!("targets:{}", raw_targets.join(",")),
18412            &format!("depth:{depth}"),
18413            &format!("limit:{limit}"),
18414        ],
18415    );
18416    if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
18417        &root,
18418        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18419        &dt_cache_key,
18420    ) {
18421        return Ok(cached_report);
18422    }
18423    let conflict = build_conflict_matrix_report_from_prepared_graph(
18424        &root,
18425        path,
18426        scope,
18427        depth,
18428        limit,
18429        impact_limit,
18430        freshness,
18431        extra_warnings.clone(),
18432        &prepared,
18433        &graph_prepared,
18434    )?;
18435    let report = build_dispatch_trace_report_from_conflict_snapshot(
18436        &root,
18437        scope,
18438        conflict,
18439        graph_prepared.graph.nodes,
18440        graph_prepared.graph.edges,
18441        depth,
18442        limit,
18443        extra_warnings,
18444    )?;
18445    cycle_packet_cache::cycle_packet_write_cache(
18446        &root,
18447        cycle_packet_cache::CyclePacketKind::ConflictMatrix,
18448        &dt_cache_key,
18449        &report,
18450    );
18451    Ok(report)
18452}
18453
18454fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
18455    let json = serde_json::to_string(report)?.replace("</", "<\\/");
18456    let mut html = String::new();
18457    html.push_str(
18458        "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
18459    );
18460    html.push_str(
18461        r#"<style>
18462:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
18463@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
18464*{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}}
18465</style>"#,
18466    );
18467    html.push_str("</head><body><div class=\"page\">");
18468    html.push_str(&format!(
18469        "<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>",
18470        html_escape(&report.targets.join(", ")),
18471        report.evidence_packet_ids.len(),
18472        report.nodes.len(),
18473        report.worker_prompt_packets.len(),
18474        html_escape(&report.contract_version)
18475    ));
18476    html.push_str(
18477        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>"#,
18478    );
18479    html.push_str("<script id=\"trace-data\" type=\"application/json\">");
18480    html.push_str(&json);
18481    html.push_str(
18482        r##"</script><script>
18483const report = JSON.parse(document.getElementById("trace-data").textContent);
18484const svg = document.getElementById("graph-canvas");
18485const nodeList = document.getElementById("nodes");
18486const packets = document.getElementById("packets");
18487const feedback = document.getElementById("feedback");
18488const nodes = report.nodes.map((node, index) => ({...node, index}));
18489const nodeById = new Map(nodes.map(node => [node.id, node]));
18490const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
18491const 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"]]);
18492function color(kind){return colorByKind.get(kind)||"#6b7280";}
18493function text(value){return value == null ? "" : String(value);}
18494function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]));}
18495function layout(){
18496  const rect = svg.getBoundingClientRect();
18497  const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
18498  const kinds = [...new Set(nodes.map(node => node.kind))].sort();
18499  const counts = new Map();
18500  for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
18501  const offsets = new Map();
18502  for (const node of nodes) {
18503    const group = kinds.indexOf(node.kind);
18504    const index = offsets.get(node.kind) || 0;
18505    offsets.set(node.kind, index + 1);
18506    const total = counts.get(node.kind) || 1;
18507    const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
18508    const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
18509    node.x = cx + Math.cos(angle) * ring;
18510    node.y = cy + Math.sin(angle) * ring;
18511  }
18512}
18513function draw(){
18514  svg.innerHTML = "";
18515  for (const edge of edges) {
18516    const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
18517    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
18518    line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
18519    line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
18520    line.setAttribute("class", "edge");
18521    line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
18522    svg.appendChild(line);
18523  }
18524  for (const node of nodes) {
18525    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
18526    circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
18527    circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
18528    circle.setAttribute("fill", color(node.kind));
18529    circle.setAttribute("class", "node");
18530    circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
18531    svg.appendChild(circle);
18532    const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
18533    label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
18534    label.setAttribute("class", "node-label");
18535    label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
18536    svg.appendChild(label);
18537  }
18538}
18539packets.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>";
18540feedback.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>";
18541nodeList.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("");
18542window.addEventListener("resize", () => { layout(); draw(); });
18543layout(); draw();
18544</script></div></body></html>"##,
18545    );
18546    Ok(html)
18547}
18548
18549struct DispatchTraceOptions<'a> {
18550    path: &'a Path,
18551    scope: Option<&'a str>,
18552    raw_targets: &'a [String],
18553    depth: usize,
18554    limit: usize,
18555    impact_limit: usize,
18556    trace_format: DispatchTraceFormat,
18557}
18558
18559fn cmd_dispatch_trace(
18560    options: DispatchTraceOptions<'_>,
18561    output_format: OutputFormat,
18562) -> Result<()> {
18563    let report = build_dispatch_trace_report(
18564        options.path,
18565        options.scope,
18566        options.raw_targets,
18567        options.depth,
18568        options.limit,
18569        options.impact_limit,
18570    )?;
18571    match options.trace_format {
18572        DispatchTraceFormat::Json => {
18573            if output_format.envelope {
18574                print_json_or_envelope(
18575                    &report,
18576                    &output_format,
18577                    "dispatch-trace",
18578                    "operator-review",
18579                    ToolEnvelopeSummary {
18580                        text: format!(
18581                            "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
18582                            report.targets.len(),
18583                            report.nodes.len(),
18584                            report.worker_prompt_packets.len()
18585                        ),
18586                        metrics: vec![
18587                            envelope_metric("targets", report.targets.len()),
18588                            envelope_metric("nodes", report.nodes.len()),
18589                            envelope_metric("edges", report.edges.len()),
18590                            envelope_metric(
18591                                "worker_prompt_packets",
18592                                report.worker_prompt_packets.len(),
18593                            ),
18594                        ],
18595                    },
18596                    report.truncated,
18597                    report.replay_commands.clone(),
18598                )
18599            } else {
18600                println!(
18601                    "{}",
18602                    to_json_schema(
18603                        &report,
18604                        output_format.pretty,
18605                        output_format.terse,
18606                        output_format.ultra_terse,
18607                        output_format.schema
18608                    )?
18609                );
18610                Ok(())
18611            }
18612        }
18613        DispatchTraceFormat::Html => {
18614            println!("{}", dispatch_trace_html(&report)?);
18615            Ok(())
18616        }
18617    }
18618}
18619
18620#[derive(Clone, Debug)]
18621struct DependencyDagProfile {
18622    id: String,
18623    graph_node_id: String,
18624    label: String,
18625    path: Option<String>,
18626    line: Option<i64>,
18627    detail: Option<String>,
18628    source_files: BTreeSet<String>,
18629    source_symbols: BTreeSet<String>,
18630    config_files: BTreeSet<String>,
18631    expected_tests: BTreeSet<String>,
18632    semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
18633    worker_feedback: ConflictMatrixWorkerFeedback,
18634}
18635
18636#[derive(Clone, Debug, Serialize)]
18637struct DependencyDagNode {
18638    id: String,
18639    graph_node_id: String,
18640    label: String,
18641    #[serde(skip_serializing_if = "Option::is_none")]
18642    path: Option<String>,
18643    #[serde(skip_serializing_if = "Option::is_none")]
18644    line: Option<i64>,
18645    #[serde(skip_serializing_if = "Option::is_none")]
18646    detail: Option<String>,
18647    source_files: Vec<String>,
18648    source_symbols: Vec<String>,
18649    config_files: Vec<String>,
18650    expected_tests: Vec<String>,
18651    semantic_refs: Vec<ConflictMatrixSemanticRef>,
18652    worker_feedback: ConflictMatrixWorkerFeedback,
18653}
18654
18655#[derive(Clone, Debug, Serialize)]
18656struct DependencyDagEdge {
18657    from: String,
18658    to: String,
18659    kind: String,
18660    weight: usize,
18661    reasons: Vec<String>,
18662    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18663    shared_files: Vec<String>,
18664    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18665    shared_symbols: Vec<String>,
18666    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18667    shared_tests: Vec<String>,
18668    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18669    shared_config_files: Vec<String>,
18670    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18671    shared_semantic_refs: Vec<String>,
18672}
18673
18674#[derive(Clone, Debug, Serialize)]
18675struct DependencyDagTopoBatch {
18676    batch: usize,
18677    targets: Vec<String>,
18678}
18679
18680#[derive(Clone, Debug, Serialize)]
18681struct DependencyDagCycleDiagnostics {
18682    has_cycles: bool,
18683    blocked_nodes: Vec<String>,
18684    cycle_edges: Vec<DependencyDagEdge>,
18685}
18686
18687#[derive(Serialize)]
18688struct DependencyDagSummary {
18689    nodes: usize,
18690    edges: usize,
18691    topo_batches: usize,
18692    has_cycles: bool,
18693}
18694
18695#[derive(Serialize)]
18696struct DependencyDagReport {
18697    contract_version: &'static str,
18698    root: String,
18699    #[serde(skip_serializing_if = "Option::is_none")]
18700    scope: Option<String>,
18701    path: String,
18702    targets: Vec<String>,
18703    projection_freshness: GraphDbFreshnessReport,
18704    projection_hashes: Vec<String>,
18705    nodes: Vec<DependencyDagNode>,
18706    edges: Vec<DependencyDagEdge>,
18707    topo_batches: Vec<DependencyDagTopoBatch>,
18708    cycle_diagnostics: DependencyDagCycleDiagnostics,
18709    summary: DependencyDagSummary,
18710    replay_commands: Vec<String>,
18711    repair_commands: Vec<String>,
18712    #[serde(skip_serializing_if = "Vec::is_empty", default)]
18713    warnings: Vec<String>,
18714}
18715
18716fn dependency_dag_backlog_node_for_target(
18717    store: &impl GraphStore,
18718    target: &str,
18719) -> Result<SubstrateGraphNode> {
18720    let resolved = graph_db_resolve_evidence_target(store, target)?
18721        .with_context(|| format!("dependency-dag target not found: {target}"))?;
18722    if resolved.kind == "backlog" {
18723        return Ok(resolved);
18724    }
18725    let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
18726        bail!(
18727            "dependency-dag target {} resolved to {} without a backlog ref_id",
18728            target,
18729            resolved.kind
18730        );
18731    };
18732    store
18733        .nodes_by_kind("backlog")?
18734        .into_iter()
18735        .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
18736        .min_by(|left, right| {
18737            left.properties
18738                .get("line")
18739                .and_then(|value| value.parse::<i64>().ok())
18740                .cmp(
18741                    &right
18742                        .properties
18743                        .get("line")
18744                        .and_then(|value| value.parse::<i64>().ok()),
18745                )
18746                .then(left.id.cmp(&right.id))
18747        })
18748        .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
18749}
18750
18751fn dependency_dag_resolve_backlog_nodes(
18752    root: &Path,
18753    path: &Path,
18754    store: &impl GraphStore,
18755    raw_targets: &[String],
18756) -> Result<Vec<SubstrateGraphNode>> {
18757    let mut nodes = Vec::new();
18758    let mut seen = BTreeSet::new();
18759    if raw_targets.is_empty() {
18760        let hinted_path = if path.is_absolute() {
18761            path.to_path_buf()
18762        } else {
18763            root.join(path)
18764        };
18765        let hinted_markdown = hinted_path
18766            .extension()
18767            .and_then(|ext| ext.to_str())
18768            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
18769        let hinted_rel = hinted_markdown.then(|| {
18770            relativize_pathbuf(&hinted_path, root)
18771                .to_string_lossy()
18772                .replace('\\', "/")
18773        });
18774        for node in store.nodes_by_kind("backlog")? {
18775            if let Some(expected_path) = &hinted_rel
18776                && node.properties.get("path") != Some(expected_path)
18777            {
18778                continue;
18779            }
18780            if seen.insert(node.id.clone()) {
18781                nodes.push(node);
18782            }
18783        }
18784        if nodes.is_empty() && hinted_rel.is_some() {
18785            for node in store.nodes_by_kind("backlog")? {
18786                if seen.insert(node.id.clone()) {
18787                    nodes.push(node);
18788                }
18789            }
18790        }
18791    } else {
18792        for target in raw_targets {
18793            let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
18794            let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
18795            if seen.insert(node.id.clone()) {
18796                nodes.push(node);
18797            }
18798        }
18799    }
18800    if nodes.is_empty() {
18801        bail!("dependency-dag needs at least one resolvable backlog id");
18802    }
18803    nodes.sort_by(|left, right| {
18804        left.properties
18805            .get("line")
18806            .and_then(|value| value.parse::<i64>().ok())
18807            .cmp(
18808                &right
18809                    .properties
18810                    .get("line")
18811                    .and_then(|value| value.parse::<i64>().ok()),
18812            )
18813            .then(left.id.cmp(&right.id))
18814    });
18815    Ok(nodes)
18816}
18817
18818fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
18819    node.properties
18820        .get("ref_id")
18821        .cloned()
18822        .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
18823}
18824
18825fn dependency_dag_node_profile(
18826    root: &Path,
18827    store: &impl GraphStore,
18828    node: &SubstrateGraphNode,
18829    graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
18830    graph_edges: &[SubstrateGraphEdge],
18831    depth: usize,
18832    limit: usize,
18833) -> Result<DependencyDagProfile> {
18834    let id = dependency_dag_node_id(node);
18835    let mut source_files = BTreeSet::new();
18836    let mut source_symbols = BTreeSet::new();
18837    for edge in graph_edges
18838        .iter()
18839        .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
18840    {
18841        let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
18842            continue;
18843        };
18844        match target.kind.as_str() {
18845            "file" | "route" => {
18846                if let Some(path) = target.properties.get("path") {
18847                    source_files.insert(path.clone());
18848                }
18849            }
18850            "symbol" => {
18851                source_symbols.insert(target.label.clone());
18852                if let Some(path) = target.properties.get("path") {
18853                    source_files.insert(path.clone());
18854                }
18855            }
18856            _ => {}
18857        }
18858    }
18859
18860    let max_rows = if limit == 0 { usize::MAX } else { limit };
18861    for (source, _) in
18862        graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
18863    {
18864        let terse: SubstrateTerseGraphNode = (&source).into();
18865        if let Some(handle) = conflict_matrix_source_handle(&terse) {
18866            source_files.insert(handle.file);
18867        }
18868    }
18869
18870    let worker_results = graph_nodes_by_id
18871        .values()
18872        .filter(|candidate| {
18873            candidate.kind == "worker_result"
18874                && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
18875        })
18876        .map(SubstrateTerseGraphNode::from)
18877        .collect::<Vec<_>>();
18878    let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
18879    let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
18880    let config_files = source_files
18881        .iter()
18882        .filter(|file| is_planner_config_path(file))
18883        .cloned()
18884        .collect();
18885
18886    let mut semantic_refs = BTreeMap::new();
18887    for kind in ["semantic_concept", "semantic_entity"] {
18888        for (semantic, _) in
18889            graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
18890        {
18891            let terse: SubstrateTerseGraphNode = (&semantic).into();
18892            let item = conflict_matrix_semantic_ref(root, &terse);
18893            semantic_refs
18894                .entry(format!("{}:{}", item.kind, item.label))
18895                .or_insert(item);
18896        }
18897    }
18898
18899    Ok(DependencyDagProfile {
18900        id,
18901        graph_node_id: node.id.clone(),
18902        label: node.label.clone(),
18903        path: node.properties.get("path").cloned(),
18904        line: node
18905            .properties
18906            .get("line")
18907            .and_then(|value| value.parse::<i64>().ok()),
18908        detail: node.properties.get("detail").cloned(),
18909        source_files,
18910        source_symbols,
18911        config_files,
18912        expected_tests,
18913        semantic_refs,
18914        worker_feedback,
18915    })
18916}
18917
18918fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
18919    let lower = text.to_ascii_lowercase();
18920    let mut refs = Vec::new();
18921    for marker in markers {
18922        let mut offset = 0usize;
18923        while let Some(pos) = lower[offset..].find(marker) {
18924            let start = offset + pos + marker.len();
18925            let segment = text[start..]
18926                .split(['\n', '.'])
18927                .next()
18928                .unwrap_or(&text[start..]);
18929            refs.extend(extract_conflict_target_refs(segment));
18930            offset = start;
18931        }
18932    }
18933    dedupe_preserve_order(refs)
18934}
18935
18936fn dependency_dag_push_edge(
18937    edges: &mut Vec<DependencyDagEdge>,
18938    seen: &mut BTreeSet<(String, String, String)>,
18939    edge: DependencyDagEdge,
18940) {
18941    if edge.from == edge.to {
18942        return;
18943    }
18944    if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
18945        edges.push(edge);
18946    }
18947}
18948
18949fn dependency_dag_explicit_edges(
18950    profiles: &[DependencyDagProfile],
18951    target_ids: &BTreeSet<String>,
18952    edges: &mut Vec<DependencyDagEdge>,
18953    seen: &mut BTreeSet<(String, String, String)>,
18954) {
18955    for profile in profiles {
18956        let detail = profile.detail.as_deref().unwrap_or_default();
18957        for dep in dependency_dag_marker_refs(
18958            detail,
18959            &[
18960                "depends on",
18961                "depends-on",
18962                "deps:",
18963                "after",
18964                "blocked by",
18965                "requires",
18966            ],
18967        ) {
18968            if target_ids.contains(&dep) {
18969                dependency_dag_push_edge(
18970                    edges,
18971                    seen,
18972                    DependencyDagEdge {
18973                        from: dep.clone(),
18974                        to: profile.id.clone(),
18975                        kind: "explicit_depends_on".to_string(),
18976                        weight: 1000,
18977                        reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
18978                        shared_files: Vec::new(),
18979                        shared_symbols: Vec::new(),
18980                        shared_tests: Vec::new(),
18981                        shared_config_files: Vec::new(),
18982                        shared_semantic_refs: Vec::new(),
18983                    },
18984                );
18985            }
18986        }
18987        for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
18988            if target_ids.contains(&downstream) {
18989                dependency_dag_push_edge(
18990                    edges,
18991                    seen,
18992                    DependencyDagEdge {
18993                        from: profile.id.clone(),
18994                        to: downstream.clone(),
18995                        kind: "explicit_before".to_string(),
18996                        weight: 900,
18997                        reasons: vec![format!(
18998                            "{} declares it should run before #{downstream}",
18999                            profile.id
19000                        )],
19001                        shared_files: Vec::new(),
19002                        shared_symbols: Vec::new(),
19003                        shared_tests: Vec::new(),
19004                        shared_config_files: Vec::new(),
19005                        shared_semantic_refs: Vec::new(),
19006                    },
19007                );
19008            }
19009        }
19010    }
19011}
19012
19013fn dependency_dag_worker_follow_up_edges(
19014    profiles: &[DependencyDagProfile],
19015    target_ids: &BTreeSet<String>,
19016    edges: &mut Vec<DependencyDagEdge>,
19017    seen: &mut BTreeSet<(String, String, String)>,
19018) {
19019    for profile in profiles {
19020        for follow_up in &profile.worker_feedback.follow_up_ids {
19021            if target_ids.contains(follow_up) {
19022                dependency_dag_push_edge(
19023                    edges,
19024                    seen,
19025                    DependencyDagEdge {
19026                        from: profile.id.clone(),
19027                        to: follow_up.clone(),
19028                        kind: "worker_result_follow_up".to_string(),
19029                        weight: 700,
19030                        reasons: vec![format!(
19031                            "worker_result for #{} references follow-up #{}",
19032                            profile.id, follow_up
19033                        )],
19034                        shared_files: Vec::new(),
19035                        shared_symbols: Vec::new(),
19036                        shared_tests: Vec::new(),
19037                        shared_config_files: Vec::new(),
19038                        shared_semantic_refs: Vec::new(),
19039                    },
19040                );
19041            }
19042        }
19043    }
19044}
19045
19046fn dependency_dag_overlap_edges(
19047    profiles: &[DependencyDagProfile],
19048    edges: &mut Vec<DependencyDagEdge>,
19049    seen: &mut BTreeSet<(String, String, String)>,
19050) {
19051    for left_idx in 0..profiles.len() {
19052        for right_idx in (left_idx + 1)..profiles.len() {
19053            let left = &profiles[left_idx];
19054            let right = &profiles[right_idx];
19055            let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19056            let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19057            let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19058            let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19059            let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19060            let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19061            let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19062            if shared_files.is_empty()
19063                && shared_symbols.is_empty()
19064                && shared_tests.is_empty()
19065                && shared_config_files.is_empty()
19066                && shared_semantic_refs.is_empty()
19067            {
19068                continue;
19069            }
19070            let kind = if shared_files.is_empty()
19071                && shared_symbols.is_empty()
19072                && shared_tests.is_empty()
19073                && shared_config_files.is_empty()
19074            {
19075                "semantic_relation"
19076            } else {
19077                "shared_resource"
19078            };
19079            let mut reasons = Vec::new();
19080            if !shared_files.is_empty() {
19081                reasons.push(format!("shared files: {}", shared_files.join(", ")));
19082            }
19083            if !shared_symbols.is_empty() {
19084                reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19085            }
19086            if !shared_tests.is_empty() {
19087                reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19088            }
19089            if !shared_config_files.is_empty() {
19090                reasons.push(format!(
19091                    "shared config files: {}",
19092                    shared_config_files.join(", ")
19093                ));
19094            }
19095            if !shared_semantic_refs.is_empty() {
19096                reasons.push(format!(
19097                    "shared semantic refs: {}",
19098                    shared_semantic_refs.join(", ")
19099                ));
19100            }
19101            let weight = shared_files.len() * 100
19102                + shared_config_files.len() * 100
19103                + shared_symbols.len() * 40
19104                + shared_tests.len() * 10
19105                + shared_semantic_refs.len() * 5;
19106            dependency_dag_push_edge(
19107                edges,
19108                seen,
19109                DependencyDagEdge {
19110                    from: left.id.clone(),
19111                    to: right.id.clone(),
19112                    kind: kind.to_string(),
19113                    weight,
19114                    reasons,
19115                    shared_files,
19116                    shared_symbols,
19117                    shared_tests,
19118                    shared_config_files,
19119                    shared_semantic_refs,
19120                },
19121            );
19122        }
19123    }
19124}
19125
19126fn dependency_dag_topo_batches(
19127    targets: &[String],
19128    edges: &[DependencyDagEdge],
19129) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19130    let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19131    let order = targets
19132        .iter()
19133        .enumerate()
19134        .map(|(idx, id)| (id.clone(), idx))
19135        .collect::<BTreeMap<_, _>>();
19136    let mut indegree = targets
19137        .iter()
19138        .map(|id| (id.clone(), 0usize))
19139        .collect::<BTreeMap<_, _>>();
19140    let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19141    let mut seen_pairs = BTreeSet::<(String, String)>::new();
19142    for edge in edges {
19143        if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19144            continue;
19145        }
19146        if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19147            continue;
19148        }
19149        *indegree.entry(edge.to.clone()).or_default() += 1;
19150        outgoing
19151            .entry(edge.from.clone())
19152            .or_default()
19153            .push(edge.to.clone());
19154    }
19155    for values in outgoing.values_mut() {
19156        values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19157        values.dedup();
19158    }
19159
19160    let mut processed = BTreeSet::new();
19161    let mut batches = Vec::new();
19162    loop {
19163        let mut ready = targets
19164            .iter()
19165            .filter(|id| !processed.contains(*id))
19166            .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19167            .cloned()
19168            .collect::<Vec<_>>();
19169        ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19170        if ready.is_empty() {
19171            break;
19172        }
19173        for id in &ready {
19174            processed.insert(id.clone());
19175            for next in outgoing.get(id).into_iter().flatten() {
19176                if let Some(value) = indegree.get_mut(next) {
19177                    *value = value.saturating_sub(1);
19178                }
19179            }
19180        }
19181        batches.push(DependencyDagTopoBatch {
19182            batch: batches.len() + 1,
19183            targets: ready,
19184        });
19185    }
19186
19187    let blocked_nodes = targets
19188        .iter()
19189        .filter(|id| !processed.contains(*id))
19190        .cloned()
19191        .collect::<Vec<_>>();
19192    let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19193    let cycle_edges = edges
19194        .iter()
19195        .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19196        .cloned()
19197        .collect::<Vec<_>>();
19198    (
19199        batches,
19200        DependencyDagCycleDiagnostics {
19201            has_cycles: !blocked_nodes.is_empty(),
19202            blocked_nodes,
19203            cycle_edges,
19204        },
19205    )
19206}
19207
19208fn dependency_dag_replay_commands(
19209    path: &Path,
19210    scope: Option<&str>,
19211    targets: &[String],
19212    depth: usize,
19213    limit: usize,
19214) -> Vec<String> {
19215    let target_args = targets
19216        .iter()
19217        .map(|target| shell_quote(target))
19218        .collect::<Vec<_>>()
19219        .join(" ");
19220    let mut command = format!(
19221        "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19222        shell_quote(path.to_string_lossy().as_ref()),
19223        scope
19224            .map(|scope| format!(" --scope {}", shell_quote(scope)))
19225            .unwrap_or_default(),
19226        depth,
19227        limit
19228    );
19229    if !target_args.is_empty() {
19230        command.push(' ');
19231        command.push_str(&target_args);
19232    }
19233    vec![command]
19234}
19235
19236fn build_dependency_dag_report(
19237    path: &Path,
19238    scope: Option<&str>,
19239    raw_targets: &[String],
19240    depth: usize,
19241    limit: usize,
19242) -> Result<DependencyDagReport> {
19243    let root = lint::resolve_project_root_or_canonical_path(path)?;
19244    write_traversal_graph_store(&root, path, scope)
19245        .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19246    let graph_db = graph_substrate_db_path(&root, scope);
19247    let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19248        .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19249    let mut warnings = Vec::new();
19250    if let Some(recovery) = store.read_only_recovery() {
19251        warnings.push(graph_db_read_recovery_diagnostic(recovery));
19252    }
19253    let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19254    if freshness.fail_closed {
19255        bail!(
19256            "dependency-dag graph projection failed closed: {}; repair: {}",
19257            freshness.diagnostics.join("; "),
19258            graph_db_repair_commands(&root, scope).join("; ")
19259        );
19260    }
19261
19262    let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19263    let graph_nodes = store.all_nodes()?;
19264    let graph_edges = store.all_edges()?;
19265    let graph_nodes_by_id = graph_nodes
19266        .into_iter()
19267        .map(|node| (node.id.clone(), node))
19268        .collect::<BTreeMap<_, _>>();
19269    let profiles = target_nodes
19270        .iter()
19271        .map(|node| {
19272            dependency_dag_node_profile(
19273                &root,
19274                &store,
19275                node,
19276                &graph_nodes_by_id,
19277                &graph_edges,
19278                depth,
19279                limit,
19280            )
19281        })
19282        .collect::<Result<Vec<_>>>()?;
19283    let targets = profiles
19284        .iter()
19285        .map(|profile| profile.id.clone())
19286        .collect::<Vec<_>>();
19287    let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19288
19289    let mut edges = Vec::new();
19290    let mut seen_edges = BTreeSet::new();
19291    dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19292    dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19293    dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19294    edges.sort_by(|left, right| {
19295        left.from
19296            .cmp(&right.from)
19297            .then(left.to.cmp(&right.to))
19298            .then(left.kind.cmp(&right.kind))
19299    });
19300    let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19301
19302    let nodes = profiles
19303        .into_iter()
19304        .map(|profile| DependencyDagNode {
19305            id: profile.id,
19306            graph_node_id: profile.graph_node_id,
19307            label: profile.label,
19308            path: profile.path,
19309            line: profile.line,
19310            detail: profile.detail,
19311            source_files: sorted_set(&profile.source_files),
19312            source_symbols: sorted_set(&profile.source_symbols),
19313            config_files: sorted_set(&profile.config_files),
19314            expected_tests: sorted_set(&profile.expected_tests),
19315            semantic_refs: profile.semantic_refs.into_values().collect(),
19316            worker_feedback: profile.worker_feedback,
19317        })
19318        .collect::<Vec<_>>();
19319    let projection_hashes = freshness
19320        .content_hash
19321        .clone()
19322        .into_iter()
19323        .collect::<Vec<_>>();
19324    let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19325    let repair_commands = graph_db_repair_commands(&root, scope);
19326    let summary = DependencyDagSummary {
19327        nodes: nodes.len(),
19328        edges: edges.len(),
19329        topo_batches: topo_batches.len(),
19330        has_cycles: cycle_diagnostics.has_cycles,
19331    };
19332
19333    Ok(DependencyDagReport {
19334        contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19335        root: root.to_string_lossy().to_string(),
19336        scope: scope.map(str::to_string),
19337        path: path.to_string_lossy().to_string(),
19338        targets,
19339        projection_freshness: freshness,
19340        projection_hashes,
19341        nodes,
19342        edges,
19343        topo_batches,
19344        cycle_diagnostics,
19345        summary,
19346        replay_commands,
19347        repair_commands,
19348        warnings,
19349    })
19350}
19351
19352fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
19353    if compact {
19354        println!(
19355            "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
19356            report.targets.len(),
19357            report.edges.len(),
19358            report.topo_batches.len(),
19359            report.cycle_diagnostics.has_cycles
19360        );
19361    } else {
19362        println!("Dependency DAG");
19363        println!("  targets: {}", report.targets.join(", "));
19364        println!("  edges:   {}", report.edges.len());
19365        println!("  cycles:  {}", report.cycle_diagnostics.has_cycles);
19366    }
19367    for batch in &report.topo_batches {
19368        println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
19369    }
19370    for edge in &report.edges {
19371        println!(
19372            "edge {} -> {} kind:{} weight:{}",
19373            edge.from, edge.to, edge.kind, edge.weight
19374        );
19375        for reason in &edge.reasons {
19376            println!("  reason: {reason}");
19377        }
19378    }
19379    if report.cycle_diagnostics.has_cycles {
19380        println!(
19381            "cycle blocked nodes: {}",
19382            report.cycle_diagnostics.blocked_nodes.join(", ")
19383        );
19384    }
19385    for command in &report.replay_commands {
19386        println!("replay: {command}");
19387    }
19388    for command in &report.repair_commands {
19389        println!("repair: {command}");
19390    }
19391    for warning in &report.warnings {
19392        println!("warning: {warning}");
19393    }
19394}
19395
19396fn cmd_dependency_dag(
19397    path: &Path,
19398    scope: Option<&str>,
19399    raw_targets: &[String],
19400    depth: usize,
19401    limit: usize,
19402    format: OutputFormat,
19403) -> Result<()> {
19404    let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
19405    if format.json_output {
19406        print_json_or_envelope(
19407            &report,
19408            &format,
19409            "dependency-dag",
19410            "topological-planning",
19411            ToolEnvelopeSummary {
19412                text: format!(
19413                    "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
19414                    report.targets.len(),
19415                    report.edges.len(),
19416                    report.topo_batches.len(),
19417                    report.cycle_diagnostics.has_cycles
19418                ),
19419                metrics: vec![
19420                    envelope_metric("targets", report.targets.len()),
19421                    envelope_metric("edges", report.edges.len()),
19422                    envelope_metric("topo_batches", report.topo_batches.len()),
19423                    envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
19424                ],
19425            },
19426            report.cycle_diagnostics.has_cycles,
19427            report.replay_commands.clone(),
19428        )
19429    } else {
19430        print_dependency_dag_human(&report, format.compact);
19431        Ok(())
19432    }
19433}
19434
19435pub(crate) fn render_log_digest_from_input(
19436    path: &Path,
19437    input: &str,
19438    format: OutputFormat,
19439) -> Result<()> {
19440    let report = log_digest::compute(path, input)?;
19441    if format.json_output {
19442        println!(
19443            "{}",
19444            to_json_schema(
19445                &report,
19446                format.pretty,
19447                format.terse,
19448                format.ultra_terse,
19449                format.schema
19450            )?
19451        );
19452        return Ok(());
19453    }
19454
19455    if format.compact {
19456        println!(
19457            "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
19458            report.non_empty_lines,
19459            report.signal_groups,
19460            report.repeated_line_groups,
19461            report.file_ref_groups,
19462            report.symbol_ref_groups,
19463            report.stack_groups
19464        );
19465        for signal in &report.signals {
19466            let location = match (&signal.path, signal.line) {
19467                (Some(path), Some(line)) => format!("{path}:{line}"),
19468                (Some(path), None) => path.clone(),
19469                _ => "-".to_string(),
19470            };
19471            println!(
19472                "{} sev:{} count:{} sums:{} msg:{}",
19473                location,
19474                signal.severity,
19475                signal.occurrences,
19476                log_digest_summary_label(signal.summary_state),
19477                truncate_for_compact(&signal.message, 80)
19478            );
19479        }
19480        for repeated in &report.repeated_lines {
19481            println!(
19482                "repeat count:{} line:{}",
19483                repeated.occurrences,
19484                truncate_for_compact(&repeated.line, 80)
19485            );
19486        }
19487        for symbol in &report.symbol_refs {
19488            println!(
19489                "sym:{} count:{} sums:{}",
19490                symbol.symbol,
19491                symbol.occurrences,
19492                log_digest_summary_label(symbol.summary_state)
19493            );
19494        }
19495        for warning in &report.warnings {
19496            println!("warning: {warning}");
19497        }
19498        return Ok(());
19499    }
19500
19501    println!("Log digest");
19502    println!("  lines:                    {}", report.total_lines);
19503    println!("  non-empty lines:          {}", report.non_empty_lines);
19504    println!("  signal groups:            {}", report.signal_groups);
19505    println!(
19506        "  repeated lines:           {}",
19507        report.repeated_line_groups
19508    );
19509    println!(
19510        "  repeated line instances:  {}",
19511        report.repeated_line_occurrences
19512    );
19513    println!("  file refs:                {}", report.file_ref_groups);
19514    println!("  symbol refs:              {}", report.symbol_ref_groups);
19515    println!("  stack groups:             {}", report.stack_groups);
19516
19517    if !report.signals.is_empty() {
19518        println!();
19519        println!("Signals:");
19520        for signal in &report.signals {
19521            match (&signal.path, signal.line, signal.column) {
19522                (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
19523                (Some(path), Some(line), None) => println!("{path}:{line}"),
19524                (Some(path), None, _) => println!("{path}"),
19525                (None, _, _) => println!("(no file anchor)"),
19526            }
19527            println!("  severity: {}", signal.severity);
19528            println!("  occurrences: {}", signal.occurrences);
19529            println!("  message: {}", signal.message);
19530            println!(
19531                "  cached summaries: {}",
19532                log_digest_summary_label(signal.summary_state)
19533            );
19534            for summary in &signal.current_summaries {
19535                println!(
19536                    "    - {}: {}",
19537                    summary.symbol,
19538                    truncate_for_compact(&summary.summary, 160)
19539                );
19540            }
19541        }
19542    }
19543
19544    if !report.repeated_lines.is_empty() {
19545        println!();
19546        println!("Repeated lines:");
19547        for repeated in &report.repeated_lines {
19548            println!(
19549                "  {}x {}",
19550                repeated.occurrences,
19551                truncate_for_compact(&repeated.line, 180)
19552            );
19553        }
19554    }
19555
19556    if !report.file_refs.is_empty() {
19557        println!();
19558        println!("Anchored files:");
19559        for file_ref in &report.file_refs {
19560            match (file_ref.line, file_ref.column) {
19561                (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
19562                (Some(line), None) => println!("{}:{}", file_ref.path, line),
19563                (None, _) => println!("{}", file_ref.path),
19564            }
19565            println!("  occurrences: {}", file_ref.occurrences);
19566            println!(
19567                "  cached summaries: {}",
19568                log_digest_summary_label(file_ref.summary_state)
19569            );
19570            for summary in &file_ref.current_summaries {
19571                println!(
19572                    "    - {}: {}",
19573                    summary.symbol,
19574                    truncate_for_compact(&summary.summary, 160)
19575                );
19576            }
19577        }
19578    }
19579
19580    if !report.symbol_refs.is_empty() {
19581        println!();
19582        println!("Symbol candidates:");
19583        for symbol in &report.symbol_refs {
19584            println!("{}", symbol.symbol);
19585            println!("  occurrences: {}", symbol.occurrences);
19586            println!(
19587                "  cached summaries: {}",
19588                log_digest_summary_label(symbol.summary_state)
19589            );
19590            for summary in &symbol.current_summaries {
19591                println!(
19592                    "    - {}: {}",
19593                    summary.symbol,
19594                    truncate_for_compact(&summary.summary, 160)
19595                );
19596            }
19597        }
19598    }
19599
19600    if !report.stack_traces.is_empty() {
19601        println!();
19602        println!("Stack groups:");
19603        for stack in &report.stack_traces {
19604            println!("  occurrences: {}", stack.occurrences);
19605            for frame in &stack.frames {
19606                println!("    - {}", frame);
19607            }
19608        }
19609    }
19610
19611    for warning in &report.warnings {
19612        println!("warning: {warning}");
19613    }
19614    Ok(())
19615}
19616
19617pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
19618    match trend {
19619        metric_digest::MetricDigestTrend::Improved => "improved",
19620        metric_digest::MetricDigestTrend::Regressed => "regressed",
19621        metric_digest::MetricDigestTrend::Flat => "flat",
19622        metric_digest::MetricDigestTrend::Unknown => "changed",
19623    }
19624}
19625
19626pub(crate) fn metric_digest_gate_label(
19627    decision: metric_digest::CommunitySearchGateDecision,
19628) -> &'static str {
19629    match decision {
19630        metric_digest::CommunitySearchGateDecision::Pass => "pass",
19631        metric_digest::CommunitySearchGateDecision::Block => "block",
19632    }
19633}
19634
19635fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
19636    let input = fs::read_to_string(fixture_path)
19637        .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
19638    let report = dci_benchmark::compute(&input)?;
19639
19640    if format.json_output {
19641        println!(
19642            "{}",
19643            to_json_schema(
19644                &report,
19645                format.pretty,
19646                format.terse,
19647                format.ultra_terse,
19648                format.schema
19649            )?
19650        );
19651        return Ok(());
19652    }
19653
19654    if format.compact {
19655        println!(
19656            "dci tasks:{} strategies:{} warnings:{}",
19657            report.tasks_loaded,
19658            report.strategies_compared,
19659            report.warnings.len()
19660        );
19661        for summary in &report.strategy_summaries {
19662            println!(
19663                "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
19664                summary.strategy,
19665                summary.rank,
19666                summary.localized,
19667                summary.task_runs,
19668                dci_benchmark::format_number(summary.localization_rate * 100.0),
19669                dci_benchmark::format_number(summary.avg_useful_hits),
19670                dci_benchmark::format_number(summary.zero_output_rate * 100.0),
19671                dci_benchmark::format_number(summary.avg_tool_calls),
19672                dci_benchmark::format_number(summary.avg_latency_ms),
19673                dci_benchmark::format_number(summary.avg_estimated_tokens),
19674                dci_benchmark::format_number(summary.avg_output_tokens)
19675            );
19676        }
19677        if let Some(gate) = &report.memory_retrieval_gate {
19678            println!(
19679                "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
19680                gate.decision,
19681                gate.baseline_strategy,
19682                dci_benchmark::format_number(gate.min_avg_useful_hits),
19683                gate.max_zero_output_failures,
19684                gate.diagnostics.len()
19685            );
19686        }
19687        for warning in &report.warnings {
19688            println!("warning: {warning}");
19689        }
19690        return Ok(());
19691    }
19692
19693    println!("DCI benchmark");
19694    if let Some(description) = &report.description {
19695        println!("  description: {}", description);
19696    }
19697    println!("  tasks loaded:        {}", report.tasks_loaded);
19698    println!("  strategies compared: {}", report.strategies_compared);
19699
19700    println!();
19701    println!("Strategy summary:");
19702    for summary in &report.strategy_summaries {
19703        println!(
19704            "  #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
19705            summary.rank,
19706            summary.strategy,
19707            summary.localized,
19708            summary.task_runs,
19709            summary.localization_rate * 100.0,
19710            dci_benchmark::format_number(summary.avg_useful_hits),
19711            summary.zero_output_rate * 100.0,
19712            dci_benchmark::format_number(summary.avg_tool_calls),
19713            dci_benchmark::format_number(summary.avg_latency_ms),
19714            dci_benchmark::format_number(summary.avg_estimated_tokens),
19715            dci_benchmark::format_number(summary.avg_output_tokens)
19716        );
19717    }
19718
19719    if let Some(gate) = &report.memory_retrieval_gate {
19720        println!();
19721        println!("Memory retrieval gate:");
19722        println!("  decision: {}", gate.decision);
19723        println!(
19724            "  baseline: {}, min avg useful hits {}, max zero-output failures {}",
19725            gate.baseline_strategy,
19726            dci_benchmark::format_number(gate.min_avg_useful_hits),
19727            gate.max_zero_output_failures
19728        );
19729        for row in &gate.rows {
19730            println!(
19731                "  {}: status {}, avg useful hits {}, zero-output failures {}",
19732                row.strategy,
19733                row.status,
19734                dci_benchmark::format_number(row.avg_useful_hits),
19735                row.zero_output_failures
19736            );
19737        }
19738        for diagnostic in &gate.diagnostics {
19739            println!("  diagnostic: {diagnostic}");
19740        }
19741    }
19742
19743    println!();
19744    println!("Task winners:");
19745    for row in &report.task_rows {
19746        let label = row
19747            .label
19748            .as_ref()
19749            .map(|value| format!(" ({value})"))
19750            .unwrap_or_default();
19751        println!("  {}{}", row.task_id, label);
19752        println!("    localized: {}", row.best_localization.join(", "));
19753        println!("    most useful hits: {}", row.most_useful_hits.join(", "));
19754        println!(
19755            "    lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
19756            row.lowest_tool_calls.as_deref().unwrap_or("-"),
19757            row.lowest_latency.as_deref().unwrap_or("-"),
19758            row.lowest_token_budget.as_deref().unwrap_or("-"),
19759            row.lowest_output_tokens.as_deref().unwrap_or("-")
19760        );
19761        if !row.zero_output_failures.is_empty() {
19762            println!("    zero output: {}", row.zero_output_failures.join(", "));
19763        }
19764    }
19765
19766    for warning in &report.warnings {
19767        println!("warning: {warning}");
19768    }
19769    Ok(())
19770}
19771
19772pub(crate) fn format_compact_count(value: u64) -> String {
19773    if value >= 1_000_000 {
19774        format!("{:.1}M", value as f64 / 1_000_000.0)
19775    } else if value >= 1_000 {
19776        format!("{:.1}K", value as f64 / 1_000.0)
19777    } else {
19778        value.to_string()
19779    }
19780}
19781
19782fn cmd_digest_runner(
19783    kind: &str,
19784    path: &Path,
19785    runner: Option<&str>,
19786    shell_command: &str,
19787    format: OutputFormat,
19788) -> Result<()> {
19789    let digest_kind = DigestRunnerKind::parse(kind)?;
19790    let root = transcript_artifact_root(path)?;
19791    let execution = run_digest_runner_command(shell_command)?;
19792    let output = &execution.output;
19793    let captured = String::from_utf8_lossy(&output.stdout).into_owned();
19794    let exit_code = output.status.code().unwrap_or(-1);
19795    if format.json_output && format.envelope {
19796        let artifact_key = format!(
19797            "{}:{}:{}:{}",
19798            digest_kind.as_str(),
19799            shell_command,
19800            execution.executed_command,
19801            captured
19802        );
19803        let artifact = if captured.trim().is_empty() {
19804            None
19805        } else {
19806            let (suffix, expand) = match digest_kind {
19807                DigestRunnerKind::Test => (
19808                    "test.log",
19809                    format!(
19810                        "tsift test-digest --path {} --input {}{} --json",
19811                        shell_quote(root.to_string_lossy().as_ref()),
19812                        shell_quote(
19813                            root.join(".tsift/artifacts")
19814                                .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
19815                                .to_string_lossy()
19816                                .as_ref()
19817                        ),
19818                        runner
19819                            .map(|value| format!(" --runner {}", shell_quote(value)))
19820                            .unwrap_or_default()
19821                    ),
19822                ),
19823                DigestRunnerKind::Log => (
19824                    "log",
19825                    format!(
19826                        "tsift log-digest --path {} --input {} --json",
19827                        shell_quote(root.to_string_lossy().as_ref()),
19828                        shell_quote(
19829                            root.join(".tsift/artifacts")
19830                                .join(format!("{}.log", stable_handle("tart", &artifact_key)))
19831                                .to_string_lossy()
19832                                .as_ref()
19833                        )
19834                    ),
19835                ),
19836            };
19837            Some(persist_transcript_artifact(
19838                &root,
19839                "tart",
19840                suffix,
19841                &artifact_key,
19842                &captured,
19843                expand,
19844            )?)
19845        };
19846        let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
19847
19848        match digest_kind {
19849            DigestRunnerKind::Test => {
19850                let digest_report = test_digest::compute(path, &captured, runner)?;
19851                let report = serde_json::json!({
19852                    "kind": digest_kind.as_str(),
19853                    "command": shell_command,
19854                    "executed_command": execution.executed_command,
19855                    "exit_code": exit_code,
19856                    "success": output.status.success(),
19857                    "filter": filter_report,
19858                    "artifact": artifact,
19859                    "digest": digest_report,
19860                });
19861                let mut follow_up = artifact
19862                    .as_ref()
19863                    .map(|entry| vec![entry.expand.clone()])
19864                    .unwrap_or_default();
19865                follow_up.push(format!(
19866                    "tsift rewrite --run {}",
19867                    shell_quote(shell_command)
19868                ));
19869                let summary_text = if output.status.success() && digest_report.failures == 0 {
19870                    format!("test run passed for {}", runner.unwrap_or("auto"))
19871                } else {
19872                    format!("test run captured {} failure(s)", digest_report.failures)
19873                };
19874                print_json_or_envelope(
19875                    &report,
19876                    &format,
19877                    "digest-runner",
19878                    "test-run",
19879                    ToolEnvelopeSummary {
19880                        text: summary_text,
19881                        metrics: vec![
19882                            envelope_metric("runner", &digest_report.runner),
19883                            envelope_metric("exit_code", exit_code),
19884                            envelope_metric("filter", execution.filter_label()),
19885                            envelope_metric("failures", digest_report.failures),
19886                            envelope_metric("groups", digest_report.grouped_failures),
19887                            envelope_metric(
19888                                "artifact",
19889                                artifact
19890                                    .as_ref()
19891                                    .map(|entry| entry.handle.as_str())
19892                                    .unwrap_or("-"),
19893                            ),
19894                        ],
19895                    },
19896                    false,
19897                    follow_up,
19898                )?;
19899            }
19900            DigestRunnerKind::Log => {
19901                let digest_report = log_digest::compute(path, &captured)?;
19902                let report = serde_json::json!({
19903                    "kind": digest_kind.as_str(),
19904                    "command": shell_command,
19905                    "executed_command": execution.executed_command,
19906                    "exit_code": exit_code,
19907                    "success": output.status.success(),
19908                    "filter": filter_report,
19909                    "artifact": artifact,
19910                    "digest": digest_report,
19911                });
19912                let mut follow_up = artifact
19913                    .as_ref()
19914                    .map(|entry| vec![entry.expand.clone()])
19915                    .unwrap_or_default();
19916                follow_up.push(format!(
19917                    "tsift rewrite --run {}",
19918                    shell_quote(shell_command)
19919                ));
19920                let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
19921                    "command finished without log signals".to_string()
19922                } else {
19923                    format!(
19924                        "command emitted {} log signal group(s)",
19925                        digest_report.signal_groups
19926                    )
19927                };
19928                print_json_or_envelope(
19929                    &report,
19930                    &format,
19931                    "digest-runner",
19932                    "command-run",
19933                    ToolEnvelopeSummary {
19934                        text: summary_text,
19935                        metrics: vec![
19936                            envelope_metric("exit_code", exit_code),
19937                            envelope_metric("filter", execution.filter_label()),
19938                            envelope_metric("signals", digest_report.signal_groups),
19939                            envelope_metric("file_refs", digest_report.file_ref_groups),
19940                            envelope_metric(
19941                                "artifact",
19942                                artifact
19943                                    .as_ref()
19944                                    .map(|entry| entry.handle.as_str())
19945                                    .unwrap_or("-"),
19946                            ),
19947                        ],
19948                    },
19949                    false,
19950                    follow_up,
19951                )?;
19952            }
19953        }
19954
19955        if output.status.success() {
19956            return Ok(());
19957        }
19958        if let Some(code) = output.status.code() {
19959            std::process::exit(code);
19960        }
19961        bail!("digest-wrapped command terminated by signal: {shell_command}");
19962    }
19963
19964    if captured.trim().is_empty() {
19965        let label = match digest_kind {
19966            DigestRunnerKind::Test => "test",
19967            DigestRunnerKind::Log => "log",
19968        };
19969        println!("No {label} output captured.");
19970    } else {
19971        match digest_kind {
19972            DigestRunnerKind::Test => {
19973                render_test_digest_from_input(path, &captured, runner, format)?
19974            }
19975            DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
19976        }
19977    }
19978
19979    if output.status.success() {
19980        return Ok(());
19981    }
19982    if let Some(code) = output.status.code() {
19983        std::process::exit(code);
19984    }
19985    bail!("digest-wrapped command terminated by signal: {shell_command}");
19986}
19987
19988struct DigestRunnerExecution {
19989    output: std::process::Output,
19990    executed_command: String,
19991    filter: Option<DigestRunnerFilter>,
19992}
19993
19994impl DigestRunnerExecution {
19995    fn filter_label(&self) -> &'static str {
19996        self.filter
19997            .as_ref()
19998            .map(|filter| filter.tool)
19999            .unwrap_or("none")
20000    }
20001}
20002
20003struct DigestRunnerFilter {
20004    tool: &'static str,
20005    command: String,
20006}
20007
20008impl DigestRunnerFilter {
20009    fn to_json(&self) -> serde_json::Value {
20010        serde_json::json!({
20011            "tool": self.tool,
20012            "command": self.command,
20013        })
20014    }
20015}
20016
20017fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20018    let filter = rtk_rewrite_for_digest_runner(shell_command);
20019    let executed_command = filter
20020        .as_ref()
20021        .map(|filter| filter.command.as_str())
20022        .unwrap_or(shell_command);
20023    let output = Command::new("sh")
20024        .arg("-lc")
20025        .arg(format!("({executed_command}) 2>&1"))
20026        .stdout(Stdio::piped())
20027        .output()
20028        .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20029
20030    Ok(DigestRunnerExecution {
20031        output,
20032        executed_command: executed_command.to_string(),
20033        filter,
20034    })
20035}
20036
20037fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20038    if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20039        return None;
20040    }
20041    let output = Command::new("rtk")
20042        .arg("rewrite")
20043        .arg(shell_command)
20044        .output()
20045        .ok()?;
20046    if !output.status.success() {
20047        return None;
20048    }
20049    let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20050    if rewritten.is_empty() || rewritten == shell_command {
20051        return None;
20052    }
20053    Some(DigestRunnerFilter {
20054        tool: "rtk",
20055        command: rewritten,
20056    })
20057}
20058
20059fn find_command_on_path(command: &str) -> Option<PathBuf> {
20060    let path_var = std::env::var_os("PATH")?;
20061    std::env::split_paths(&path_var)
20062        .map(|dir| dir.join(command))
20063        .find(|candidate| candidate.is_file())
20064}
20065
20066pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20067    if !db_path.exists() {
20068        bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20069    }
20070    summarize::SummaryDb::open_read_only_resilient(db_path)
20071}
20072
20073fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20074    !matches!(report.index, status::IndexStatus::Fresh { .. })
20075}
20076
20077fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20078    !matches!(report.instructions, init::InstructionStatus::Current { .. })
20079}
20080
20081pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20082    if status_instructions_need_fix(report) {
20083        eprintln!("status fix: refreshing tsift instructions");
20084        init::init(root, false, false)?;
20085    }
20086
20087    let eviction = cycle_packet_cache::cycle_packet_cache_evict(
20088        root,
20089        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
20090        cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
20091    );
20092    if eviction.evicted_entries > 0 {
20093        eprintln!(
20094            "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
20095            eviction.evicted_entries,
20096            eviction.evicted_bytes,
20097            eviction.remaining_entries
20098        );
20099    }
20100
20101    if !status_index_needs_fix(report) {
20102        return Ok(());
20103    }
20104
20105    let scopes = config::Config::submodule_dirs(root)?;
20106    if scopes.is_empty() {
20107        eprintln!("status fix: refreshing index");
20108        run_index_update(
20109            &root.join(".tsift/index.db"),
20110            root,
20111            "status --fix refreshing index".to_string(),
20112            root,
20113            None,
20114            false,
20115            false,
20116        )?;
20117        return Ok(());
20118    }
20119
20120    let cfg = config::Config::load(root)?;
20121    for scope in scopes {
20122        if !scope.source_root.exists() {
20123            eprintln!(
20124                "status fix: skipping missing submodule `{}` ({})",
20125                scope.id,
20126                scope.source_root.display()
20127            );
20128            continue;
20129        }
20130        eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20131        run_index_update(
20132            &cfg.db_path_for(root, &scope.id),
20133            &scope.source_root,
20134            format!("status --fix refreshing submodule `{}` index", scope.id),
20135            root,
20136            Some(scope.id.as_str()),
20137            false,
20138            false,
20139        )?;
20140    }
20141
20142    Ok(())
20143}
20144
20145pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20146    match &report.index {
20147        status::IndexStatus::Fresh { missing_scopes, .. }
20148        | status::IndexStatus::Stale { missing_scopes, .. }
20149        | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20150    }
20151}
20152
20153pub(crate) fn autoindex_missing_workspace_scopes(
20154    root: &Path,
20155    report: &status::StatusReport,
20156) -> Result<()> {
20157    let missing_scopes = match &report.index {
20158        status::IndexStatus::Fresh { missing_scopes, .. }
20159        | status::IndexStatus::Stale { missing_scopes, .. }
20160        | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20161    };
20162    if missing_scopes.is_empty() {
20163        return Ok(());
20164    }
20165
20166    let missing_scope_ids = missing_scopes
20167        .iter()
20168        .map(|scope| scope.scope.as_str())
20169        .collect::<std::collections::HashSet<_>>();
20170    let cfg = config::Config::load(root)?;
20171    for scope in config::Config::submodule_dirs(root)? {
20172        if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20173            continue;
20174        }
20175        let db_path = cfg.db_path_for(root, &scope.id);
20176        run_index_update(
20177            &db_path,
20178            &scope.source_root,
20179            format!(
20180                "autoindexing missing submodule `{}` during status",
20181                scope.id
20182            ),
20183            root,
20184            Some(scope.id.as_str()),
20185            false,
20186            false,
20187        )?;
20188    }
20189    Ok(())
20190}
20191
20192pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
20193    for warning in &stats.warnings {
20194        let rel_path = relativize_pathbuf(&warning.path, root);
20195        eprintln!(
20196            "warning: summarize stats {}: {}",
20197            rel_path.display(),
20198            warning.message
20199        );
20200    }
20201}
20202
20203fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
20204    Result::<(), anyhow::Error>::Err(err)
20205        .context(context)
20206        .unwrap_err()
20207}
20208
20209fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
20210    let message = err.to_string();
20211    message.contains("another tsift index writer is already active")
20212        || substrate::error_mentions_locked_db(err)
20213}
20214
20215fn add_write_lock_context(
20216    err: anyhow::Error,
20217    action: String,
20218    root: &std::path::Path,
20219    scope: Option<&str>,
20220) -> anyhow::Error {
20221    if !should_attach_lock_diagnostics(&err) {
20222        return contextualize_error(err, action);
20223    }
20224
20225    let Ok(report) = status::check_locks(root, None, scope) else {
20226        return contextualize_error(err, action);
20227    };
20228
20229    contextualize_error(
20230        err,
20231        format!(
20232            "{}\n\nlock diagnostics:\n{}",
20233            action,
20234            status::format_locks_human(&report, false).trim_end()
20235        ),
20236    )
20237}
20238
20239pub(crate) fn run_index_update(
20240    db_path: &std::path::Path,
20241    source_root: &std::path::Path,
20242    action: String,
20243    root: &std::path::Path,
20244    scope: Option<&str>,
20245    rebuild: bool,
20246    prune: bool,
20247) -> Result<index::IndexSummary> {
20248    let result = (|| {
20249        let db = index::IndexDb::open(db_path)?;
20250        if rebuild {
20251            db.rebuild(source_root)
20252        } else if prune {
20253            db.apply_changes_pruned(source_root)
20254        } else {
20255            db.apply_changes(source_root)
20256        }
20257    })();
20258
20259    let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
20260    emit_index_warnings(&summary, source_root, scope);
20261    Ok(summary)
20262}
20263
20264pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
20265    for change in &mut summary.changes {
20266        change.path = relativize_pathbuf(&change.path, root);
20267    }
20268    for warning in &mut summary.warnings {
20269        warning.path = relativize_pathbuf(&warning.path, root);
20270    }
20271}
20272
20273fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
20274    for warning in &summary.warnings {
20275        let rel_path = relativize_pathbuf(&warning.path, root);
20276        let stage = match warning.stage {
20277            index::IndexWarningStage::ReadSource => "read failed",
20278            index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
20279            index::IndexWarningStage::ExtractCallSites => "call extraction failed",
20280            index::IndexWarningStage::ExtractRoutes => "route extraction failed",
20281        };
20282        let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
20283        let lang_suffix = warning
20284            .language
20285            .as_deref()
20286            .map(|lang| format!(" [{}]", lang))
20287            .unwrap_or_default();
20288        eprintln!(
20289            "warning: {}{}{}: {}: {}",
20290            scope_prefix,
20291            rel_path.display(),
20292            lang_suffix,
20293            stage,
20294            warning.message
20295        );
20296    }
20297}
20298
20299pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
20300    let config_path = root.join(".tsift/config.toml");
20301    if !config_path.exists() {
20302        return summarize::SummarizeConfig::default();
20303    }
20304    #[derive(serde::Deserialize, Default)]
20305    struct RawConfig {
20306        #[serde(default)]
20307        summarize: Option<RawSummarize>,
20308    }
20309    #[derive(serde::Deserialize)]
20310    struct RawSummarize {
20311        model: Option<String>,
20312        max_file_tokens: Option<usize>,
20313        api_key_env: Option<String>,
20314    }
20315    let content = std::fs::read_to_string(&config_path).unwrap_or_default();
20316    let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
20317    let defaults = summarize::SummarizeConfig::default();
20318    match raw.summarize {
20319        Some(s) => summarize::SummarizeConfig {
20320            model: s.model.unwrap_or(defaults.model),
20321            max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
20322            api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
20323        },
20324        None => defaults,
20325    }
20326}
20327
20328#[derive(Debug, Clone, PartialEq, Eq)]
20329struct ExtractSymbolContext {
20330    db_path: PathBuf,
20331    source_root: PathBuf,
20332}
20333
20334pub(crate) fn find_symbols_db_for_file(
20335    root: &Path,
20336    file_path: &Path,
20337) -> Result<Option<ExtractSymbolContext>> {
20338    let cfg = config::Config::load(root)?;
20339    let mut submodules = config::Config::submodule_dirs(root)?;
20340    submodules.sort_by(|left, right| {
20341        right
20342            .source_root
20343            .components()
20344            .count()
20345            .cmp(&left.source_root.components().count())
20346    });
20347
20348    for scope in submodules {
20349        if !file_path.starts_with(&scope.source_root) {
20350            continue;
20351        }
20352        let db_path = cfg.db_path_for(root, &scope.id);
20353        if db_path.exists() {
20354            return Ok(Some(ExtractSymbolContext {
20355                db_path,
20356                source_root: scope.source_root,
20357            }));
20358        }
20359    }
20360
20361    let single = root.join(".tsift/index.db");
20362    if single.exists() && file_path.starts_with(root) {
20363        return Ok(Some(ExtractSymbolContext {
20364            db_path: single,
20365            source_root: root.to_path_buf(),
20366        }));
20367    }
20368
20369    Ok(None)
20370}
20371
20372pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
20373    let canonical = path
20374        .canonicalize()
20375        .with_context(|| format!("canonicalizing {}", path.display()))?;
20376
20377    Ok(if canonical.is_dir() {
20378        canonical
20379    } else {
20380        canonical
20381            .parent()
20382            .map(Path::to_path_buf)
20383            .unwrap_or(canonical)
20384    })
20385}
20386
20387fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
20388    if path.exists() {
20389        return path
20390            .canonicalize()
20391            .with_context(|| format!("canonicalizing extract scope {}", path.display()));
20392    }
20393
20394    Ok(summarize::normalize_lexical_path(path))
20395}
20396
20397pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
20398    let scope = if extract_path.is_absolute() {
20399        extract_path.to_path_buf()
20400    } else {
20401        root.join(extract_path)
20402    };
20403    normalize_extract_scope_path(&scope)
20404}
20405
20406pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
20407    normalize_extract_scope_path(changed_path)
20408        .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
20409        .starts_with(extract_scope)
20410}
20411
20412pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
20413    summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
20414}
20415
20416pub(crate) fn summarize_full_extract_deleted_summary_paths(
20417    summary_db: &summarize::SummaryDb,
20418    root: &Path,
20419    extract_scope: &Path,
20420    files_to_extract: &[PathBuf],
20421) -> Result<BTreeSet<String>> {
20422    let live_paths = files_to_extract
20423        .iter()
20424        .map(|file_path| summarize_relative_file_path(root, file_path))
20425        .collect::<BTreeSet<_>>();
20426    let mut deleted = BTreeSet::new();
20427
20428    for cached_path in summary_db.cached_file_paths()? {
20429        if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
20430            continue;
20431        }
20432        if !live_paths.contains(&cached_path) {
20433            deleted.insert(cached_path);
20434        }
20435    }
20436
20437    Ok(deleted)
20438}
20439
20440#[derive(Debug, Clone)]
20441struct SearchIndexTarget {
20442    label: String,
20443    db_path: PathBuf,
20444    source_root: PathBuf,
20445    scope_name: Option<String>,
20446    reindex_cmd: String,
20447}
20448
20449fn cargo_package_index_target(
20450    root: &Path,
20451    package: multiplicity::CargoPackageInfo,
20452) -> SearchIndexTarget {
20453    SearchIndexTarget {
20454        label: format!("cargo package `{}` index", package.scope_id),
20455        db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
20456        source_root: package.package_root.clone(),
20457        scope_name: Some(package.scope_id.clone()),
20458        reindex_cmd: format!(
20459            "tsift index --submodule {} {}",
20460            package.scope_id,
20461            root.display()
20462        ),
20463    }
20464}
20465
20466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20467enum SearchIndexState {
20468    Missing,
20469    Fresh,
20470    Stale { stale_files: usize },
20471}
20472
20473fn resolve_search_index_targets(
20474    root: &Path,
20475    path_hint: &Path,
20476    scope: Option<&str>,
20477    federated: bool,
20478) -> Result<Vec<SearchIndexTarget>> {
20479    if let Some(scope_name) = scope {
20480        if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
20481            let cfg = config::Config::load(root)?;
20482            return Ok(vec![SearchIndexTarget {
20483                label: format!("submodule `{}` index", scope.id),
20484                db_path: cfg.db_path_for(root, &scope.id),
20485                source_root: scope.source_root.clone(),
20486                scope_name: Some(scope.id.clone()),
20487                reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20488            }]);
20489        }
20490        if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
20491            return Ok(vec![cargo_package_index_target(root, package)]);
20492        }
20493        config::Config::resolve_submodule(root, scope_name)?;
20494    }
20495
20496    if federated {
20497        let cfg = config::Config::load(root)?;
20498        let mut targets = Vec::new();
20499        for scope in config::Config::submodule_dirs(root)? {
20500            if !cfg.federation_for_scope(&scope) {
20501                continue;
20502            }
20503            targets.push(SearchIndexTarget {
20504                label: format!("submodule `{}` index", scope.id),
20505                db_path: cfg.db_path_for(root, &scope.id),
20506                source_root: scope.source_root.clone(),
20507                scope_name: Some(scope.id.clone()),
20508                reindex_cmd: format!("tsift index --workspace {}", root.display()),
20509            });
20510        }
20511        return Ok(targets);
20512    }
20513
20514    if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
20515        let cfg = config::Config::load(root)?;
20516        return Ok(vec![SearchIndexTarget {
20517            label: format!("submodule `{}` index", scope.id),
20518            db_path: cfg.db_path_for(root, &scope.id),
20519            source_root: scope.source_root.clone(),
20520            scope_name: Some(scope.id.clone()),
20521            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20522        }]);
20523    }
20524
20525    if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
20526        return Ok(vec![cargo_package_index_target(root, package)]);
20527    }
20528
20529    if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
20530        let cfg = config::Config::load(root)?;
20531        return Ok(vec![SearchIndexTarget {
20532            label: format!("submodule `{}` index", scope.id),
20533            db_path: cfg.db_path_for(root, &scope.id),
20534            source_root: scope.source_root.clone(),
20535            scope_name: Some(scope.id.clone()),
20536            reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
20537        }]);
20538    }
20539
20540    let scopes = config::Config::submodule_dirs(root)?;
20541    if !scopes.is_empty() {
20542        let root_db = root.join(".tsift/index.db");
20543        if !root_db.exists() {
20544            let available_scopes = scopes
20545                .iter()
20546                .map(|scope| scope.id.as_str())
20547                .collect::<Vec<_>>()
20548                .join(", ");
20549            let cfg = config::Config::load(root)?;
20550            let indexed_scopes = scopes
20551                .iter()
20552                .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
20553                .map(|scope| scope.id.as_str())
20554                .collect::<Vec<_>>();
20555            let indexed_label = if indexed_scopes.is_empty() {
20556                "none".to_string()
20557            } else {
20558                indexed_scopes.join(", ")
20559            };
20560            bail!(
20561                "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: {}.",
20562                root.display(),
20563                root_db.display(),
20564                available_scopes,
20565                indexed_label,
20566            );
20567        }
20568    }
20569
20570    Ok(vec![SearchIndexTarget {
20571        label: "index".to_string(),
20572        db_path: root.join(".tsift/index.db"),
20573        source_root: root.to_path_buf(),
20574        scope_name: None,
20575        reindex_cmd: format!("tsift index {}", root.display()),
20576    }])
20577}
20578
20579fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
20580    if !target.source_root.exists() || !target.db_path.exists() {
20581        return Ok(SearchIndexState::Missing);
20582    }
20583
20584    let inspection =
20585        index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
20586    let stale_files =
20587        inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
20588    if stale_files == 0 {
20589        Ok(SearchIndexState::Fresh)
20590    } else {
20591        Ok(SearchIndexState::Stale { stale_files })
20592    }
20593}
20594
20595#[derive(Debug, Clone, PartialEq, Eq)]
20596struct RebuildSearchTarget {
20597    label: String,
20598    reason: RebuildSearchReason,
20599    reindex_cmd: String,
20600}
20601
20602#[derive(Debug, Clone, PartialEq, Eq)]
20603enum RebuildSearchReason {
20604    Missing,
20605    Stale { stale_files: usize },
20606}
20607
20608#[derive(Debug, Clone, PartialEq, Eq)]
20609struct DegradedSearchTarget {
20610    label: String,
20611    reason: RebuildSearchReason,
20612    reindex_cmd: String,
20613}
20614
20615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20616pub(crate) enum DegradedSearchMode {
20617    ReadOnly,
20618    Exact,
20619}
20620
20621#[derive(Debug)]
20622struct SearchPrecheck {
20623    targets: Vec<SearchIndexTarget>,
20624    degraded_targets: Vec<DegradedSearchTarget>,
20625}
20626
20627fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
20628    err.chain().any(|cause| {
20629        cause
20630            .to_string()
20631            .contains("another tsift index writer is already active")
20632    })
20633}
20634
20635fn infer_agent_doc_task_submodule(
20636    root: &Path,
20637    path_hint: &Path,
20638) -> Result<Option<config::WorkspaceScope>> {
20639    let hinted_path = if path_hint.is_absolute() {
20640        path_hint.to_path_buf()
20641    } else {
20642        root.join(path_hint)
20643    };
20644    let Ok(relative) = hinted_path.strip_prefix(root) else {
20645        return Ok(None);
20646    };
20647    let mut components = relative.components();
20648    let Some(std::path::Component::Normal(first)) = components.next() else {
20649        return Ok(None);
20650    };
20651    if first != "tasks" {
20652        return Ok(None);
20653    }
20654    let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
20655        return Ok(None);
20656    };
20657    config::Config::find_submodule(root, file_stem)
20658}
20659
20660fn degraded_search_target(
20661    target: &SearchIndexTarget,
20662    reason: RebuildSearchReason,
20663) -> DegradedSearchTarget {
20664    DegradedSearchTarget {
20665        label: target.label.clone(),
20666        reason,
20667        reindex_cmd: target.reindex_cmd.clone(),
20668    }
20669}
20670
20671fn apply_search_index_update(
20672    root: &Path,
20673    target: &SearchIndexTarget,
20674) -> Result<index::IndexSummary> {
20675    run_index_update(
20676        &target.db_path,
20677        &target.source_root,
20678        format!("autoindexing {}", target.label),
20679        root,
20680        target.scope_name.as_deref(),
20681        false,
20682        false,
20683    )
20684}
20685
20686fn collect_rebuild_search_targets(
20687    targets: &[SearchIndexTarget],
20688) -> Result<Vec<RebuildSearchTarget>> {
20689    let mut rebuild_targets = Vec::new();
20690    for target in targets {
20691        let reason = match inspect_search_index(target)? {
20692            SearchIndexState::Missing => RebuildSearchReason::Missing,
20693            SearchIndexState::Fresh => continue,
20694            SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
20695        };
20696        rebuild_targets.push(RebuildSearchTarget {
20697            label: target.label.clone(),
20698            reason,
20699            reindex_cmd: target.reindex_cmd.clone(),
20700        });
20701    }
20702    Ok(rebuild_targets)
20703}
20704
20705fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
20706    match target.reason {
20707        RebuildSearchReason::Missing => format!("{} is missing", target.label),
20708        RebuildSearchReason::Stale { stale_files } => {
20709            let file_suffix = if stale_files == 1 { "" } else { "s" };
20710            format!(
20711                "{} is stale ({} file{})",
20712                target.label, stale_files, file_suffix
20713            )
20714        }
20715    }
20716}
20717
20718fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
20719    if rebuild_targets.len() == 1 {
20720        let target = &rebuild_targets[0];
20721        return format!(
20722            "{}. Run `{}` to rebuild before retrying.",
20723            rebuild_search_target_detail(target),
20724            target.reindex_cmd
20725        );
20726    }
20727
20728    let summary: Vec<String> = rebuild_targets
20729        .iter()
20730        .take(3)
20731        .map(rebuild_search_target_detail)
20732        .collect();
20733    let overflow = rebuild_targets.len().saturating_sub(summary.len());
20734    let mut details = summary.join(", ");
20735    if overflow > 0 {
20736        details.push_str(&format!(", +{} more", overflow));
20737    }
20738    let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
20739    format!(
20740        "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
20741        rebuild_targets.len(),
20742        details,
20743        reindex_cmd
20744    )
20745}
20746
20747pub(crate) fn precheck_search_indexes(
20748    root: &Path,
20749    path_hint: &Path,
20750    scope: Option<&str>,
20751    federated: bool,
20752    autoindex: bool,
20753) -> Result<SearchPrecheck> {
20754    let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
20755    let mut stale_targets = Vec::new();
20756    let mut degraded_targets = Vec::new();
20757
20758    for target in &targets {
20759        match inspect_search_index(target)? {
20760            SearchIndexState::Missing => {
20761                if autoindex && let Err(err) = apply_search_index_update(root, target) {
20762                    if is_active_writer_lock_error(&err) {
20763                        degraded_targets
20764                            .push(degraded_search_target(target, RebuildSearchReason::Missing));
20765                    } else {
20766                        return Err(err);
20767                    }
20768                }
20769            }
20770            SearchIndexState::Fresh => {}
20771            SearchIndexState::Stale { stale_files } => {
20772                if autoindex {
20773                    if let Err(err) = apply_search_index_update(root, target) {
20774                        if is_active_writer_lock_error(&err) {
20775                            degraded_targets.push(degraded_search_target(
20776                                target,
20777                                RebuildSearchReason::Stale { stale_files },
20778                            ));
20779                        } else {
20780                            return Err(err);
20781                        }
20782                    }
20783                } else {
20784                    stale_targets.push(RebuildSearchTarget {
20785                        label: target.label.clone(),
20786                        reason: RebuildSearchReason::Stale { stale_files },
20787                        reindex_cmd: target.reindex_cmd.clone(),
20788                    });
20789                }
20790            }
20791        }
20792    }
20793
20794    if stale_targets.is_empty() {
20795        return Ok(SearchPrecheck {
20796            targets,
20797            degraded_targets,
20798        });
20799    }
20800
20801    bail!(
20802        "tsift search aborted: {} \
20803         or re-run without `--no-autoindex`.",
20804        rebuild_search_targets_message(&stale_targets),
20805    );
20806}
20807
20808pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
20809    if targets.is_empty() {
20810        return None;
20811    }
20812
20813    if targets
20814        .iter()
20815        .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
20816    {
20817        Some(DegradedSearchMode::Exact)
20818    } else {
20819        Some(DegradedSearchMode::ReadOnly)
20820    }
20821}
20822
20823fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
20824    if targets.len() == 1 {
20825        let target = &targets[0];
20826        return match target.reason {
20827            RebuildSearchReason::Missing => format!("{} is missing", target.label),
20828            RebuildSearchReason::Stale { stale_files } => {
20829                let file_suffix = if stale_files == 1 { "" } else { "s" };
20830                format!(
20831                    "{} is stale ({} file{})",
20832                    target.label, stale_files, file_suffix
20833                )
20834            }
20835        };
20836    }
20837
20838    let missing = targets
20839        .iter()
20840        .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
20841        .count();
20842    let stale = targets.len().saturating_sub(missing);
20843    let mut parts = Vec::new();
20844    if stale > 0 {
20845        let suffix = if stale == 1 { "" } else { "es" };
20846        parts.push(format!("{stale} stale index{suffix}"));
20847    }
20848    if missing > 0 {
20849        let suffix = if missing == 1 { "" } else { "es" };
20850        parts.push(format!("{missing} missing index{suffix}"));
20851    }
20852    parts.join(", ")
20853}
20854
20855pub(crate) fn emit_degraded_search_note(
20856    targets: &[DegradedSearchTarget],
20857    mode: DegradedSearchMode,
20858) {
20859    let summary = degraded_search_targets_summary(targets);
20860    let reindex_cmd = &targets[0].reindex_cmd;
20861    match mode {
20862        DegradedSearchMode::ReadOnly => eprintln!(
20863            "note: active tsift writer detected; skipping autoindex because {}. \
20864             Continuing with read-only search and the current index snapshot; symbol hits may lag. \
20865             Retry `{}` after the active writer finishes for fresh index results.",
20866            summary, reindex_cmd
20867        ),
20868        DegradedSearchMode::Exact => eprintln!(
20869            "note: active tsift writer detected; skipping autoindex because {}. \
20870             Continuing with exact live-file search. Retry `{}` after the active writer finishes \
20871             for indexed symbol hits.",
20872            summary, reindex_cmd
20873        ),
20874    }
20875}
20876
20877fn search_timeout_message(
20878    timeout_secs: u64,
20879    strategy: &str,
20880    targets: &[SearchIndexTarget],
20881) -> Result<String> {
20882    let rebuild_targets = collect_rebuild_search_targets(targets)?;
20883    if rebuild_targets.is_empty() {
20884        return Ok(format!(
20885            "tsift search timed out after {}s (strategy: {}). \
20886             The search root looks fresh, so reindexing is unlikely to help. \
20887             Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
20888             or try a different strategy.",
20889            timeout_secs, strategy,
20890        ));
20891    }
20892
20893    Ok(format!(
20894        "tsift search timed out after {}s (strategy: {}). {}",
20895        timeout_secs,
20896        strategy,
20897        rebuild_search_targets_message(&rebuild_targets),
20898    ))
20899}
20900
20901fn is_exact_preferring_query_char(ch: char) -> bool {
20902    matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
20903}
20904
20905fn query_prefers_exact_search(query: &str) -> bool {
20906    let trimmed = query.trim();
20907    !trimmed.is_empty()
20908        && !trimmed.chars().any(char::is_whitespace)
20909        && trimmed.chars().any(|ch| ch.is_alphanumeric())
20910        && trimmed.chars().any(is_exact_preferring_query_char)
20911        && trimmed
20912            .chars()
20913            .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
20914}
20915
20916pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
20917    strategy.unwrap_or_else(|| {
20918        if query_prefers_exact_search(query) {
20919            "exact".to_string()
20920        } else {
20921            "lexical".to_string()
20922        }
20923    })
20924}
20925
20926
20927pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
20928    let mut files = Vec::new();
20929    if path.is_file() {
20930        files.push(path.to_path_buf());
20931        return Ok(files);
20932    }
20933    let walker = ignore::WalkBuilder::new(path)
20934        .hidden(true)
20935        .git_ignore(true)
20936        .build();
20937    for entry in walker {
20938        let entry = entry?;
20939        if entry.file_type().is_some_and(|ft| ft.is_file()) {
20940            let p = entry.path();
20941            if let Some(ext) = p.extension() {
20942                let ext = ext.to_string_lossy();
20943                if matches!(
20944                    ext.as_ref(),
20945                    "rs" | "py"
20946                        | "ts"
20947                        | "tsx"
20948                        | "js"
20949                        | "jsx"
20950                        | "kt"
20951                        | "kts"
20952                        | "zig"
20953                        | "sh"
20954                        | "bash"
20955                        | "zsh"
20956                ) {
20957                    files.push(p.to_path_buf());
20958                }
20959            }
20960        }
20961    }
20962    Ok(files)
20963}
20964
20965#[cfg(test)]
20966 mod tests {
20967     use super::*;
20968     use super::semantic_edit::{
20969         EditOp,
20970         apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
20971         markdown_section_spans,
20972     };
20973     use tsift_memory::{MemoryEventKind, MemoryStore};
20974
20975    use std::cell::RefCell;
20976    use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
20977    fn parse_cli<I, T>(itr: I) -> Cli
20978    where
20979        I: IntoIterator<Item = T> + Send + 'static,
20980        T: Into<std::ffi::OsString> + Clone + Send + 'static,
20981    {
20982        std::thread::Builder::new()
20983            .name("cli-parse".to_string())
20984            .stack_size(16 * 1024 * 1024)
20985            .spawn(move || Cli::parse_from(itr))
20986            .unwrap()
20987            .join()
20988            .unwrap()
20989    }
20990
20991    fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
20992    where
20993        I: IntoIterator<Item = T> + Send + 'static,
20994        T: Into<std::ffi::OsString> + Clone + Send + 'static,
20995    {
20996        std::thread::Builder::new()
20997            .name("cli-try-parse".to_string())
20998            .stack_size(16 * 1024 * 1024)
20999            .spawn(move || Cli::try_parse_from(itr))
21000            .unwrap()
21001            .join()
21002            .unwrap()
21003    }
21004
21005    fn build_relative_search_budget_report(
21006        query: &str,
21007        strategy: &str,
21008        root: &Path,
21009        response: &sift::SearchResponse,
21010        symbol_hits: &[index::SymbolHit],
21011        budget: ResponseBudget,
21012        filters: &SearchFacetFilters,
21013    ) -> SearchBudgetReport {
21014        build_search_budget_report(SearchBudgetReportInput {
21015            query,
21016            strategy,
21017            root,
21018            response,
21019            symbol_hits,
21020            absolute: false,
21021            budget,
21022            filters,
21023        })
21024    }
21025
21026    #[derive(Default)]
21027    struct MemoryConvexGraphClient {
21028        nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
21029        edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
21030    }
21031
21032    impl ConvexGraphClient for MemoryConvexGraphClient {
21033        fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
21034            self.nodes
21035                .borrow_mut()
21036                .insert(row.external_id.clone(), row.clone());
21037            Ok(())
21038        }
21039
21040        fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
21041            self.edges
21042                .borrow_mut()
21043                .insert(row.edge_key.clone(), row.clone());
21044            Ok(())
21045        }
21046
21047        fn delete_node_row(&self, external_id: &str) -> Result<usize> {
21048            Ok(usize::from(
21049                self.nodes.borrow_mut().remove(external_id).is_some(),
21050            ))
21051        }
21052
21053        fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
21054            Ok(usize::from(
21055                self.edges.borrow_mut().remove(edge_key).is_some(),
21056            ))
21057        }
21058
21059        fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
21060            Ok(self.nodes.borrow().get(external_id).cloned())
21061        }
21062
21063        fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
21064            Ok(self.nodes.borrow().values().cloned().collect())
21065        }
21066
21067        fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
21068            Ok(self.edges.borrow().values().cloned().collect())
21069        }
21070
21071        fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
21072            Ok(self
21073                .nodes
21074                .borrow()
21075                .values()
21076                .filter(|row| row.kind == kind)
21077                .cloned()
21078                .collect())
21079        }
21080
21081        fn outgoing_edge_rows(
21082            &self,
21083            from_external_id: &str,
21084            kind: Option<&str>,
21085        ) -> Result<Vec<ConvexEdgeRow>> {
21086            Ok(self
21087                .edges
21088                .borrow()
21089                .values()
21090                .filter(|row| row.from_external_id == from_external_id)
21091                .filter(|row| kind.is_none_or(|kind| row.kind == kind))
21092                .cloned()
21093                .collect())
21094        }
21095    }
21096
21097    fn init_git_repo(path: &Path) {
21098        let status = std::process::Command::new("git")
21099            .args(["init"])
21100            .current_dir(path)
21101            .status()
21102            .unwrap();
21103        assert!(status.success(), "git init failed");
21104
21105        let status = std::process::Command::new("git")
21106            .args(["add", "."])
21107            .current_dir(path)
21108            .status()
21109            .unwrap();
21110        assert!(status.success(), "git add failed");
21111
21112        let status = std::process::Command::new("git")
21113            .args([
21114                "-c",
21115                "user.name=tsift-tests",
21116                "-c",
21117                "user.email=tsift-tests@example.com",
21118                "commit",
21119                "--quiet",
21120                "-m",
21121                "init",
21122            ])
21123            .current_dir(path)
21124            .status()
21125            .unwrap();
21126        assert!(status.success(), "git commit failed");
21127    }
21128
21129    fn write_empty_root_index(root: &Path) {
21130        let index_dir = root.join(".tsift");
21131        fs::create_dir_all(&index_dir).unwrap();
21132        fs::write(index_dir.join("index.db"), "").unwrap();
21133    }
21134
21135    fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
21136        if let Some(parent) = path.parent() {
21137            fs::create_dir_all(parent).unwrap();
21138        }
21139        let body = std::iter::repeat_n(line, lines)
21140            .collect::<Vec<_>>()
21141            .join("\n");
21142        fs::write(path, format!("{body}\n")).unwrap();
21143        path.to_path_buf()
21144    }
21145
21146    // --- build_token_capped_preview ---
21147
21148    #[test]
21149    fn token_capped_preview_returns_all_lines_when_under_cap() {
21150        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 1", "}"];
21151        let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
21152        assert!(!result.was_capped);
21153        assert_eq!(result.preview.len(), 3);
21154        assert_eq!(result.capped_end, 3);
21155    }
21156
21157    #[test]
21158    fn token_capped_preview_truncates_when_over_cap() {
21159        let lines: Vec<&str> = (0..200).map(|_| "    let x = some_very_long_expression_here();").collect();
21160        let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
21161        assert!(result.was_capped);
21162        assert!(result.preview.len() < 200);
21163        assert!(result.capped_end < 200);
21164    }
21165
21166    #[test]
21167    fn token_capped_preview_keeps_at_least_one_line() {
21168        let long_line: String = "x".repeat(8000);
21169        let lines: Vec<&str> = vec![&long_line];
21170        let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
21171        assert!(!result.was_capped);
21172        assert_eq!(result.preview.len(), 1);
21173    }
21174
21175    #[test]
21176    fn token_capped_preview_cap_at_boundary() {
21177        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21178        let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
21179        assert!(!result.was_capped);
21180        assert_eq!(result.preview.len(), 4);
21181    }
21182
21183    #[test]
21184    fn token_capped_preview_cap_just_over_boundary() {
21185        let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
21186        let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
21187        assert!(result.was_capped);
21188        assert_eq!(result.preview.len(), 3);
21189        assert_eq!(result.capped_end, 3);
21190    }
21191
21192    #[test]
21193    fn token_capped_preview_empty_lines() {
21194        let lines: Vec<&str> = vec![];
21195        let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
21196        assert!(!result.was_capped);
21197        assert!(result.preview.is_empty());
21198    }
21199
21200    #[test]
21201    fn token_capped_preview_per_line_truncation_applied() {
21202        let long_line = "x".repeat(500);
21203        let lines: Vec<&str> = vec![&long_line, "short"];
21204        let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
21205        assert!(!result.was_capped);
21206        assert_eq!(result.preview.len(), 2);
21207        assert!(result.preview[0].text.len() <= 23);
21208        assert!(result.preview[0].text.ends_with("..."));
21209    }
21210
21211    // --- classify_task ---
21212
21213    #[test]
21214    fn route_search_defaults_to_haiku() {
21215        let (tier, model) = classify_task("find all uses of authenticate");
21216        assert_eq!(tier, "haiku");
21217        assert!(
21218            model.contains("haiku"),
21219            "expected haiku model, got {}",
21220            model
21221        );
21222    }
21223
21224    #[test]
21225    fn route_edit_keywords_to_sonnet() {
21226        for kw in &[
21227            "edit the file",
21228            "fix the bug",
21229            "update the config",
21230            "remove dead code",
21231            "create a new module",
21232        ] {
21233            let (tier, _) = classify_task(kw);
21234            assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
21235        }
21236    }
21237
21238    #[test]
21239    fn route_architecture_keywords_to_opus() {
21240        for kw in &[
21241            "design the API",
21242            "architecture review",
21243            "plan the migration",
21244            "analyze the system",
21245            "evaluate trade-offs",
21246        ] {
21247            let (tier, _) = classify_task(kw);
21248            assert_eq!(tier, "opus", "expected opus for {:?}", kw);
21249        }
21250    }
21251
21252    #[test]
21253    fn route_architecture_beats_edit() {
21254        // "design and implement" — architecture signal wins (checked first)
21255        let (tier, _) = classify_task("design and implement the new auth service");
21256        assert_eq!(tier, "opus");
21257    }
21258
21259    #[test]
21260    fn cli_accepts_global_compact_flag() {
21261        let cli = parse_cli(["tsift", "--compact", "status"]);
21262        assert!(cli.compact);
21263        assert!(matches!(cli.command, Some(Commands::Status { .. })));
21264    }
21265
21266    #[test]
21267    fn summarize_diff_scope_matches_relative_directory() {
21268        let root = Path::new("/repo");
21269        let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
21270
21271        assert!(summarize_diff_matches_scope(
21272            Path::new("/repo/src/feature/main.rs"),
21273            &extract_scope
21274        ));
21275        assert!(!summarize_diff_matches_scope(
21276            Path::new("/repo/src/other/main.rs"),
21277            &extract_scope
21278        ));
21279    }
21280
21281    #[test]
21282    fn summarize_diff_scope_matches_relative_file() {
21283        let root = Path::new("/repo");
21284        let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
21285
21286        assert!(summarize_diff_matches_scope(
21287            Path::new("/repo/src/feature/main.rs"),
21288            &extract_scope
21289        ));
21290        assert!(!summarize_diff_matches_scope(
21291            Path::new("/repo/src/feature/lib.rs"),
21292            &extract_scope
21293        ));
21294    }
21295
21296    #[test]
21297    fn summarize_extract_scope_walks_relative_paths_from_root() {
21298        let dir = tempfile::tempdir().unwrap();
21299        let source_dir = dir.path().join("src");
21300        std::fs::create_dir_all(&source_dir).unwrap();
21301        let main_rs = source_dir.join("main.rs");
21302        std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
21303
21304        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
21305        let files = collect_source_files(&extract_scope).unwrap();
21306
21307        assert_eq!(files, vec![main_rs]);
21308    }
21309
21310    #[test]
21311    fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
21312        let dir = tempfile::tempdir().unwrap();
21313        let nested = dir.path().join("src/nested");
21314        std::fs::create_dir_all(&nested).unwrap();
21315        std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
21316        let nested_file = nested.join("main.rs");
21317        std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
21318
21319        let extract_base = resolve_extract_base(&nested).unwrap();
21320        let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
21321        let files = collect_source_files(&extract_scope).unwrap();
21322
21323        assert_eq!(extract_scope, nested);
21324        assert_eq!(files, vec![nested_file]);
21325    }
21326
21327    #[test]
21328    fn summarize_extract_base_uses_parent_of_file_path() {
21329        let dir = tempfile::tempdir().unwrap();
21330        let nested = dir.path().join("src/nested");
21331        std::fs::create_dir_all(&nested).unwrap();
21332        let file_path = nested.join("main.rs");
21333        std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
21334
21335        let extract_base = resolve_extract_base(&file_path).unwrap();
21336
21337        assert_eq!(extract_base, nested);
21338    }
21339
21340    #[test]
21341    fn summarize_extract_scope_normalizes_dotdot_segments() {
21342        let dir = tempfile::tempdir().unwrap();
21343        let source_dir = dir.path().join("src");
21344        std::fs::create_dir_all(&source_dir).unwrap();
21345
21346        let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
21347
21348        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21349        assert!(summarize_diff_matches_scope(
21350            &source_dir.join("main.rs"),
21351            &extract_scope
21352        ));
21353    }
21354
21355    #[cfg(unix)]
21356    #[test]
21357    fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
21358        use std::os::unix::fs::symlink;
21359
21360        let dir = tempfile::tempdir().unwrap();
21361        let real_root = dir.path().join("real");
21362        let source_dir = real_root.join("src");
21363        std::fs::create_dir_all(&source_dir).unwrap();
21364        let symlink_scope = dir.path().join("scope-link");
21365        symlink(&source_dir, &symlink_scope).unwrap();
21366
21367        let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
21368
21369        assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
21370        assert!(summarize_diff_matches_scope(
21371            &source_dir.join("lib.rs"),
21372            &extract_scope
21373        ));
21374    }
21375
21376    #[test]
21377    fn summarize_diff_extract_includes_untracked_files() {
21378        let dir = tempfile::tempdir().unwrap();
21379        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21380        init_git_repo(dir.path());
21381
21382        let source_dir = dir.path().join("src");
21383        std::fs::create_dir_all(&source_dir).unwrap();
21384        let new_file = source_dir.join("new.rs");
21385        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21386
21387        let files = summarize::git_changed_files(dir.path()).unwrap();
21388
21389        assert_eq!(files.existing, vec![new_file]);
21390        assert!(files.deleted.is_empty());
21391    }
21392
21393    #[test]
21394    fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
21395        let dir = tempfile::tempdir().unwrap();
21396        let status = std::process::Command::new("git")
21397            .args(["init"])
21398            .current_dir(dir.path())
21399            .status()
21400            .unwrap();
21401        assert!(status.success(), "git init failed");
21402
21403        let source_dir = dir.path().join("src");
21404        std::fs::create_dir_all(&source_dir).unwrap();
21405        let new_file = source_dir.join("new.rs");
21406        std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
21407
21408        let files = summarize::git_changed_files(dir.path()).unwrap();
21409
21410        assert_eq!(files.existing, vec![new_file]);
21411        assert!(files.deleted.is_empty());
21412    }
21413
21414    #[test]
21415    fn summarize_diff_extract_tracks_deleted_files() {
21416        let dir = tempfile::tempdir().unwrap();
21417        let source_dir = dir.path().join("src");
21418        std::fs::create_dir_all(&source_dir).unwrap();
21419        let deleted_file = source_dir.join("gone.rs");
21420        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21421        init_git_repo(dir.path());
21422
21423        std::fs::remove_file(&deleted_file).unwrap();
21424
21425        let files = summarize::git_changed_files(dir.path()).unwrap();
21426
21427        assert!(files.existing.is_empty());
21428        assert_eq!(files.deleted, vec![deleted_file]);
21429    }
21430
21431    #[test]
21432    fn summarize_diff_extract_tracks_git_renames() {
21433        let dir = tempfile::tempdir().unwrap();
21434        let source_dir = dir.path().join("src");
21435        std::fs::create_dir_all(&source_dir).unwrap();
21436        let old_file = source_dir.join("old.rs");
21437        let new_file = source_dir.join("new.rs");
21438        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21439        init_git_repo(dir.path());
21440
21441        let status = std::process::Command::new("git")
21442            .args(["mv", "src/old.rs", "src/new.rs"])
21443            .current_dir(dir.path())
21444            .status()
21445            .unwrap();
21446        assert!(status.success(), "git mv failed");
21447
21448        let files = summarize::git_changed_files(dir.path()).unwrap();
21449
21450        assert_eq!(files.existing, vec![new_file]);
21451        assert_eq!(files.deleted, vec![old_file]);
21452    }
21453
21454    #[test]
21455    fn summarize_diff_extract_deletes_removed_summary_rows() {
21456        let dir = tempfile::tempdir().unwrap();
21457        let source_dir = dir.path().join("src");
21458        std::fs::create_dir_all(&source_dir).unwrap();
21459        let deleted_file = source_dir.join("gone.rs");
21460        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21461        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21462        init_git_repo(dir.path());
21463
21464        let summary_db =
21465            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21466        summary_db
21467            .insert(&summarize::Summary {
21468                id: 0,
21469                symbol_name: "stale".to_string(),
21470                file_path: "src/gone.rs".to_string(),
21471                content_hash: "hash1".to_string(),
21472                summary: "stale summary".to_string(),
21473                entities: None,
21474                relationships: None,
21475                concept_labels: None,
21476                extracted_at: "1700000000".to_string(),
21477                model: "test".to_string(),
21478                tokens_input: Some(100),
21479                tokens_output: Some(50),
21480            })
21481            .unwrap();
21482
21483        std::fs::remove_file(&deleted_file).unwrap();
21484
21485        cmd_summarize(
21486            None,
21487            None,
21488            Some(PathBuf::from("src")),
21489            true,
21490            false,
21491            dir.path(),
21492            false,
21493            true,
21494            false,
21495            false,
21496            false,
21497        )
21498        .unwrap();
21499
21500        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21501    }
21502
21503    #[test]
21504    fn summarize_diff_extract_deletes_renamed_summary_rows() {
21505        let dir = tempfile::tempdir().unwrap();
21506        let source_dir = dir.path().join("src");
21507        std::fs::create_dir_all(&source_dir).unwrap();
21508        let old_file = source_dir.join("old.rs");
21509        std::fs::write(&old_file, "fn stale() {}\n").unwrap();
21510        std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
21511        init_git_repo(dir.path());
21512
21513        let summary_db =
21514            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21515        summary_db
21516            .insert(&summarize::Summary {
21517                id: 0,
21518                symbol_name: "stale".to_string(),
21519                file_path: "src/old.rs".to_string(),
21520                content_hash: "hash1".to_string(),
21521                summary: "stale summary".to_string(),
21522                entities: None,
21523                relationships: None,
21524                concept_labels: None,
21525                extracted_at: "1700000000".to_string(),
21526                model: "test".to_string(),
21527                tokens_input: Some(100),
21528                tokens_output: Some(50),
21529            })
21530            .unwrap();
21531
21532        let status = std::process::Command::new("git")
21533            .args(["mv", "src/old.rs", "src/new.rs"])
21534            .current_dir(dir.path())
21535            .status()
21536            .unwrap();
21537        assert!(status.success(), "git mv failed");
21538
21539        cmd_summarize(
21540            None,
21541            None,
21542            Some(PathBuf::from("src")),
21543            true,
21544            false,
21545            dir.path(),
21546            false,
21547            true,
21548            false,
21549            false,
21550            false,
21551        )
21552        .unwrap();
21553
21554        assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
21555    }
21556
21557    #[test]
21558    fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
21559        let dir = tempfile::tempdir().unwrap();
21560        let source_dir = dir.path().join("src");
21561        std::fs::create_dir_all(&source_dir).unwrap();
21562        let deleted_file = source_dir.join("gone.rs");
21563        std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
21564
21565        let summary_db =
21566            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21567        summary_db
21568            .insert(&summarize::Summary {
21569                id: 0,
21570                symbol_name: "stale".to_string(),
21571                file_path: "src/gone.rs".to_string(),
21572                content_hash: "hash1".to_string(),
21573                summary: "stale summary".to_string(),
21574                entities: None,
21575                relationships: None,
21576                concept_labels: None,
21577                extracted_at: "1700000000".to_string(),
21578                model: "test".to_string(),
21579                tokens_input: Some(100),
21580                tokens_output: Some(50),
21581            })
21582            .unwrap();
21583
21584        std::fs::remove_file(&deleted_file).unwrap();
21585
21586        cmd_summarize(
21587            None,
21588            None,
21589            Some(PathBuf::from("src")),
21590            false,
21591            false,
21592            dir.path(),
21593            false,
21594            true,
21595            false,
21596            false,
21597            false,
21598        )
21599        .unwrap();
21600
21601        assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
21602    }
21603
21604    #[test]
21605    fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
21606        let dir = tempfile::tempdir().unwrap();
21607        let source_dir = dir.path().join("src");
21608        std::fs::create_dir_all(&source_dir).unwrap();
21609        let file = source_dir.join("lib.rs");
21610        std::fs::write(&file, "fn helper() {}\n").unwrap();
21611
21612        let content = std::fs::read(&file).unwrap();
21613        let summary_db =
21614            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21615        summary_db
21616            .insert(&summarize::Summary {
21617                id: 0,
21618                symbol_name: "lib.rs".to_string(),
21619                file_path: "src/lib.rs".to_string(),
21620                content_hash: summarize::content_hash(&content),
21621                summary: "cached summary".to_string(),
21622                entities: None,
21623                relationships: None,
21624                concept_labels: None,
21625                extracted_at: "1700000000".to_string(),
21626                model: "test".to_string(),
21627                tokens_input: Some(100),
21628                tokens_output: Some(50),
21629            })
21630            .unwrap();
21631        drop(summary_db);
21632
21633        let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
21634        let _lock = hold_writer_lock(&lock_path);
21635
21636        let err = cmd_summarize(
21637            None,
21638            None,
21639            Some(PathBuf::from("src")),
21640            false,
21641            false,
21642            dir.path(),
21643            false,
21644            true,
21645            false,
21646            false,
21647            false,
21648        )
21649        .unwrap_err();
21650        let message = err.to_string();
21651
21652        assert!(message.contains("another tsift summarize extractor is already active"));
21653        assert!(message.contains("tsift summarize --extract"));
21654    }
21655
21656    #[test]
21657    fn summarize_stats_fails_closed_when_cache_missing() {
21658        let dir = tempfile::tempdir().unwrap();
21659        let err = cmd_summarize(
21660            None,
21661            None,
21662            None,
21663            false,
21664            true,
21665            dir.path(),
21666            false,
21667            false,
21668            false,
21669            false,
21670            false,
21671        )
21672        .unwrap_err();
21673
21674        assert!(
21675            err.to_string().contains("no summaries.db found"),
21676            "got: {err}"
21677        );
21678        assert!(!dir.path().join(".tsift/summaries.db").exists());
21679    }
21680
21681    #[test]
21682    fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21683        let dir = tempfile::tempdir().unwrap();
21684        let summary_db =
21685            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21686        summary_db
21687            .insert(&summarize::Summary {
21688                id: 0,
21689                symbol_name: "alpha_helper".to_string(),
21690                file_path: "src/lib.rs".to_string(),
21691                content_hash: "hash1".to_string(),
21692                summary: "cached summary".to_string(),
21693                entities: None,
21694                relationships: None,
21695                concept_labels: None,
21696                extracted_at: "1700000000".to_string(),
21697                model: "claude-haiku-4-5-20251001".to_string(),
21698                tokens_input: Some(100),
21699                tokens_output: Some(40),
21700            })
21701            .unwrap();
21702        drop(summary_db);
21703        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21704
21705        let result = cmd_summarize(
21706            None,
21707            None,
21708            None,
21709            false,
21710            true,
21711            dir.path(),
21712            false,
21713            false,
21714            false,
21715            false,
21716            false,
21717        );
21718
21719        assert!(result.is_ok());
21720    }
21721
21722    #[test]
21723    fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
21724        let dir = tempfile::tempdir().unwrap();
21725        let summary_db =
21726            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21727        summary_db
21728            .insert(&summarize::Summary {
21729                id: 0,
21730                symbol_name: "alpha_helper".to_string(),
21731                file_path: "src/lib.rs".to_string(),
21732                content_hash: "hash1".to_string(),
21733                summary: "cached summary".to_string(),
21734                entities: None,
21735                relationships: None,
21736                concept_labels: None,
21737                extracted_at: "1700000000".to_string(),
21738                model: "claude-haiku-4-5-20251001".to_string(),
21739                tokens_input: Some(100),
21740                tokens_output: Some(40),
21741            })
21742            .unwrap();
21743        drop(summary_db);
21744        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
21745
21746        let result = cmd_summarize(
21747            Some("alpha_helper".to_string()),
21748            None,
21749            None,
21750            false,
21751            false,
21752            dir.path(),
21753            false,
21754            true,
21755            false,
21756            false,
21757            false,
21758        );
21759
21760        assert!(result.is_ok());
21761    }
21762
21763    #[test]
21764    fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
21765        let dir = tempfile::tempdir().unwrap();
21766        let nested = dir.path().join("src/nested");
21767        std::fs::create_dir_all(&nested).unwrap();
21768
21769        let summary_db =
21770            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
21771        summary_db
21772            .insert(&summarize::Summary {
21773                id: 0,
21774                symbol_name: "alpha_helper".to_string(),
21775                file_path: "src/lib.rs".to_string(),
21776                content_hash: "hash1".to_string(),
21777                summary: "cached summary".to_string(),
21778                entities: None,
21779                relationships: None,
21780                concept_labels: None,
21781                extracted_at: "1700000000".to_string(),
21782                model: "claude-haiku-4-5-20251001".to_string(),
21783                tokens_input: Some(100),
21784                tokens_output: Some(40),
21785            })
21786            .unwrap();
21787
21788        let result = cmd_summarize(
21789            Some("alpha_helper".to_string()),
21790            None,
21791            None,
21792            false,
21793            false,
21794            &nested,
21795            false,
21796            true,
21797            false,
21798            false,
21799            false,
21800        );
21801
21802        assert!(result.is_ok());
21803        assert!(!nested.join(".tsift/summaries.db").exists());
21804    }
21805
21806    #[test]
21807    fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
21808        let dir = tempfile::tempdir().unwrap();
21809        std::fs::write(
21810            dir.path().join(".gitmodules"),
21811            r#"[submodule "src/alpha"]
21812	path = src/alpha
21813	url = https://example.com/alpha
21814[submodule "src/beta"]
21815	path = src/beta
21816	url = https://example.com/beta
21817"#,
21818        )
21819        .unwrap();
21820
21821        let alpha_root = dir.path().join("src/alpha");
21822        let beta_root = dir.path().join("src/beta");
21823        std::fs::create_dir_all(alpha_root.join("src")).unwrap();
21824        std::fs::create_dir_all(beta_root.join("src")).unwrap();
21825        std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
21826        std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
21827        std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
21828        let beta_file = beta_root.join("src/lib.rs");
21829        std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
21830        std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
21831        std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
21832
21833        let context = find_symbols_db_for_file(dir.path(), &beta_file)
21834            .unwrap()
21835            .expect("expected matching scoped index");
21836
21837        assert_eq!(
21838            context.db_path,
21839            dir.path().join(".tsift/indexes/beta/index.db")
21840        );
21841        assert_eq!(context.source_root, beta_root);
21842    }
21843
21844    // --- apply_edit_op ---
21845
21846    fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
21847        EditOp {
21848            file: PathBuf::from("dummy.txt"),
21849            old: old.to_string(),
21850            new: new.to_string(),
21851            replace_all,
21852        }
21853    }
21854
21855    #[test]
21856    fn edit_replaces_single_occurrence() {
21857        let content = "hello world";
21858        let op = make_op("world", "rust", false);
21859        let (result, count) = apply_edit_op(content, &op).unwrap();
21860        assert_eq!(result, "hello rust");
21861        assert_eq!(count, 1);
21862    }
21863
21864    #[test]
21865    fn edit_replace_all_replaces_every_occurrence() {
21866        let content = "foo foo foo";
21867        let op = make_op("foo", "bar", true);
21868        let (result, count) = apply_edit_op(content, &op).unwrap();
21869        assert_eq!(result, "bar bar bar");
21870        assert_eq!(count, 3);
21871    }
21872
21873    #[test]
21874    fn edit_fails_when_old_not_found() {
21875        let content = "hello world";
21876        let op = make_op("missing", "x", false);
21877        assert!(apply_edit_op(content, &op).is_err());
21878    }
21879
21880    #[test]
21881    fn edit_fails_when_ambiguous_without_replace_all() {
21882        let content = "foo foo";
21883        let op = make_op("foo", "bar", false);
21884        let err = apply_edit_op(content, &op).unwrap_err();
21885        assert!(err.to_string().contains("2 times"), "got: {}", err);
21886    }
21887
21888    #[test]
21889    fn edit_fails_when_old_equals_new() {
21890        let content = "hello";
21891        let op = make_op("hello", "hello", false);
21892        assert!(apply_edit_op(content, &op).is_err());
21893    }
21894
21895    #[test]
21896    fn edit_batch_rolls_back_when_later_swap_fails() {
21897        let dir = tempfile::tempdir().unwrap();
21898        let alpha = dir.path().join("alpha.txt");
21899        let beta = dir.path().join("beta.txt");
21900        fs::write(&alpha, "alpha old\n").unwrap();
21901        fs::write(&beta, "beta old\n").unwrap();
21902
21903        let batch = EditBatch {
21904            edits: vec![
21905                EditOp {
21906                    file: alpha.clone(),
21907                    old: "old".to_string(),
21908                    new: "new".to_string(),
21909                    replace_all: false,
21910                },
21911                EditOp {
21912                    file: beta.clone(),
21913                    old: "old".to_string(),
21914                    new: "new".to_string(),
21915                    replace_all: false,
21916                },
21917            ],
21918        };
21919
21920        let plan = build_edit_plan(&batch).unwrap();
21921        let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
21922            if commit_index == 1 {
21923                bail!("simulated swap failure");
21924            }
21925            Ok(())
21926        }) {
21927            Ok(_) => panic!("expected simulated swap failure"),
21928            Err(err) => err,
21929        };
21930
21931        assert!(err.to_string().contains("simulated swap failure"));
21932        assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
21933        assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
21934    }
21935
21936    // --- SQL introspection ---
21937
21938    fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
21939        let tmp = tempfile::NamedTempFile::new().unwrap();
21940        let conn = Connection::open(tmp.path()).unwrap();
21941        conn.execute_batch(
21942            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
21943             INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
21944             INSERT INTO users VALUES (2, 'Bob', NULL);
21945             CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
21946                 FOREIGN KEY(user_id) REFERENCES users(id));
21947             INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
21948             INSERT INTO posts VALUES (2, 1, 'Second', NULL);
21949             INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
21950        ).unwrap();
21951        (tmp, conn)
21952    }
21953
21954    // --- rewrite_command ---
21955
21956    #[test]
21957    fn rewrite_rg_simple_pattern() {
21958        let result = rewrite_command("rg authenticate");
21959        assert_eq!(
21960            result,
21961            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
21962        );
21963    }
21964
21965    #[test]
21966    fn rewrite_rg_with_path() {
21967        let result = rewrite_command("rg authenticate src/");
21968        assert_eq!(
21969            result,
21970            Some(
21971                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
21972                    .to_string()
21973            )
21974        );
21975    }
21976
21977    #[test]
21978    fn rewrite_rg_with_flags_ignored() {
21979        let result = rewrite_command("rg -i authenticate src/");
21980        assert_eq!(
21981            result,
21982            Some(
21983                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
21984                    .to_string()
21985            )
21986        );
21987    }
21988
21989    #[test]
21990    fn rewrite_rg_with_type_flag() {
21991        // -t rs takes a value, should be skipped; pattern is next positional
21992        let result = rewrite_command("rg -t rs authenticate");
21993        assert_eq!(
21994            result,
21995            Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
21996        );
21997    }
21998
21999    #[test]
22000    fn rewrite_rg_pipe_passthrough() {
22001        // Pipe chains can't be translated — pass through
22002        let result = rewrite_command("rg authenticate | head -5");
22003        assert_eq!(result, None);
22004    }
22005
22006    #[test]
22007    fn rewrite_rg_files_passthrough() {
22008        let result = rewrite_command("rg --files src/tsift .agent-doc logs");
22009        assert_eq!(result, None);
22010    }
22011
22012    #[test]
22013    fn rewrite_find_passthrough() {
22014        let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
22015        assert_eq!(result, None);
22016    }
22017
22018    #[test]
22019    fn rewrite_grep_recursive() {
22020        let result = rewrite_command("grep -r authenticate src/");
22021        assert_eq!(
22022            result,
22023            Some(
22024                "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22025                    .to_string()
22026            )
22027        );
22028    }
22029
22030    #[test]
22031    fn rewrite_grep_non_recursive_passthrough() {
22032        let result = rewrite_command("grep authenticate file.txt");
22033        assert_eq!(result, None);
22034    }
22035
22036    #[test]
22037    fn rewrite_tsift_passthrough() {
22038        let result = rewrite_command("tsift search \"foo\"");
22039        assert_eq!(result, Some("tsift search \"foo\"".to_string()));
22040    }
22041
22042    #[test]
22043    fn rewrite_run_tsift_search_disables_timeout_by_default() {
22044        let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
22045        assert_eq!(
22046            result,
22047            "tsift search hookcaps --exact --path /tmp/x --timeout 0"
22048        );
22049    }
22050
22051    #[test]
22052    fn rewrite_run_preserves_explicit_search_timeout() {
22053        let result = effective_rewrite_run_command(
22054            "tsift search hookcaps --exact --path /tmp/x --timeout 5",
22055        );
22056        assert_eq!(
22057            result,
22058            "tsift search hookcaps --exact --path /tmp/x --timeout 5"
22059        );
22060    }
22061
22062    #[test]
22063    fn rewrite_unrelated_passthrough() {
22064        let result = rewrite_command("echo cargo build");
22065        assert_eq!(result, None);
22066    }
22067
22068    #[test]
22069    fn rewrite_rg_quoted_pattern() {
22070        let result = rewrite_command("rg \"fn main\"");
22071        assert_eq!(
22072            result,
22073            Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
22074        );
22075    }
22076
22077    #[test]
22078    fn rewrite_git_diff_to_diff_digest() {
22079        let result = rewrite_command("git diff");
22080        assert_eq!(result, Some("tsift diff-digest .".to_string()));
22081    }
22082
22083    #[test]
22084    fn rewrite_git_diff_cached_to_diff_digest() {
22085        let result = rewrite_command("git diff --cached");
22086        assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
22087    }
22088
22089    #[test]
22090    fn rewrite_git_diff_with_path_to_diff_digest() {
22091        let result = rewrite_command("git diff -- src/");
22092        assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
22093    }
22094
22095    #[test]
22096    fn rewrite_git_diff_with_revision_passthrough() {
22097        let result = rewrite_command("git diff HEAD~1");
22098        assert_eq!(result, None);
22099    }
22100
22101    #[test]
22102    fn rewrite_git_show_to_revision_diff_digest() {
22103        let result = rewrite_command("git show HEAD~1");
22104        assert_eq!(
22105            result,
22106            Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
22107        );
22108    }
22109
22110    #[test]
22111    fn rewrite_git_log_patch_history_to_revision_diff_digest() {
22112        let result = rewrite_command("git log -p -1 HEAD~2");
22113        assert_eq!(
22114            result,
22115            Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
22116        );
22117    }
22118
22119    #[test]
22120    fn rewrite_cat_long_agent_doc_session_to_session_digest() {
22121        let dir = tempfile::tempdir().unwrap();
22122        let session = dir.path().join("tsift.md");
22123        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22124        for index in 0..90 {
22125            body.push_str(&format!("❯ prompt {index}?\n"));
22126        }
22127        fs::write(&session, body).unwrap();
22128
22129        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22130        assert_eq!(
22131            result,
22132            Some(format!(
22133                "tsift session-digest --path {} --input {} --source markdown",
22134                shell_quote(&resolve_digest_context_path(&session)),
22135                shell_quote(session.to_str().unwrap())
22136            ))
22137        );
22138    }
22139
22140    #[test]
22141    fn rewrite_head_long_claude_jsonl_to_session_digest() {
22142        let dir = tempfile::tempdir().unwrap();
22143        let session = dir.path().join("session.jsonl");
22144        let line =
22145            r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
22146        let body = std::iter::repeat_n(line, 120)
22147            .collect::<Vec<_>>()
22148            .join("\n");
22149        fs::write(&session, format!("{body}\n")).unwrap();
22150
22151        let result = rewrite_command(&format!(
22152            "head -n 120 {}",
22153            shell_quote(session.to_str().unwrap())
22154        ));
22155        assert_eq!(
22156            result,
22157            Some(format!(
22158                "tsift session-digest --path {} --input {} --source claude-jsonl",
22159                shell_quote(&resolve_digest_context_path(&session)),
22160                shell_quote(session.to_str().unwrap())
22161            ))
22162        );
22163    }
22164
22165    #[test]
22166    fn rewrite_head_long_codex_jsonl_to_session_digest() {
22167        let dir = tempfile::tempdir().unwrap();
22168        let session = dir.path().join("codex.jsonl");
22169        let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
22170        let body = std::iter::repeat_n(line, 120)
22171            .collect::<Vec<_>>()
22172            .join("\n");
22173        fs::write(&session, format!("{body}\n")).unwrap();
22174
22175        let result = rewrite_command(&format!(
22176            "head -n 120 {}",
22177            shell_quote(session.to_str().unwrap())
22178        ));
22179        assert_eq!(
22180            result,
22181            Some(format!(
22182                "tsift session-digest --path {} --input {} --source codex-jsonl",
22183                shell_quote(&resolve_digest_context_path(&session)),
22184                shell_quote(session.to_str().unwrap())
22185            ))
22186        );
22187    }
22188
22189    #[test]
22190    fn rewrite_small_transcript_window_passthrough() {
22191        let dir = tempfile::tempdir().unwrap();
22192        let session = dir.path().join("session.jsonl");
22193        let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
22194        let body = std::iter::repeat_n(line, 120)
22195            .collect::<Vec<_>>()
22196            .join("\n");
22197        fs::write(&session, format!("{body}\n")).unwrap();
22198
22199        let result = rewrite_command(&format!(
22200            "tail -n 20 {}",
22201            shell_quote(session.to_str().unwrap())
22202        ));
22203        assert_eq!(result, None);
22204    }
22205
22206    #[test]
22207    fn rewrite_sed_large_agent_doc_range_to_session_digest() {
22208        let dir = tempfile::tempdir().unwrap();
22209        let session = dir.path().join("tsift.md");
22210        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22211        for index in 0..120 {
22212            body.push_str(&format!("### Re: topic {index}\n"));
22213        }
22214        fs::write(&session, body).unwrap();
22215
22216        let result = rewrite_command(&format!(
22217            "sed -n '1,120p' {}",
22218            shell_quote(session.to_str().unwrap())
22219        ));
22220        assert_eq!(
22221            result,
22222            Some(format!(
22223                "tsift session-digest --path {} --input {} --source markdown",
22224                shell_quote(&resolve_digest_context_path(&session)),
22225                shell_quote(session.to_str().unwrap())
22226            ))
22227        );
22228    }
22229
22230    #[test]
22231    fn rewrite_cat_large_agent_doc_log_to_session_digest() {
22232        let dir = tempfile::tempdir().unwrap();
22233        let session = dir.path().join("tsift.log");
22234        let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
22235        let body = std::iter::repeat_n(line, 120)
22236            .collect::<Vec<_>>()
22237            .join("\n");
22238        fs::write(&session, format!("{body}\n")).unwrap();
22239
22240        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22241        assert_eq!(
22242            result,
22243            Some(format!(
22244                "tsift session-digest --path {} --input {} --source agent-doc-log",
22245                shell_quote(&resolve_digest_context_path(&session)),
22246                shell_quote(session.to_str().unwrap())
22247            ))
22248        );
22249    }
22250
22251    #[test]
22252    fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
22253        let dir = tempfile::tempdir().unwrap();
22254        fs::write(
22255            dir.path().join(".gitmodules"),
22256            r#"[submodule "src/tsift"]
22257	path = src/tsift
22258	url = https://example.com/tsift
22259"#,
22260        )
22261        .unwrap();
22262        let submodule = dir.path().join("src/tsift");
22263        fs::create_dir_all(submodule.join("tasks")).unwrap();
22264        fs::write(
22265            submodule.join(".git"),
22266            "gitdir: ../../.git/modules/src/tsift\n",
22267        )
22268        .unwrap();
22269        let session = submodule.join("tasks/plan.md");
22270        let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
22271        for index in 0..90 {
22272            body.push_str(&format!("❯ prompt {index}?\n"));
22273        }
22274        fs::write(&session, body).unwrap();
22275
22276        let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
22277
22278        assert_eq!(
22279            result,
22280            Some(format!(
22281                "tsift session-digest --path {} --input {} --source markdown",
22282                shell_quote(submodule.to_str().unwrap()),
22283                shell_quote(session.to_str().unwrap())
22284            ))
22285        );
22286    }
22287
22288    #[test]
22289    fn rewrite_regular_markdown_read_passthrough() {
22290        let dir = tempfile::tempdir().unwrap();
22291        let readme = dir.path().join("README.md");
22292        let body = std::iter::repeat_n("plain markdown", 120)
22293            .collect::<Vec<_>>()
22294            .join("\n");
22295        fs::write(&readme, format!("{body}\n")).unwrap();
22296
22297        let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
22298        assert_eq!(result, None);
22299    }
22300
22301    #[test]
22302    fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
22303        let dir = tempfile::tempdir().unwrap();
22304        write_empty_root_index(dir.path());
22305        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22306
22307        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22308
22309        assert_eq!(
22310            result,
22311            Some(format!(
22312                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
22313                shell_quote(&dir.path().to_string_lossy())
22314            ))
22315        );
22316    }
22317
22318    #[test]
22319    fn rewrite_head_small_source_window_passthrough() {
22320        let dir = tempfile::tempdir().unwrap();
22321        write_empty_root_index(dir.path());
22322        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22323
22324        let result = rewrite_command(&format!(
22325            "head -n 20 {}",
22326            shell_quote(source.to_str().unwrap())
22327        ));
22328
22329        assert_eq!(result, None);
22330    }
22331
22332    #[test]
22333    fn rewrite_sed_large_source_range_to_source_read() {
22334        let dir = tempfile::tempdir().unwrap();
22335        write_empty_root_index(dir.path());
22336        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22337
22338        let result = rewrite_command(&format!(
22339            "sed -n '40,160p' {}",
22340            shell_quote(source.to_str().unwrap())
22341        ));
22342
22343        assert_eq!(
22344            result,
22345            Some(format!(
22346                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
22347                shell_quote(&dir.path().to_string_lossy())
22348            ))
22349        );
22350    }
22351
22352    #[test]
22353    fn rewrite_tail_large_source_window_preserves_tail_anchor() {
22354        let dir = tempfile::tempdir().unwrap();
22355        write_empty_root_index(dir.path());
22356        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
22357
22358        let result = rewrite_command(&format!(
22359            "tail -n 120 {}",
22360            shell_quote(source.to_str().unwrap())
22361        ));
22362
22363        assert_eq!(
22364            result,
22365            Some(format!(
22366                "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
22367                shell_quote(&dir.path().to_string_lossy())
22368            ))
22369        );
22370    }
22371
22372    #[test]
22373    fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
22374        let dir = tempfile::tempdir().unwrap();
22375        write_empty_root_index(dir.path());
22376        let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
22377
22378        let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
22379
22380        assert_eq!(result, None);
22381    }
22382
22383    #[test]
22384    fn rewrite_large_source_read_passthrough_without_index() {
22385        let dir = tempfile::tempdir().unwrap();
22386        let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
22387
22388        let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
22389
22390        assert_eq!(result, None);
22391    }
22392
22393    #[test]
22394    fn rewrite_cargo_test_to_digest_runner() {
22395        let result = rewrite_command("cargo test --lib");
22396        assert_eq!(
22397            result,
22398            Some(
22399                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
22400            )
22401        );
22402    }
22403
22404    #[test]
22405    fn rewrite_pytest_to_digest_runner() {
22406        let result = rewrite_command("pytest -q tests/test_cli.py");
22407        assert_eq!(
22408            result,
22409            Some(
22410                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
22411            )
22412        );
22413    }
22414
22415    #[test]
22416    fn rewrite_python_m_pytest_to_digest_runner() {
22417        let result = rewrite_command("python -m pytest tests/test_cli.py");
22418        assert_eq!(
22419            result,
22420            Some(
22421                "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
22422            )
22423        );
22424    }
22425
22426    #[test]
22427    fn rewrite_cargo_build_to_log_digest_runner() {
22428        let result = rewrite_command("cargo build --release");
22429        assert_eq!(
22430            result,
22431            Some(
22432                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
22433            )
22434        );
22435    }
22436
22437    #[test]
22438    fn rewrite_cargo_install_to_log_digest_runner() {
22439        let result = rewrite_command("cargo install --path . --force");
22440        assert_eq!(
22441            result,
22442            Some(
22443                "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
22444            )
22445        );
22446    }
22447
22448    #[test]
22449    fn rewrite_metacharacter_command_passthrough() {
22450        let result = rewrite_command("cargo test | head");
22451        assert_eq!(result, None);
22452    }
22453
22454    #[test]
22455    fn rewrite_output_cap_detects_search_even_with_global_flag() {
22456        let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
22457        assert_eq!(cap.max_lines, 50);
22458        assert_eq!(cap.strip_prefix, Some("Strategy:"));
22459    }
22460
22461    #[test]
22462    fn rewrite_output_cap_skips_structured_output() {
22463        assert!(rewrite_output_cap("tsift search foo --json").is_none());
22464        assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
22465        assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
22466    }
22467
22468    #[test]
22469    fn rewrite_output_format_forwards_envelope_to_digest_runner() {
22470        let command = rewrite_command("cargo test --lib").expect("rewrite");
22471        let forwarded = apply_rewrite_output_format(
22472            &command,
22473            OutputFormat {
22474                json_output: true,
22475                compact: false,
22476                pretty: false,
22477                terse: false,
22478                ultra_terse: false,
22479                schema: false,
22480                envelope: true,
22481            },
22482        );
22483        assert_eq!(
22484            forwarded,
22485            "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
22486        );
22487    }
22488
22489    #[test]
22490    fn rewrite_output_format_forwards_json_when_requested() {
22491        let command = rewrite_command("cargo build --release").expect("rewrite");
22492        let forwarded = apply_rewrite_output_format(
22493            &command,
22494            OutputFormat {
22495                json_output: true,
22496                compact: false,
22497                pretty: true,
22498                terse: false,
22499                ultra_terse: false,
22500                schema: false,
22501                envelope: false,
22502            },
22503        );
22504        assert_eq!(
22505            forwarded,
22506            "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
22507        );
22508    }
22509
22510    #[test]
22511    fn output_cap_strips_search_header_and_truncates() {
22512        let capped = apply_output_cap(
22513            b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
22514            OutputCap {
22515                max_lines: 2,
22516                strip_prefix: Some("Strategy:"),
22517            },
22518        );
22519        assert_eq!(
22520            capped,
22521            "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
22522        );
22523    }
22524
22525    #[test]
22526    fn sql_schema_overview_lists_tables() {
22527        let (_tmp, conn) = setup_test_db();
22528        let tables = schema_overview(&conn).unwrap();
22529        let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
22530        assert_eq!(names, &["posts", "users"]);
22531    }
22532
22533    #[test]
22534    fn sql_schema_overview_row_counts() {
22535        let (_tmp, conn) = setup_test_db();
22536        let tables = schema_overview(&conn).unwrap();
22537        let users = tables.iter().find(|t| t.name == "users").unwrap();
22538        let posts = tables.iter().find(|t| t.name == "posts").unwrap();
22539        assert_eq!(users.row_count, 2);
22540        assert_eq!(posts.row_count, 3);
22541    }
22542
22543    #[test]
22544    fn sql_table_columns_metadata() {
22545        let (_tmp, conn) = setup_test_db();
22546        let cols = table_columns(&conn, "users").unwrap();
22547        assert_eq!(cols.len(), 3);
22548        assert_eq!(cols[0].name, "id");
22549        assert!(cols[0].pk);
22550        assert_eq!(cols[1].name, "name");
22551        assert!(cols[1].notnull);
22552        assert_eq!(cols[2].name, "email");
22553        assert!(!cols[2].notnull);
22554    }
22555
22556    #[test]
22557    fn sql_execute_query_returns_rows() {
22558        let (_tmp, conn) = setup_test_db();
22559        let (columns, rows) =
22560            execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
22561        assert_eq!(columns, &["name", "email"]);
22562        assert_eq!(rows.len(), 2);
22563        assert_eq!(rows[0][0], serde_json::json!("Alice"));
22564        assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
22565        assert_eq!(rows[1][1], serde_json::Value::Null);
22566    }
22567
22568    #[test]
22569    fn sql_execute_query_aggregate() {
22570        let (_tmp, conn) = setup_test_db();
22571        let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
22572        assert_eq!(columns, &["cnt"]);
22573        assert_eq!(rows[0][0], serde_json::json!(3));
22574    }
22575
22576    #[test]
22577    fn sql_execute_query_join() {
22578        let (_tmp, conn) = setup_test_db();
22579        let (_cols, rows) = execute_query(
22580            &conn,
22581            "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
22582        )
22583        .unwrap();
22584        assert_eq!(rows.len(), 3);
22585        assert_eq!(rows[0][0], serde_json::json!("Alice"));
22586        assert_eq!(rows[2][0], serde_json::json!("Bob"));
22587    }
22588
22589    #[test]
22590    fn sql_open_db_read_only() {
22591        let (tmp, _conn) = setup_test_db();
22592        drop(_conn);
22593        let ro_conn = open_db(tmp.path()).unwrap();
22594        let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
22595        assert!(result.is_err(), "read-only connection should reject writes");
22596    }
22597
22598    #[test]
22599    fn sql_empty_table_schema() {
22600        let tmp = tempfile::NamedTempFile::new().unwrap();
22601        let conn = Connection::open(tmp.path()).unwrap();
22602        conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
22603            .unwrap();
22604        let tables = schema_overview(&conn).unwrap();
22605        assert_eq!(tables[0].row_count, 0);
22606        assert_eq!(tables[0].columns.len(), 2);
22607    }
22608
22609    // --- graph command ---
22610
22611    fn setup_graph_index() -> tempfile::TempDir {
22612        let dir = tempfile::tempdir().unwrap();
22613        std::fs::write(
22614            dir.path().join("main.rs"),
22615            "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
22616        )
22617        .unwrap();
22618        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22619        db.apply_changes(dir.path()).unwrap();
22620        dir
22621    }
22622
22623    fn setup_traversal_project() -> tempfile::TempDir {
22624        let dir = setup_graph_index();
22625        let task_dir = dir.path().join("tasks/software");
22626        std::fs::create_dir_all(&task_dir).unwrap();
22627        std::fs::write(
22628            task_dir.join("tsift.md"),
22629            r#"---
22630agent_doc_session: tsift-v0.1
22631agent_doc_format: template
22632---
22633
22634## Exchange
22635
22636<!-- agent:exchange patch=append -->
22637❯ do [#kgnv]
22638Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
22639<!-- /agent:exchange -->
22640
22641<!-- agent:queue -->
22642dispatch #spec-test-build-install-commit-push
22643- do [#kgnv]
22644<!-- /agent:queue -->
22645
22646## Backlog
22647
22648<!-- agent:backlog -->
22649- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
22650<!-- /agent:backlog -->
22651"#,
22652        )
22653        .unwrap();
22654        dir
22655    }
22656
22657    fn resolve_ast_span_node<'a>(
22658        graph: &'a TraversalGraphBuild,
22659        label: &str,
22660        symbol_kind: &str,
22661    ) -> &'a TraversalNode {
22662        graph
22663            .nodes
22664            .values()
22665            .find(|node| {
22666                node.kind == "ast_span"
22667                    && node.label == label
22668                    && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
22669            })
22670            .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
22671    }
22672
22673    fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
22674        let dir = tempfile::tempdir().unwrap();
22675        std::fs::write(
22676            dir.path().join("rust.rs"),
22677            r#"mod fixture_nav_rust_mod {
22678    pub fn fixture_nav_rust_helper() {}
22679    pub fn fixture_nav_rust_entry() {
22680        fixture_nav_rust_helper();
22681    }
22682}
22683"#,
22684        )
22685        .unwrap();
22686        std::fs::write(
22687            dir.path().join("python.py"),
22688            r#"def fixture_nav_python_helper():
22689    return 1
22690
22691def fixture_nav_python_entry():
22692    return fixture_nav_python_helper()
22693"#,
22694        )
22695        .unwrap();
22696        std::fs::write(
22697            dir.path().join("typescript.ts"),
22698            r#"export function fixture_nav_typescript_entry(): number {
22699    return fixtureNavTsHelper();
22700}
22701
22702function fixtureNavTsHelper(): number {
22703    return 1;
22704}
22705"#,
22706        )
22707        .unwrap();
22708        std::fs::write(
22709            dir.path().join("javascript.js"),
22710            r#"function fixture_nav_javascript_entry() {
22711    return fixtureNavJsHelper();
22712}
22713
22714function fixtureNavJsHelper() {
22715    return 1;
22716}
22717"#,
22718        )
22719        .unwrap();
22720        std::fs::write(
22721            dir.path().join("kotlin.kt"),
22722            r#"fun fixture_nav_kotlin_entry(): Int {
22723    return fixtureNavKotlinHelper()
22724}
22725
22726fun fixtureNavKotlinHelper(): Int = 1
22727"#,
22728        )
22729        .unwrap();
22730        std::fs::write(
22731            dir.path().join("zig.zig"),
22732            r#"pub fn fixture_nav_zig_entry() i32 {
22733    return fixtureNavZigHelper();
22734}
22735
22736fn fixtureNavZigHelper() i32 {
22737    return 1;
22738}
22739"#,
22740        )
22741        .unwrap();
22742        std::fs::write(
22743            dir.path().join("bash.sh"),
22744            r#"#!/usr/bin/env bash
22745fixture_nav_bash_entry() {
22746    fixture_nav_bash_helper
22747}
22748
22749fixture_nav_bash_helper() {
22750    echo ok
22751}
22752
22753alias fixture_nav_bash_alias='echo alias'
22754"#,
22755        )
22756        .unwrap();
22757        std::fs::write(
22758            dir.path().join("README.md"),
22759            r#"# Fixture Guide
22760
22761## Fixture Section
22762
22763- Fixture step
22764  - Nested fixture step
22765
22766```python
22767def fixture_nav_markdown_embedded():
22768    return 1
22769```
22770"#,
22771        )
22772        .unwrap();
22773
22774        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22775        db.apply_changes(dir.path()).unwrap();
22776        dir
22777    }
22778
22779    fn assert_cli_expand_command_parses(command: &str) {
22780        let args = shell_split(command)
22781            .into_iter()
22782            .map(str::to_string)
22783            .collect::<Vec<_>>();
22784        assert!(
22785            try_parse_cli(args).is_ok(),
22786            "expand command should parse as a tsift CLI command: {command}"
22787        );
22788    }
22789
22790    fn setup_multiplicity_project() -> tempfile::TempDir {
22791        let dir = tempfile::tempdir().unwrap();
22792        std::fs::write(
22793            dir.path().join("Cargo.toml"),
22794            r#"[workspace]
22795members = ["crates/core-lib", "crates/cli-app"]
22796"#,
22797        )
22798        .unwrap();
22799        std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
22800        std::fs::write(
22801            dir.path().join("crates/core-lib/Cargo.toml"),
22802            r#"[package]
22803name = "core-lib"
22804
22805[lib]
22806name = "core_lib"
22807
22808[features]
22809default = []
22810"#,
22811        )
22812        .unwrap();
22813        std::fs::write(
22814            dir.path().join("crates/core-lib/src/lib.rs"),
22815            "pub fn run() {}\n",
22816        )
22817        .unwrap();
22818        std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
22819        std::fs::write(
22820            dir.path().join("crates/cli-app/Cargo.toml"),
22821            r#"[package]
22822name = "cli-app"
22823
22824[[bin]]
22825name = "cli-app"
22826
22827[dependencies]
22828core-lib = { path = "../core-lib" }
22829"#,
22830        )
22831        .unwrap();
22832        std::fs::write(
22833            dir.path().join("crates/cli-app/src/main.rs"),
22834            "use core_lib::run;\nfn main() { run(); }\n",
22835        )
22836        .unwrap();
22837        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22838        db.apply_changes(dir.path()).unwrap();
22839
22840        let task_dir = dir.path().join("tasks/software");
22841        std::fs::create_dir_all(&task_dir).unwrap();
22842        std::fs::write(
22843            task_dir.join("tsift.md"),
22844            r#"---
22845agent_doc_session: tsift-multiplicity
22846agent_doc_format: template
22847---
22848
22849## Backlog
22850
22851<!-- agent:backlog -->
22852- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
22853<!-- /agent:backlog -->
22854"#,
22855        )
22856        .unwrap();
22857        init_git_repo(dir.path());
22858        dir
22859    }
22860
22861    fn setup_dependency_dag_project() -> tempfile::TempDir {
22862        let dir = tempfile::tempdir().unwrap();
22863        std::fs::write(
22864            dir.path().join("main.rs"),
22865            "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
22866        )
22867        .unwrap();
22868        std::fs::write(
22869            dir.path().join("Cargo.toml"),
22870            "[package]\nname = \"dag-fixture\"\n",
22871        )
22872        .unwrap();
22873        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
22874        db.apply_changes(dir.path()).unwrap();
22875
22876        let task_dir = dir.path().join("tasks/software");
22877        std::fs::create_dir_all(&task_dir).unwrap();
22878        std::fs::write(
22879            task_dir.join("tsift.md"),
22880            r#"---
22881agent_doc_session: tsift-dag
22882agent_doc_format: template
22883---
22884
22885## Exchange
22886
22887<!-- agent:exchange patch=append -->
22888Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
22889<!-- /agent:exchange -->
22890
22891## Backlog
22892
22893<!-- agent:backlog -->
22894- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
22895- [ ] [#alpha] Update shared_helper in main.rs after #prep.
22896- [ ] [#beta] Refactor shared_helper tests in main.rs.
22897- [ ] [#gamma] Follow-up review for graph navigation.
22898<!-- /agent:backlog -->
22899"#,
22900        )
22901        .unwrap();
22902        dir
22903    }
22904
22905    fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
22906        let dir = setup_graph_index();
22907        let task_dir = dir.path().join("tasks/software");
22908        std::fs::create_dir_all(&task_dir).unwrap();
22909        std::fs::write(
22910            task_dir.join("tsift.md"),
22911            r#"---
22912agent_doc_session: tsift-dag-cycle
22913agent_doc_format: template
22914---
22915
22916## Backlog
22917
22918<!-- agent:backlog -->
22919- [ ] [#left] Left side depends on #right.
22920- [ ] [#right] Right side depends on #left.
22921<!-- /agent:backlog -->
22922"#,
22923        )
22924        .unwrap();
22925        dir
22926    }
22927
22928    fn seed_traversal_semantic_summaries(dir: &Path) {
22929        let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
22930        summary_db
22931            .insert(&summarize::Summary {
22932                id: 0,
22933                symbol_name: "helper".to_string(),
22934                file_path: "main.rs".to_string(),
22935                content_hash: "hash-main".to_string(),
22936                summary: "helper builds graph navigation handles for traversal.".to_string(),
22937                entities: Some(vec![
22938                    summarize::Entity {
22939                        name: "helper".to_string(),
22940                        kind: "function".to_string(),
22941                        description: "Builds graph navigation handles.".to_string(),
22942                    },
22943                    summarize::Entity {
22944                        name: "TraversalGraph".to_string(),
22945                        kind: "type".to_string(),
22946                        description: "Carries GraphStore-backed traversal rows.".to_string(),
22947                    },
22948                ]),
22949                relationships: Some(vec![summarize::Relationship {
22950                    from: "helper".to_string(),
22951                    to: "TraversalGraph".to_string(),
22952                    kind: "uses".to_string(),
22953                }]),
22954                concept_labels: Some(vec![
22955                    "graph navigation".to_string(),
22956                    "semantic extraction".to_string(),
22957                ]),
22958                extracted_at: "1700000000".to_string(),
22959                model: "test-model".to_string(),
22960                tokens_input: Some(10),
22961                tokens_output: Some(5),
22962            })
22963            .unwrap();
22964    }
22965
22966    fn seed_tsift_memory_graph_db(dir: &Path) {
22967        let db = dir.join(".tsift").join("memory.db");
22968        let store = MemoryStore::open_or_create(&db).unwrap();
22969        let project = dir.to_string_lossy().to_string();
22970        let observation = MemoryEvent::new(
22971            MemoryEventKind::ImportedObservation,
22972            "claude-mem:observations:1",
22973            [
22974                "Graph memory adapter",
22975                "read-only projection",
22976                "graph-db should retrieve tsift memory observations",
22977                "Project memory is queried from .tsift/memory.db",
22978                "graph memory, tsift memory, semantic query",
22979            ]
22980            .join("\n\n"),
22981        )
22982        .with_session_id("claude-session-a")
22983        .with_observed_at_unix(1_700_000_000)
22984        .with_import("claude-mem", "observations:1")
22985        .with_metadata("project", project.clone())
22986        .with_metadata("observation_type", "fact")
22987        .with_metadata("prompt_number", "7")
22988        .with_metadata("discovery_tokens", "42")
22989        .with_metadata("content_hash", "hash-observation-1");
22990        store.insert_event(&observation).unwrap();
22991
22992        let summary = MemoryEvent::new(
22993            MemoryEventKind::ImportedSessionSummary,
22994            "claude-mem:session_summaries:2",
22995            [
22996                "Query old memory from graph-db",
22997                "Read-only tsift memory SQLite projection",
22998                "Semantic graph rows can point at existing memory",
22999                "Projected source and session nodes",
23000                "Keep capture ownership inside tsift-memory",
23001                "summary note",
23002            ]
23003            .join("\n\n"),
23004        )
23005        .with_session_id("claude-session-a")
23006        .with_observed_at_unix(1_700_000_010)
23007        .with_import("claude-mem", "session_summaries:2")
23008        .with_metadata("project", project)
23009        .with_metadata("prompt_number", "8")
23010        .with_metadata("discovery_tokens", "36");
23011        store.insert_event(&summary).unwrap();
23012
23013        let prompt = MemoryEvent::new(
23014            MemoryEventKind::ImportedUserPrompt,
23015            "claude-mem:user_prompts:3",
23016            "How can graph-db query tsift memory semantic history?",
23017        )
23018        .with_session_id("claude-session-a")
23019        .with_observed_at_unix(1_700_000_020)
23020        .with_import("claude-mem", "user_prompts:3")
23021        .with_metadata("prompt_number", "9");
23022        store.insert_event(&prompt).unwrap();
23023    }
23024
23025    #[test]
23026    fn graph_callers_query() {
23027        let dir = setup_graph_index();
23028        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23029        let callers = db.callers_of("helper").unwrap();
23030        assert_eq!(callers.len(), 1);
23031        assert_eq!(callers[0].caller_name, "main");
23032    }
23033
23034    #[test]
23035    fn graph_callees_query() {
23036        let dir = setup_graph_index();
23037        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23038        let callees = db.callees_of("main").unwrap();
23039        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
23040        assert!(names.contains(&"helper"));
23041        assert!(names.contains(&"new"));
23042    }
23043
23044    #[test]
23045    fn graph_no_callers_returns_empty() {
23046        let dir = setup_graph_index();
23047        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23048        let callers = db.callers_of("nonexistent").unwrap();
23049        assert!(callers.is_empty());
23050    }
23051
23052    #[test]
23053    fn graph_cmd_autoindexes_missing_index_by_default() {
23054        let dir = tempfile::tempdir().unwrap();
23055        std::fs::write(
23056            dir.path().join("main.rs"),
23057            "fn helper() {}\nfn main() { helper(); }\n",
23058        )
23059        .unwrap();
23060        let result = cmd_graph(
23061            "helper",
23062            dir.path(),
23063            true,
23064            false,
23065            None,
23066            20,
23067            false,
23068            true,
23069            false,
23070            false,
23071            false,
23072            false,
23073            false,
23074            TagpathSearchOpts::default(),
23075        );
23076
23077        assert!(result.is_ok());
23078        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
23079        let summary = db.compute_changes(dir.path()).unwrap();
23080        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
23081    }
23082
23083    #[test]
23084    fn traversal_graph_has_stable_typed_handles() {
23085        let dir = setup_traversal_project();
23086        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23087        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23088
23089        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
23090        let symbol = resolve_traversal_node(&graph, "helper").unwrap();
23091        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23092        let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
23093
23094        assert!(file.handle.starts_with("gfil-"));
23095        assert!(symbol.handle.starts_with("gsym-"));
23096        assert!(backlog.handle.starts_with("gbak-"));
23097        assert!(session.handle.starts_with("gses-"));
23098
23099        assert_eq!(
23100            symbol.handle,
23101            resolve_traversal_node(&graph_again, "helper")
23102                .unwrap()
23103                .handle
23104        );
23105        assert_eq!(
23106            backlog.handle,
23107            resolve_traversal_node(&graph_again, "#kgnv")
23108                .unwrap()
23109                .handle
23110        );
23111    }
23112
23113    #[test]
23114    fn traversal_graph_links_backlog_items_to_code_tokens() {
23115        let dir = setup_traversal_project();
23116        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23117        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23118        let helper = resolve_traversal_node(&graph, "helper").unwrap();
23119
23120        assert!(graph.edges.iter().any(|edge| {
23121            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23122        }));
23123    }
23124
23125    #[test]
23126    fn session_hinted_traversal_skips_global_call_edges() {
23127        let dir = setup_traversal_project();
23128        let session = dir.path().join("tasks/software/tsift.md");
23129        let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
23130        let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
23131        let helper = resolve_traversal_node(&bounded, "helper").unwrap();
23132
23133        assert!(bounded.edges.iter().any(|edge| {
23134            edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23135        }));
23136        assert!(
23137            !bounded.edges.iter().any(|edge| edge.relation == "calls"),
23138            "session-hinted graph-db projections should not materialize unrelated global call edges"
23139        );
23140
23141        let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
23142        assert!(
23143            full.edges.iter().any(|edge| edge.relation == "calls"),
23144            "root/full projections still carry the complete indexed call graph"
23145        );
23146    }
23147
23148    #[test]
23149    fn agent_doc_task_path_infers_matching_workspace_scope() {
23150        let dir = tempfile::tempdir().unwrap();
23151        std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
23152        std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
23153        std::fs::write(
23154            dir.path().join(".gitmodules"),
23155            "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
23156        )
23157        .unwrap();
23158        let task = dir.path().join("tasks/software/tsift.md");
23159        std::fs::write(&task, "# tsift\n").unwrap();
23160
23161        let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
23162        let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
23163        let cfg = config::Config::load(dir.path()).unwrap();
23164
23165        assert_eq!(targets.len(), 1);
23166        assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
23167        assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
23168        assert!(
23169            targets[0]
23170                .db_path
23171                .ends_with(".tsift/indexes/tsift/index.db")
23172        );
23173        assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
23174    }
23175
23176    #[test]
23177    fn cargo_package_scope_selector_indexes_package_db() {
23178        let dir = setup_multiplicity_project();
23179        let targets =
23180            resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
23181
23182        assert_eq!(targets.len(), 1);
23183        assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
23184        assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
23185        assert!(
23186            targets[0]
23187                .db_path
23188                .ends_with(".tsift/indexes/cargo/core-lib/index.db")
23189        );
23190
23191        cmd_index(
23192            dir.path(),
23193            false,
23194            false,
23195            false,
23196            false,
23197            true,
23198            false,
23199            Some("core_lib"),
23200            false,
23201            true,
23202            false,
23203            false,
23204            false,
23205            false,
23206        )
23207        .unwrap();
23208        assert!(targets[0].db_path.exists());
23209    }
23210
23211    #[test]
23212    fn path_inference_prefers_nested_cargo_package_without_submodule() {
23213        let dir = setup_multiplicity_project();
23214        let source = dir.path().join("crates/cli-app/src/main.rs");
23215        let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
23216
23217        assert_eq!(targets.len(), 1);
23218        assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
23219        assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
23220    }
23221
23222    #[test]
23223    fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
23224        let dir = setup_multiplicity_project();
23225        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23226        let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
23227        let core = resolve_traversal_node(&graph, "core-lib").unwrap();
23228        let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
23229        let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
23230
23231        assert_eq!(workspace.kind, "cargo_workspace");
23232        assert_eq!(core.kind, "cargo_package");
23233        assert_eq!(
23234            core.properties.get("features"),
23235            Some(&"default".to_string())
23236        );
23237        assert!(graph.edges.iter().any(|edge| {
23238            edge.from == workspace.handle
23239                && edge.to == core.handle
23240                && edge.relation == "contains_package"
23241        }));
23242        assert!(graph.edges.iter().any(|edge| {
23243            edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
23244        }));
23245        assert!(graph.edges.iter().any(|edge| {
23246            edge.from == cli.handle
23247                && edge.to == core.handle
23248                && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
23249        }));
23250    }
23251
23252    #[test]
23253    fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
23254        let dir = setup_multiplicity_project();
23255        let session = dir.path().join("tasks/software/tsift.md");
23256        let report =
23257            build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
23258                .unwrap();
23259
23260        assert!(report.per_target_fail_closed.is_empty());
23261        let candidate = report
23262            .candidates
23263            .iter()
23264            .find(|candidate| candidate.target == "corepkg")
23265            .unwrap();
23266        assert!(
23267            candidate
23268                .owned_files
23269                .iter()
23270                .any(|file| file == "crates/core-lib/Cargo.toml"),
23271            "{:?}",
23272            candidate.owned_files
23273        );
23274    }
23275
23276    #[test]
23277    fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
23278        let dir = setup_traversal_project();
23279        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23280        let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
23281        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23282
23283        assert_eq!(job.kind, "job_packet");
23284        assert!(job.handle.starts_with("gjob-"));
23285        assert!(graph.edges.iter().any(|edge| {
23286            edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
23287        }));
23288
23289        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23290        let jobs = store.nodes_by_kind("job_packet").unwrap();
23291        assert!(
23292            jobs.iter()
23293                .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23294            "expected queued job packet in graph store, got {jobs:?}"
23295        );
23296    }
23297
23298    #[test]
23299    fn traversal_graph_includes_routes_and_handler_edges() {
23300        let dir = tempfile::tempdir().unwrap();
23301        std::fs::write(
23302            dir.path().join("api.py"),
23303            r#"@router.get("/items")
23304def list_items():
23305    return []
23306"#,
23307        )
23308        .unwrap();
23309        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23310        db.apply_changes(dir.path()).unwrap();
23311
23312        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23313        let route = resolve_traversal_node(&graph, "/items").unwrap();
23314        let handler = resolve_traversal_node(&graph, "list_items").unwrap();
23315
23316        assert_eq!(route.kind, "route");
23317        assert!(graph.edges.iter().any(|edge| {
23318            edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
23319        }));
23320    }
23321
23322    #[test]
23323    fn traversal_graph_projects_rust_ast_navigation_edges() {
23324        let dir = tempfile::tempdir().unwrap();
23325        std::fs::write(
23326            dir.path().join("main.rs"),
23327            r#"mod api {
23328    pub fn helper() {}
23329    pub fn handler() { helper(); }
23330}
23331
23332fn main() { api::handler(); }
23333"#,
23334        )
23335        .unwrap();
23336        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23337        db.apply_changes(dir.path()).unwrap();
23338
23339        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23340        let api = resolve_ast_span_node(&graph, "api", "mod");
23341        let helper = resolve_ast_span_node(&graph, "helper", "function");
23342        let handler = resolve_ast_span_node(&graph, "handler", "function");
23343
23344        assert_eq!(helper.kind, "ast_span");
23345        assert!(helper.handle.starts_with("span-"));
23346        assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
23347        assert!(graph.edges.iter().any(|edge| {
23348            edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
23349        }));
23350        assert!(graph.edges.iter().any(|edge| {
23351            edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
23352        }));
23353        assert!(graph.edges.iter().any(|edge| {
23354            edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
23355        }));
23356        assert!(graph.edges.iter().any(|edge| {
23357            edge.from == helper.handle
23358                && edge.to == handler.handle
23359                && edge.relation == "next_sibling"
23360        }));
23361        assert!(graph.edges.iter().any(|edge| {
23362            edge.from == handler.handle
23363                && edge.to == helper.handle
23364                && edge.relation == "previous_sibling"
23365        }));
23366        assert!(graph.edges.iter().any(|edge| {
23367            edge.from == helper.handle
23368                && edge.to == api.handle
23369                && edge.relation == "enclosing_module"
23370        }));
23371        assert!(graph.edges.iter().any(|edge| {
23372            edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
23373        }));
23374
23375        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23376        let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
23377        assert!(
23378            ast_nodes.iter().any(|node| node.id == helper.handle
23379                && node.properties.get("symbol_kind") == Some(&"function".to_string())),
23380            "expected helper AST span in graph store, got {ast_nodes:?}"
23381        );
23382        assert!(
23383            store
23384                .outgoing_edges(&helper.handle, Some("parent"))
23385                .unwrap()
23386                .iter()
23387                .any(|edge| edge.to_id == api.handle),
23388            "expected persisted AST parent edge"
23389        );
23390    }
23391
23392    #[test]
23393    fn traversal_graph_projects_markdown_section_block_edges() {
23394        let dir = tempfile::tempdir().unwrap();
23395        std::fs::write(
23396            dir.path().join("README.md"),
23397            "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
23398        )
23399        .unwrap();
23400        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23401        db.apply_changes(dir.path()).unwrap();
23402
23403        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23404        let guide = resolve_ast_span_node(&graph, "Guide", "heading");
23405        let code = resolve_ast_span_node(&graph, "rust", "code_block");
23406        let embedded = resolve_ast_span_node(&graph, "demo", "function");
23407        let list_item = graph
23408            .nodes
23409            .values()
23410            .find(|node| {
23411                node.kind == "ast_span"
23412                    && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
23413                    && node.properties.get("section_handle") == Some(&guide.handle)
23414            })
23415            .expect("missing Markdown list item AST span");
23416
23417        assert_eq!(
23418            code.properties.get("markdown_block_kind"),
23419            Some(&"fenced_code_block".to_string())
23420        );
23421        assert_eq!(
23422            guide.properties.get("heading_level"),
23423            Some(&"1".to_string())
23424        );
23425        assert_eq!(
23426            embedded.properties.get("embedded"),
23427            Some(&"true".to_string())
23428        );
23429        assert_eq!(
23430            embedded.properties.get("language"),
23431            Some(&"rust".to_string())
23432        );
23433        assert_eq!(
23434            embedded.properties.get("markdown_block_handle"),
23435            Some(&code.handle)
23436        );
23437        assert!(graph.edges.iter().any(|edge| {
23438            edge.from == guide.handle
23439                && edge.to == code.handle
23440                && edge.relation == "contains_markdown_block"
23441        }));
23442        assert!(graph.edges.iter().any(|edge| {
23443            edge.from == code.handle
23444                && edge.to == guide.handle
23445                && edge.relation == "enclosing_section"
23446        }));
23447        assert!(graph.edges.iter().any(|edge| {
23448            edge.from == guide.handle
23449                && edge.to == list_item.handle
23450                && edge.relation == "contains_markdown_block"
23451        }));
23452        assert!(graph.edges.iter().any(|edge| {
23453            edge.from == code.handle
23454                && edge.to == embedded.handle
23455                && edge.relation == "contains_embedded_symbol"
23456        }));
23457        assert!(graph.edges.iter().any(|edge| {
23458            edge.from == embedded.handle
23459                && edge.to == code.handle
23460                && edge.relation == "embedded_in_fence"
23461        }));
23462        assert!(graph.edges.iter().any(|edge| {
23463            edge.from == guide.handle
23464                && edge.to == embedded.handle
23465                && edge.relation == "contains_embedded_code"
23466        }));
23467
23468        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23469        assert!(
23470            store
23471                .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
23472                .unwrap()
23473                .iter()
23474                .any(|edge| edge.to_id == code.handle),
23475            "expected persisted Markdown section/block edge"
23476        );
23477        assert!(
23478            store
23479                .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
23480                .unwrap()
23481                .iter()
23482                .any(|edge| edge.to_id == embedded.handle),
23483            "expected persisted Markdown fence/embedded symbol edge"
23484        );
23485    }
23486
23487    #[test]
23488    fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
23489        let dir = setup_multilingual_ast_navigation_project();
23490        let db =
23491            index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
23492        let symbols = db.all_symbols().unwrap();
23493        let expected_symbols = [
23494            ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
23495            (
23496                "python",
23497                "fixture_nav_python_entry",
23498                "function",
23499                "python.py",
23500            ),
23501            (
23502                "typescript",
23503                "fixture_nav_typescript_entry",
23504                "function",
23505                "typescript.ts",
23506            ),
23507            (
23508                "javascript",
23509                "fixture_nav_javascript_entry",
23510                "function",
23511                "javascript.js",
23512            ),
23513            (
23514                "kotlin",
23515                "fixture_nav_kotlin_entry",
23516                "function",
23517                "kotlin.kt",
23518            ),
23519            ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
23520            ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
23521            ("markdown", "Fixture Section", "heading", "README.md"),
23522            ("markdown", "Fixture step", "list_item", "README.md"),
23523            ("markdown", "python", "code_block", "README.md"),
23524        ];
23525
23526        for (language, name, kind, file) in expected_symbols {
23527            let symbol = symbols
23528                .iter()
23529                .find(|symbol| {
23530                    symbol.language == language
23531                        && symbol.name == name
23532                        && symbol.kind == kind
23533                        && symbol.file.ends_with(file)
23534                })
23535                .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
23536            assert!(
23537                symbol.start_byte.is_some() && symbol.end_byte.is_some(),
23538                "{language} {name} should carry AST byte spans"
23539            );
23540        }
23541
23542        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23543        let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23544        let expected_ast_nodes = [
23545            ("fixture_nav_rust_entry", "function", "rust"),
23546            ("fixture_nav_python_entry", "function", "python"),
23547            ("fixture_nav_typescript_entry", "function", "typescript"),
23548            ("fixture_nav_javascript_entry", "function", "javascript"),
23549            ("fixture_nav_kotlin_entry", "function", "kotlin"),
23550            ("fixture_nav_zig_entry", "function", "zig"),
23551            ("fixture_nav_bash_entry", "function", "bash"),
23552            ("Fixture Section", "heading", "markdown"),
23553            ("Fixture step", "list_item", "markdown"),
23554            ("python", "code_block", "markdown"),
23555            ("fixture_nav_markdown_embedded", "function", "python"),
23556        ];
23557
23558        for (name, kind, language) in expected_ast_nodes {
23559            let node = resolve_ast_span_node(&graph, name, kind);
23560            let repeated = resolve_ast_span_node(&graph_again, name, kind);
23561            assert!(
23562                node.handle.starts_with("span-"),
23563                "{name} handle: {}",
23564                node.handle
23565            );
23566            assert_eq!(
23567                node.handle, repeated.handle,
23568                "{language} {name} handle drifted"
23569            );
23570            assert_eq!(
23571                node.properties.get("language"),
23572                Some(&language.to_string()),
23573                "{name} should keep its language label"
23574            );
23575        }
23576
23577        let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
23578        let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
23579        let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
23580        assert!(graph.edges.iter().any(|edge| {
23581            edge.from == markdown_section.handle
23582                && edge.to == markdown_code.handle
23583                && edge.relation == "contains_markdown_block"
23584        }));
23585        assert!(graph.edges.iter().any(|edge| {
23586            edge.from == markdown_code.handle
23587                && edge.to == embedded.handle
23588                && edge.relation == "contains_embedded_symbol"
23589        }));
23590        assert!(
23591            graph.nodes.len() <= 80,
23592            "multilingual AST fixture should stay bounded, got {} nodes",
23593            graph.nodes.len()
23594        );
23595        assert!(
23596            graph.edges.len() <= 180,
23597            "multilingual AST fixture should stay bounded, got {} edges",
23598            graph.edges.len()
23599        );
23600
23601        let response = empty_search_response(dir.path(), "lexical");
23602        let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
23603        let report = build_relative_search_budget_report(
23604            "fixture_nav_python_entry",
23605            "lexical",
23606            dir.path(),
23607            &response,
23608            &symbol_hits,
23609            ResponseBudget::new(Some(8), Some(120)),
23610            &SearchFacetFilters::default(),
23611        );
23612        let report_again = build_relative_search_budget_report(
23613            "fixture_nav_python_entry",
23614            "lexical",
23615            dir.path(),
23616            &response,
23617            &symbol_hits,
23618            ResponseBudget::new(Some(8), Some(120)),
23619            &SearchFacetFilters::default(),
23620        );
23621
23622        let top = report
23623            .ranked
23624            .first()
23625            .expect("ranked preview should not be empty");
23626        assert_eq!(top.source, "symbol_span");
23627        assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
23628        assert!(top.handle.starts_with("srnk-"));
23629        assert_eq!(top.handle, report_again.ranked[0].handle);
23630        assert!(
23631            top.reasons.iter().any(|reason| reason == "ast_span"),
23632            "expected AST span ranking reason, got {:?}",
23633            top.reasons
23634        );
23635        assert!(report.ranked.len() <= 8);
23636        assert!(report.symbols.len() <= 8);
23637
23638        let symbol = report
23639            .symbols
23640            .iter()
23641            .find(|symbol| symbol.name == "fixture_nav_python_entry")
23642            .expect("missing search preview symbol");
23643        assert_cli_expand_command_parses(&symbol.expand);
23644        let ast = symbol
23645            .ast
23646            .as_ref()
23647            .expect("search symbol should expose AST");
23648        assert_cli_expand_command_parses(&ast.expand.source_window);
23649        assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
23650        assert_cli_expand_command_parses(&ast.expand.symbol_read);
23651
23652        let markdown_hits = db.symbol_search("python", 20).unwrap();
23653        let markdown_report = build_relative_search_budget_report(
23654            "python",
23655            "lexical",
23656            dir.path(),
23657            &response,
23658            &markdown_hits,
23659            ResponseBudget::new(Some(8), Some(120)),
23660            &SearchFacetFilters::default(),
23661        );
23662        let markdown_symbol = markdown_report
23663            .symbols
23664            .iter()
23665            .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
23666            .expect("missing Markdown code-block symbol");
23667        let markdown_ast = markdown_symbol
23668            .ast
23669            .as_ref()
23670            .expect("Markdown code block should expose AST");
23671        assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
23672        assert_eq!(
23673            markdown_ast
23674                .span
23675                .markdown
23676                .as_ref()
23677                .unwrap()
23678                .embedded_symbols[0]
23679                .name,
23680            "fixture_nav_markdown_embedded"
23681        );
23682    }
23683
23684    #[test]
23685    fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
23686        let edges = vec![
23687            TraversalEdge {
23688                from: "origin".to_string(),
23689                to: "aaa_low".to_string(),
23690                relation: "unknown".to_string(),
23691                label: None,
23692                weight: 1,
23693            },
23694            TraversalEdge {
23695                from: "origin".to_string(),
23696                to: "zzz_high".to_string(),
23697                relation: "mentions".to_string(),
23698                label: None,
23699                weight: 1,
23700            },
23701        ];
23702
23703        let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
23704
23705        assert!(handles.contains("origin"));
23706        assert!(handles.contains("zzz_high"), "{handles:?}");
23707        assert!(!handles.contains("aaa_low"), "{handles:?}");
23708    }
23709
23710    #[test]
23711    fn traversal_materializes_provider_neutral_sqlite_graph() {
23712        let dir = setup_traversal_project();
23713        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23714        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23715
23716        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23717        let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
23718        assert!(
23719            backlog_nodes.iter().any(|node| node.id == backlog.handle
23720                && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
23721            "expected materialized backlog node, got {backlog_nodes:?}"
23722        );
23723        assert!(
23724            store
23725                .all_nodes()
23726                .unwrap()
23727                .iter()
23728                .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
23729                    && node.properties.get("projection_version")
23730                        == Some(&GRAPH_PROJECTION_VERSION.to_string())),
23731            "expected projection metadata node"
23732        );
23733        let source_handles = store.nodes_by_kind("source_handle").unwrap();
23734        assert!(
23735            source_handles
23736                .iter()
23737                .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
23738            "expected bounded source_handle rows, got {source_handles:?}"
23739        );
23740        let worker_context = store.nodes_by_kind("worker_context").unwrap();
23741        assert!(
23742            worker_context
23743                .iter()
23744                .any(|node| node.properties.get("target")
23745                    == Some(&"tasks/software/tsift.md".to_string())),
23746            "expected bounded worker_context rows, got {worker_context:?}"
23747        );
23748        let worker_results = store.nodes_by_kind("worker_result").unwrap();
23749        assert!(
23750            worker_results.iter().any(|node| {
23751                node.properties.get("ref_id") == Some(&"kgnv".to_string())
23752                    && node.properties.get("status") == Some(&"completed".to_string())
23753                    && node.properties.get("touched_files") == Some(&"main.rs".to_string())
23754                    && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
23755            }),
23756            "expected worker_result rows, got {worker_results:?}"
23757        );
23758    }
23759
23760    #[test]
23761    fn traversal_projection_materializes_cached_semantic_rows() {
23762        let dir = setup_traversal_project();
23763        seed_traversal_semantic_summaries(dir.path());
23764        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23765        let helper = resolve_traversal_node(&graph, "helper").unwrap();
23766        let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
23767        let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
23768
23769        assert_eq!(concept.kind, "semantic_concept");
23770        assert_eq!(entity.kind, "semantic_entity");
23771        assert!(concept.handle.starts_with("gcon-"));
23772        assert!(entity.handle.starts_with("gent-"));
23773
23774        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23775        assert!(
23776            store
23777                .nodes_by_kind("semantic_concept")
23778                .unwrap()
23779                .iter()
23780                .any(|node| node.label == "semantic extraction"
23781                    && node.properties.contains_key("embedding")),
23782            "expected persisted concept embeddings"
23783        );
23784        assert!(
23785            store
23786                .outgoing_edges(&helper.handle, Some("mentions_concept"))
23787                .unwrap()
23788                .iter()
23789                .any(|edge| edge.to_id == concept.handle),
23790            "expected helper symbol to link to cached summary concept"
23791        );
23792        assert!(
23793            store
23794                .outgoing_edges(
23795                    &semantic_entity_handle("helper", "function"),
23796                    Some("semantic_relation")
23797                )
23798                .unwrap()
23799                .iter()
23800                .any(|edge| edge.to_id == entity.handle
23801                    && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
23802            "expected LLM relationship rows projected into GraphStore"
23803        );
23804    }
23805
23806    #[test]
23807    fn traversal_projection_materializes_tsift_memory_rows() {
23808        let dir = setup_traversal_project();
23809        seed_tsift_memory_graph_db(dir.path());
23810        let memory_db = dir.path().join(".tsift").join("memory.db");
23811        let store = MemoryStore::open_or_create(&memory_db).unwrap();
23812        for summary in ["first closeout", "second closeout"] {
23813            let event = MemoryEvent::new(
23814                MemoryEventKind::ResponseSummary,
23815                "tasks/software/tsift.md",
23816                summary,
23817            )
23818            .with_session_id("tasks/software/tsift.md")
23819            .with_observed_at_unix(1_700_000_100);
23820            store.insert_event(&event).unwrap();
23821        }
23822        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23823        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23824
23825        let native_sources = store
23826            .nodes_by_kind("source_handle")
23827            .unwrap()
23828            .into_iter()
23829            .filter(|node| {
23830                node.properties.get("provider") == Some(&"tsift-memory".to_string())
23831                    && node.properties.get("source_ref")
23832                        == Some(&"tasks/software/tsift.md".to_string())
23833            })
23834            .collect::<Vec<_>>();
23835        assert_eq!(
23836            native_sources.len(),
23837            2,
23838            "same-source native memory events must get distinct source handles"
23839        );
23840
23841        let source = store
23842            .nodes_by_kind("source_handle")
23843            .unwrap()
23844            .into_iter()
23845            .find(|node| {
23846                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23847            })
23848            .expect("expected tsift-memory source handle");
23849        let session = store
23850            .nodes_by_kind("memory_session")
23851            .unwrap()
23852            .into_iter()
23853            .find(|node| {
23854                node.properties.get("provider") == Some(&"tsift-memory".to_string())
23855                    && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
23856            })
23857            .expect("expected tsift-memory session node");
23858        let event = store
23859            .nodes_by_kind("memory_event")
23860            .unwrap()
23861            .into_iter()
23862            .find(|node| {
23863                node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
23864                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
23865                    && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
23866            })
23867            .expect("expected tsift-memory event node");
23868        let concept = store
23869            .nodes_by_kind("semantic_concept")
23870            .unwrap()
23871            .into_iter()
23872            .find(|node| {
23873                node.properties.get("provider") == Some(&"tsift-memory".to_string())
23874                    && node.label.contains("Graph memory adapter")
23875                    && node.properties.contains_key("embedding")
23876            })
23877            .expect("expected tsift-memory semantic concept");
23878
23879        assert!(
23880            store
23881                .outgoing_edges(&session.id, Some("records_memory_source"))
23882                .unwrap()
23883                .iter()
23884                .any(|edge| edge.to_id == source.id),
23885            "expected session to link to source handle"
23886        );
23887        assert!(
23888            store
23889                .outgoing_edges(&session.id, Some("records_memory_event"))
23890                .unwrap()
23891                .iter()
23892                .any(|edge| edge.to_id == event.id),
23893            "expected session to link to memory event"
23894        );
23895        assert!(
23896            store
23897                .outgoing_edges(&event.id, Some("projects_source"))
23898                .unwrap()
23899                .iter()
23900                .any(|edge| edge.to_id == source.id),
23901            "expected memory event to project source handle"
23902        );
23903        assert!(
23904            store
23905                .outgoing_edges(&source.id, Some("mentions_concept"))
23906                .unwrap()
23907                .iter()
23908                .any(|edge| edge.to_id == concept.id),
23909            "expected source handle to seed semantic concept"
23910        );
23911
23912        let related = semantic_related_report_from_store(
23913            dir.path(),
23914            None,
23915            "tsift memory graph adapter",
23916            5,
23917            SemanticRelatedKind::Concept,
23918            &store,
23919        )
23920        .unwrap();
23921        assert!(
23922            related
23923                .items
23924                .iter()
23925                .any(|item| item.handle == concept.id && item.score > 0.0),
23926            "expected semantic query to retrieve tsift-memory concept, got {:?}",
23927            related.items
23928        );
23929
23930        let graph_related = graph_db_report_from_store(
23931            dir.path(),
23932            None,
23933            "sqlite",
23934            GraphDbQuery::Related {
23935                query: "tsift memory graph adapter".to_string(),
23936                kind: SemanticRelatedKind::Concept,
23937                depth: 1,
23938                seed_limit: 5,
23939                limit: 20,
23940            },
23941            &store,
23942            sqlite_graph_freshness(&store, "root").unwrap(),
23943            Vec::new(),
23944        )
23945        .unwrap();
23946        assert_eq!(
23947            graph_related
23948                .readiness
23949                .as_ref()
23950                .map(|readiness| readiness.status.as_str()),
23951            Some("ready"),
23952            "tsift-memory semantic rows should satisfy graph-db related readiness"
23953        );
23954        assert!(
23955            graph_related.nodes.iter().any(|node| {
23956                node.kind == "semantic_concept"
23957                    && node.properties.get("provider") == Some(&"tsift-memory".to_string())
23958            }),
23959            "expected related graph output to include tsift-memory semantic rows"
23960        );
23961    }
23962
23963    #[test]
23964    fn semantic_related_query_uses_persisted_graph_embeddings() {
23965        let dir = setup_traversal_project();
23966        seed_traversal_semantic_summaries(dir.path());
23967        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23968        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23969
23970        let report = semantic_related_report_from_store(
23971            dir.path(),
23972            None,
23973            "graph navigation",
23974            5,
23975            SemanticRelatedKind::Concept,
23976            &store,
23977        )
23978        .unwrap();
23979
23980        assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
23981        assert!(
23982            report
23983                .items
23984                .iter()
23985                .any(|item| item.label == "graph navigation"
23986                    && item.kind == "semantic_concept"
23987                    && item.score > 0.9),
23988            "expected nearest concept match from graph embeddings, got {:?}",
23989            report.items
23990        );
23991    }
23992
23993    #[test]
23994    fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
23995        let dir = setup_traversal_project();
23996        seed_traversal_semantic_summaries(dir.path());
23997        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
23998        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
23999
24000        let report = graph_db_report_from_store(
24001            dir.path(),
24002            None,
24003            "sqlite",
24004            GraphDbQuery::Related {
24005                query: "graph navigation".to_string(),
24006                kind: SemanticRelatedKind::All,
24007                depth: 1,
24008                seed_limit: 2,
24009                limit: 20,
24010            },
24011            &store,
24012            sqlite_graph_freshness(&store, "root").unwrap(),
24013            Vec::new(),
24014        )
24015        .unwrap();
24016
24017        let knowledge = report.knowledge_retrieval.as_ref().unwrap();
24018        assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
24019        assert_eq!(knowledge.seed_kind, "all");
24020        assert_eq!(knowledge.depth, 1);
24021        assert_eq!(
24022            report
24023                .readiness
24024                .as_ref()
24025                .map(|readiness| readiness.status.as_str()),
24026            Some("ready")
24027        );
24028        assert!(
24029            knowledge
24030                .diagnostics
24031                .iter()
24032                .any(|diagnostic| diagnostic.contains("incident"))
24033        );
24034        assert!(
24035            report
24036                .semantic_related
24037                .iter()
24038                .any(|item| item.label == "graph navigation"
24039                    && item.kind == "semantic_concept"
24040                    && item.score > 0.9),
24041            "expected natural-language query to seed the graph navigation concept, got {:?}",
24042            report.semantic_related
24043        );
24044        assert!(
24045            report
24046                .nodes
24047                .iter()
24048                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
24049        );
24050        assert!(
24051            report
24052                .nodes
24053                .iter()
24054                .any(|node| node.kind == "symbol" && node.label == "helper"),
24055            "incident expansion from semantic seed should recover source symbols, got {:?}",
24056            report
24057                .nodes
24058                .iter()
24059                .map(|node| (&node.kind, &node.label))
24060                .collect::<Vec<_>>()
24061        );
24062        assert!(
24063            report
24064                .edges
24065                .iter()
24066                .any(|edge| edge.kind == "mentions_concept")
24067        );
24068        assert!(
24069            report.output_budget.as_ref().is_some_and(|budget| budget
24070                .diagnostics
24071                .iter()
24072                .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
24073            "expected related output budget diagnostics, got {:?}",
24074            report.output_budget
24075        );
24076    }
24077
24078    #[test]
24079    fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
24080        let dir = setup_graph_index();
24081        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24082        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24083
24084        let report = graph_db_report_from_store(
24085            dir.path(),
24086            None,
24087            "sqlite",
24088            GraphDbQuery::Related {
24089                query: "graph navigation".to_string(),
24090                kind: SemanticRelatedKind::All,
24091                depth: 1,
24092                seed_limit: 2,
24093                limit: 20,
24094            },
24095            &store,
24096            sqlite_graph_freshness(&store, "root").unwrap(),
24097            Vec::new(),
24098        )
24099        .unwrap();
24100
24101        let readiness = report.readiness.as_ref().unwrap();
24102        assert_eq!(readiness.status, "blocked");
24103        assert_eq!(readiness.reason, "summary_cache_empty");
24104        assert!(readiness.fail_closed);
24105        assert_eq!(
24106            readiness.next_commands,
24107            vec![
24108                "tsift summarize --extract .".to_string(),
24109                graph_db_refresh_command(dir.path(), None)
24110            ]
24111        );
24112        assert!(
24113            report
24114                .knowledge_retrieval
24115                .as_ref()
24116                .unwrap()
24117                .diagnostics
24118                .iter()
24119                .any(|diagnostic| diagnostic.contains("summary cache empty")
24120                    && diagnostic.contains("graph-db materialized code/session rows")),
24121            "expected related diagnostics to carry readiness gate, got {:?}",
24122            report.knowledge_retrieval.as_ref().unwrap().diagnostics
24123        );
24124    }
24125
24126    #[test]
24127    fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
24128        let mut nodes = vec![
24129            SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
24130            SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
24131        ];
24132        let mut edges = vec![SubstrateGraphEdge::new(
24133            "zzz_high",
24134            "seed",
24135            "mentions_concept",
24136        )];
24137        for idx in 0..24 {
24138            let id = format!("aaa_low_{idx:02}");
24139            nodes.push(SubstrateGraphNode::new(
24140                id.clone(),
24141                "note",
24142                format!("low {idx}"),
24143            ));
24144            edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
24145        }
24146        let mut store = SqliteGraphStore::in_memory().unwrap();
24147        store
24148            .replace_projection(&GraphProjection { nodes, edges })
24149            .unwrap();
24150
24151        let subgraph =
24152            graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
24153
24154        assert_eq!(subgraph.nodes.len(), 3);
24155        assert_eq!(subgraph.nodes[0].id, "seed");
24156        assert_eq!(
24157            subgraph.nodes[1].id, "zzz_high",
24158            "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
24159            subgraph.nodes
24160        );
24161        assert!(subgraph.truncated);
24162        assert!(
24163            subgraph
24164                .diagnostics
24165                .iter()
24166                .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
24167            "{:?}",
24168            subgraph.diagnostics
24169        );
24170        assert!(
24171            subgraph
24172                .diagnostics
24173                .iter()
24174                .any(|diagnostic| diagnostic.contains("skipped")),
24175            "{:?}",
24176            subgraph.diagnostics
24177        );
24178    }
24179
24180    #[test]
24181    fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
24182        let dir = setup_traversal_project();
24183        seed_traversal_semantic_summaries(dir.path());
24184        init_git_repo(dir.path());
24185        let session = dir.path().join("tasks/software/tsift.md");
24186        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24187        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24188        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24189        let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24190            root: dir.path(),
24191            scope: None,
24192            backend: "sqlite",
24193            target: "kgnv",
24194            depth: 4,
24195            limit: 8,
24196            cursor: None,
24197            store: &store,
24198            freshness,
24199            warnings: Vec::new(),
24200        })
24201        .unwrap();
24202        assert!(
24203            evidence
24204                .semantic_related
24205                .iter()
24206                .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
24207            "expected semantic evidence rows, got {:?}",
24208            evidence
24209                .semantic_related
24210                .iter()
24211                .map(|node| (&node.kind, &node.label))
24212                .collect::<Vec<_>>()
24213        );
24214        assert!(
24215            evidence
24216                .output_budget
24217                .as_ref()
24218                .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
24219                    diagnostic.contains("semantic_match")
24220                        && diagnostic.contains("source_handle_coverage")
24221                })),
24222            "expected evidence output budget diagnostics, got {:?}",
24223            evidence.output_budget
24224        );
24225
24226        let cached_diff = diff_digest::compute(
24227            dir.path(),
24228            diff_digest::DiffDigestOptions {
24229                cached: true,
24230                revision: None,
24231                max_parsed_files: None,
24232            },
24233        )
24234        .unwrap();
24235        let impact_report = impact::compute(
24236            dir.path(),
24237            impact::ImpactOptions {
24238                cached: true,
24239                revision: None,
24240                scope: None,
24241                limit: 10,
24242            },
24243        )
24244        .unwrap();
24245        let graph_nodes = store.all_nodes().unwrap();
24246        let graph_index = conflict_matrix_graph_index(&graph_nodes);
24247        let semantic_candidate = conflict_matrix_candidate_from_evidence(
24248            dir.path(),
24249            &evidence,
24250            &graph_index,
24251            &cached_diff,
24252            &impact_report,
24253        );
24254        assert!(semantic_candidate.semantic_dispatch_score > 0);
24255        assert!(
24256            semantic_candidate
24257                .semantic_dispatch_reasons
24258                .iter()
24259                .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
24260            "expected semantic ranking explanations, got {:?}",
24261            semantic_candidate.semantic_dispatch_reasons
24262        );
24263        assert!(
24264            semantic_candidate
24265                .semantic_related
24266                .iter()
24267                .any(|item| item.label == "graph navigation")
24268        );
24269
24270        let mut plain_candidate = semantic_candidate.clone();
24271        plain_candidate.target = "plain".to_string();
24272        plain_candidate.semantic_related.clear();
24273        plain_candidate.semantic_dispatch_score = 0;
24274        plain_candidate.semantic_dispatch_reasons.clear();
24275        let mut ranked = [plain_candidate, semantic_candidate];
24276        ranked.sort_by(|left, right| {
24277            left.risk
24278                .cmp(&right.risk)
24279                .then_with(|| left.risk_score.cmp(&right.risk_score))
24280                .then_with(|| {
24281                    right
24282                        .semantic_dispatch_score
24283                        .cmp(&left.semantic_dispatch_score)
24284                })
24285                .then_with(|| left.target.cmp(&right.target))
24286        });
24287        assert_eq!(ranked[0].target, "kgnv");
24288    }
24289
24290    #[test]
24291    fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
24292        let dir = setup_dependency_dag_project();
24293        let session = dir.path().join("tasks/software/tsift.md");
24294        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24295
24296        assert_eq!(report.contract_version, "dependency-dag-v1");
24297        assert_eq!(
24298            report.targets,
24299            vec![
24300                "prep".to_string(),
24301                "alpha".to_string(),
24302                "beta".to_string(),
24303                "gamma".to_string()
24304            ]
24305        );
24306        assert!(report.edges.iter().any(|edge| {
24307            edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
24308        }));
24309        assert!(report.edges.iter().any(|edge| {
24310            edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
24311        }));
24312        assert!(report.edges.iter().any(|edge| {
24313            edge.from == "alpha"
24314                && edge.to == "beta"
24315                && edge.kind == "shared_resource"
24316                && edge.shared_files.contains(&"main.rs".to_string())
24317                && edge.shared_symbols.contains(&"shared_helper".to_string())
24318        }));
24319        assert!(
24320            !report.cycle_diagnostics.has_cycles,
24321            "{:?}",
24322            report.cycle_diagnostics
24323        );
24324        assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
24325        assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
24326        assert!(
24327            report.replay_commands[0].contains("dependency-dag"),
24328            "{:?}",
24329            report.replay_commands
24330        );
24331
24332        cmd_dependency_dag(
24333            &session,
24334            None,
24335            &["alpha".to_string(), "beta".to_string()],
24336            4,
24337            12,
24338            OutputFormat {
24339                json_output: true,
24340                compact: false,
24341                pretty: false,
24342                terse: false,
24343                ultra_terse: false,
24344                schema: false,
24345                envelope: false,
24346            },
24347        )
24348        .unwrap();
24349    }
24350
24351    #[test]
24352    fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
24353        let dir = setup_dependency_dag_cycle_project();
24354        let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
24355
24356        assert!(report.cycle_diagnostics.has_cycles);
24357        assert_eq!(
24358            report.cycle_diagnostics.blocked_nodes,
24359            vec!["left".to_string(), "right".to_string()]
24360        );
24361        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24362            edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
24363        }));
24364        assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
24365            edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
24366        }));
24367    }
24368
24369    #[test]
24370    fn traversal_projection_queries_match_sqlite_and_convex_stores() {
24371        let dir = setup_traversal_project();
24372        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24373        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
24374
24375        let mut sqlite = SqliteGraphStore::in_memory().unwrap();
24376        sqlite.replace_projection(&projection).unwrap();
24377        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24378        projection.upsert_into(&convex).unwrap();
24379
24380        let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
24381        let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
24382        assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
24383        assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
24384
24385        let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
24386        let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
24387        assert!(convex_graph.edges.iter().any(|edge| {
24388            edge.from == sqlite_backlog.handle
24389                && edge.to == convex_helper.handle
24390                && edge.relation == "mentions"
24391        }));
24392    }
24393
24394    #[test]
24395    fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
24396        let dir = setup_traversal_project();
24397        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24398        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24399        let freshness = sqlite_graph_freshness(&store, "root").unwrap();
24400        assert_eq!(freshness.status, "current");
24401
24402        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24403        let report = graph_db_report_from_store(
24404            dir.path(),
24405            None,
24406            "sqlite",
24407            GraphDbQuery::Neighborhood {
24408                id: backlog.handle.clone(),
24409                depth: 1,
24410                edge_kind: Some("mentions".to_string()),
24411                cursor: None,
24412                limit: None,
24413                property_filters: Vec::new(),
24414            },
24415            &store,
24416            freshness,
24417            Vec::new(),
24418        )
24419        .unwrap();
24420        assert!(
24421            report
24422                .edges
24423                .iter()
24424                .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
24425            "expected backlog mention edge, got {:?}",
24426            report.edges
24427        );
24428        assert!(
24429            report.ranked_neighbors.iter().any(|neighbor| {
24430                neighbor.depth == Some(1)
24431                    && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
24432                    && neighbor.node_id != backlog.handle
24433                    && neighbor.handle_coverage_pct >= 95.0
24434                    && neighbor.duplicate_name_precision >= 0.99
24435            }),
24436            "expected ranked neighborhood neighbors with quality scores, got {:?}",
24437            report.ranked_neighbors
24438        );
24439        assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
24440        let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
24441        assert!(!ranking_gate.ranked_output_default);
24442        assert_eq!(ranking_gate.default_order, "stable_node_id");
24443        assert!(
24444            ranking_gate
24445                .diagnostics
24446                .iter()
24447                .any(|diagnostic| diagnostic.contains("score-capped")),
24448            "{ranking_gate:?}"
24449        );
24450        assert!(
24451            ranking_gate
24452                .required_metrics
24453                .iter()
24454                .any(|metric| metric == "handle_coverage_pct")
24455        );
24456        assert!(
24457            ranking_gate
24458                .required_metrics
24459                .iter()
24460                .any(|metric| metric == "duplicate_name_precision")
24461        );
24462        assert!(
24463            report
24464                .page
24465                .as_ref()
24466                .unwrap()
24467                .diagnostics
24468                .iter()
24469                .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
24470            "expected SQLite neighborhood query plan diagnostics, got {:?}",
24471            report.page.as_ref().unwrap().diagnostics
24472        );
24473        let edges_report = graph_db_report_from_store(
24474            dir.path(),
24475            None,
24476            "sqlite",
24477            GraphDbQuery::Edges {
24478                edge_kind: Some("mentions".to_string()),
24479                cursor: None,
24480                limit: Some(2),
24481                property_filters: Vec::new(),
24482            },
24483            &store,
24484            sqlite_graph_freshness(&store, "root").unwrap(),
24485            Vec::new(),
24486        )
24487        .unwrap();
24488        let edge_id = edges_report
24489            .edges
24490            .first()
24491            .map(|edge| edge.id.clone())
24492            .expect("expected at least one paged mentions edge");
24493        assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
24494        assert_eq!(
24495            edges_report.page.as_ref().unwrap().returned_edges,
24496            edges_report.edges.len()
24497        );
24498
24499        let edge_report = graph_db_report_from_store(
24500            dir.path(),
24501            None,
24502            "sqlite",
24503            GraphDbQuery::Edge {
24504                id: edge_id.clone(),
24505            },
24506            &store,
24507            sqlite_graph_freshness(&store, "root").unwrap(),
24508            Vec::new(),
24509        )
24510        .unwrap();
24511        assert_eq!(
24512            edge_report.edge.as_ref().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
24513            Some(edge_id.clone())
24514        );
24515
24516        let incident_report = graph_db_report_from_store(
24517            dir.path(),
24518            None,
24519            "sqlite",
24520            GraphDbQuery::Incident {
24521                id: backlog.handle.clone(),
24522                edge_kind: Some("mentions".to_string()),
24523                cursor: None,
24524                limit: Some(1),
24525                property_filters: Vec::new(),
24526            },
24527            &store,
24528            sqlite_graph_freshness(&store, "root").unwrap(),
24529            Vec::new(),
24530        )
24531        .unwrap();
24532        assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
24533        assert!(
24534            incident_report
24535                .edges
24536                .iter()
24537                .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
24538            "{:?}",
24539            incident_report.edges
24540        );
24541
24542        let schema_report = graph_db_report_from_store(
24543            dir.path(),
24544            None,
24545            "sqlite",
24546            GraphDbQuery::Schema,
24547            &store,
24548            sqlite_graph_freshness(&store, "root").unwrap(),
24549            Vec::new(),
24550        )
24551        .unwrap();
24552        assert!(
24553            schema_report
24554                .schema
24555                .unwrap()
24556                .operations
24557                .iter()
24558                .any(|operation| operation.command.starts_with("neighborhood"))
24559        );
24560    }
24561
24562    #[test]
24563    fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
24564        let mut nodes = vec![SubstrateGraphNode::new(
24565            "origin",
24566            "backlog",
24567            "#budgeted-neighborhood",
24568        )];
24569        let mut edges = Vec::new();
24570        for idx in 0..32 {
24571            let id = format!("src-{idx:02}");
24572            nodes.push(
24573                SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
24574                    .with_property("source_ref", format!("fixture:{idx}"))
24575                    .with_property("detail", "x".repeat(600)),
24576            );
24577            edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
24578        }
24579        let store = SqliteGraphStore::in_memory().unwrap();
24580        GraphProjection { nodes, edges }
24581            .upsert_into(&store)
24582            .unwrap();
24583
24584        let report = graph_db_report_from_store(
24585            Path::new("."),
24586            None,
24587            "fixture",
24588            GraphDbQuery::Neighborhood {
24589                id: "origin".to_string(),
24590                depth: 1,
24591                edge_kind: None,
24592                cursor: None,
24593                limit: None,
24594                property_filters: Vec::new(),
24595            },
24596            &store,
24597            current_graph_db_freshness(),
24598            Vec::new(),
24599        )
24600        .unwrap();
24601        let budget = report.output_budget.as_ref().unwrap();
24602        assert!(budget.selected_nodes < budget.candidate_nodes);
24603        assert!(
24604            budget.dropped_by_budget.iter().any(|drop| {
24605                drop.item == "node"
24606                    && drop.kind == "source_handle"
24607                    && drop.reason == "per_kind_quota"
24608            }),
24609            "expected source_handle budget drops, got {:?}",
24610            budget.dropped_by_budget
24611        );
24612        assert!(report.page.as_ref().unwrap().truncated);
24613        assert!(
24614            report
24615                .page
24616                .as_ref()
24617                .unwrap()
24618                .diagnostics
24619                .iter()
24620                .any(|diagnostic| diagnostic.contains("budget ranking signals")),
24621            "{:?}",
24622            report.page
24623        );
24624    }
24625
24626    #[test]
24627    fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
24628        let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
24629        let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
24630        for idx in 0..8 {
24631            let id = format!("far-{idx:02}");
24632            nodes.push(SubstrateGraphNode::new(
24633                id.clone(),
24634                "note",
24635                format!("aaa deeper row {idx}"),
24636            ));
24637            depth_by_id.insert(id, 6);
24638        }
24639
24640        let origin_ids = vec!["target".to_string()];
24641        let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
24642            &origin_ids,
24643            &BTreeMap::new(),
24644            nodes,
24645            Vec::new(),
24646            Some(3),
24647            Some(&depth_by_id),
24648            None,
24649        );
24650
24651        assert!(
24652            budgeted.nodes.iter().any(|node| node.id == "near"),
24653            "expected the shallow evidence row to outrank deeper rows, got {:?}",
24654            budgeted
24655                .nodes
24656                .iter()
24657                .map(|node| (&node.id, &node.label))
24658                .collect::<Vec<_>>()
24659        );
24660        assert!(
24661            budgeted.report.dropped_by_budget.iter().any(|drop| {
24662                drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
24663            }),
24664            "expected node quota drops, got {:?}",
24665            budgeted.report.dropped_by_budget
24666        );
24667        assert!(
24668            budgeted
24669                .report
24670                .diagnostics
24671                .iter()
24672                .any(|diagnostic| diagnostic.contains("depth")),
24673            "{:?}",
24674            budgeted.report.diagnostics
24675        );
24676    }
24677
24678    #[test]
24679    fn evidence_pagination_returns_next_cursor_when_truncated() {
24680        let mut nodes = vec![SubstrateGraphNode::new(
24681            "target".to_string(),
24682            "backlog_item",
24683            "target item".to_string(),
24684        )];
24685        let mut depth_by_id = BTreeMap::new();
24686        depth_by_id.insert("target".to_string(), 0);
24687        for idx in 0..20 {
24688            let id = format!("ev-{idx}");
24689            nodes.push(SubstrateGraphNode::new(
24690                id.clone(),
24691                "source_handle",
24692                format!("evidence row {idx}"),
24693            ).with_property("detail", "x".repeat(400)));
24694            depth_by_id.insert(id, 1);
24695        }
24696        let origin_ids = vec!["target".to_string()];
24697        let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
24698            &origin_ids,
24699            &BTreeMap::new(),
24700            nodes.clone(),
24701            Vec::new(),
24702            Some(3),
24703            Some(&depth_by_id),
24704            None,
24705        );
24706        assert!(
24707            first_page.truncated,
24708            "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
24709            first_page.nodes.len(),
24710            first_page.report.candidate_nodes
24711        );
24712        assert!(
24713            first_page.next_cursor.is_some(),
24714            "expected next_cursor when truncated"
24715        );
24716        let cursor = first_page.next_cursor.unwrap();
24717        assert!(
24718            !cursor.is_empty(),
24719            "cursor should be a non-empty node id"
24720        );
24721        let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
24722        let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
24723            &origin_ids,
24724            &BTreeMap::new(),
24725            nodes.clone(),
24726            Vec::new(),
24727            Some(3),
24728            Some(&depth_by_id),
24729            Some(&cursor),
24730        );
24731        let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
24732        let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
24733        assert!(
24734            overlap.is_empty(),
24735            "pages should not overlap, but found shared ids: {overlap:?}"
24736        );
24737        assert!(
24738            second_page.report.diagnostics.iter().any(|d| d.contains("cursor skipped")),
24739            "expected cursor skip diagnostic, got {:?}",
24740            second_page.report.diagnostics
24741        );
24742    }
24743
24744    #[test]
24745    fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
24746        let mut nodes = vec![SubstrateGraphNode::new(
24747            "target".to_string(),
24748            "backlog_item",
24749            "target item".to_string(),
24750        )];
24751        let mut depth_by_id = BTreeMap::new();
24752        depth_by_id.insert("target".to_string(), 0);
24753        for idx in 0..3 {
24754            let id = format!("ev-{idx}");
24755            nodes.push(SubstrateGraphNode::new(
24756                id.clone(),
24757                "source_handle",
24758                format!("evidence row {idx}"),
24759            ));
24760            depth_by_id.insert(id, 1);
24761        }
24762        let origin_ids = vec!["target".to_string()];
24763        let result = graph_db_apply_output_budget_with_depths_and_cursor(
24764            &origin_ids,
24765            &BTreeMap::new(),
24766            nodes,
24767            Vec::new(),
24768            None,
24769            Some(&depth_by_id),
24770            None,
24771        );
24772        assert!(
24773            !result.truncated,
24774            "expected no truncation with small candidate set and default budget"
24775        );
24776        assert!(
24777            result.next_cursor.is_none(),
24778            "expected no next_cursor when not truncated"
24779        );
24780    }
24781
24782    #[test]
24783    fn evidence_pagination_invalid_cursor_returns_first_page() {
24784        let mut nodes = vec![SubstrateGraphNode::new(
24785            "target".to_string(),
24786            "backlog_item",
24787            "target item".to_string(),
24788        )];
24789        let mut depth_by_id = BTreeMap::new();
24790        depth_by_id.insert("target".to_string(), 0);
24791        for idx in 0..5 {
24792            let id = format!("ev-{idx}");
24793            nodes.push(SubstrateGraphNode::new(
24794                id.clone(),
24795                "source_handle",
24796                format!("evidence row {idx}"),
24797            ));
24798            depth_by_id.insert(id, 1);
24799        }
24800        let origin_ids = vec!["target".to_string()];
24801        let result = graph_db_apply_output_budget_with_depths_and_cursor(
24802            &origin_ids,
24803            &BTreeMap::new(),
24804            nodes.clone(),
24805            Vec::new(),
24806            None,
24807            Some(&depth_by_id),
24808            Some("nonexistent-id"),
24809        );
24810        assert!(
24811            result.report.diagnostics.iter().any(|d| d.contains("cursor skipped 0")),
24812            "invalid cursor should skip 0 candidates, got {:?}",
24813            result.report.diagnostics
24814        );
24815    }
24816
24817    #[test]
24818    fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
24819        let dir = setup_traversal_project();
24820        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24821        let graph_db = dir.path().join(".tsift/graph.db");
24822        let _lock = hold_rollback_journal_lock(&graph_db);
24823
24824        let report =
24825            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24826                .unwrap();
24827
24828        assert_eq!(report.status, "current");
24829        assert_eq!(
24830            report.recovery,
24831            Some(index::ReadOnlyRecovery::SnapshotFallback)
24832        );
24833        assert!(
24834            report
24835                .warnings
24836                .iter()
24837                .any(|warning| warning.contains("rollback-journal lock")),
24838            "expected rollback-journal recovery warning, got {:?}",
24839            report.warnings
24840        );
24841    }
24842
24843    #[test]
24844    fn graph_db_status_copies_wal_sidecars_when_locked() {
24845        let dir = setup_traversal_project();
24846        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24847        let graph_db = dir.path().join(".tsift/graph.db");
24848        let _lock = hold_wal_database_lock(&graph_db);
24849
24850        let report =
24851            graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
24852                .unwrap();
24853
24854        assert_eq!(report.status, "current");
24855        assert_eq!(
24856            report.recovery,
24857            Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
24858        );
24859        assert!(
24860            report
24861                .warnings
24862                .iter()
24863                .any(|warning| warning.contains("WAL-aware snapshot fallback")),
24864            "expected WAL recovery warning, got {:?}",
24865            report.warnings
24866        );
24867    }
24868
24869    #[test]
24870    fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
24871        let dir = setup_traversal_project();
24872        let session = dir.path().join("tasks/software/tsift.md");
24873        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
24874        let graph_db = dir.path().join(".tsift/graph.db");
24875        let _lock = hold_rollback_journal_lock(&graph_db);
24876
24877        let result = cmd_graph_db(
24878            &session,
24879            None,
24880            GraphDbBackend::Sqlite,
24881            None,
24882            GraphDbQuery::Evidence {
24883                target: "kgnv".to_string(),
24884                depth: 3,
24885                limit: 8,
24886                cursor: None,
24887            },
24888            OutputFormat {
24889                json_output: false,
24890                compact: true,
24891                pretty: false,
24892                terse: false,
24893                ultra_terse: false,
24894                schema: false,
24895                envelope: false,
24896            },
24897        );
24898
24899        assert!(result.is_ok());
24900    }
24901
24902    fn current_graph_db_freshness() -> GraphDbFreshnessReport {
24903        GraphDbFreshnessReport {
24904            status: "current".to_string(),
24905            fail_closed: false,
24906            projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
24907            content_hash: Some("fixture".to_string()),
24908            source_watermark: None,
24909            diagnostics: Vec::new(),
24910        }
24911    }
24912
24913    #[test]
24914    fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
24915        let dir = setup_traversal_project();
24916        refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24917        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24918        let stale = GraphDbFreshnessReport {
24919            status: "stale".to_string(),
24920            fail_closed: true,
24921            projection_version: Some("old-v0".to_string()),
24922            content_hash: None,
24923            source_watermark: None,
24924            diagnostics: vec!["projection content hash is missing".to_string()],
24925        };
24926
24927        let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
24928            root: dir.path(),
24929            scope: None,
24930            backend: "sqlite",
24931            target: "kgnv",
24932            depth: 3,
24933            limit: 8,
24934            cursor: None,
24935            store: &store,
24936            freshness: stale,
24937            warnings: Vec::new(),
24938        }) {
24939            Ok(_) => panic!("stale graph freshness should fail closed"),
24940            Err(err) => err,
24941        };
24942        let message = err.to_string();
24943        assert!(message.contains("failed closed"), "{message}");
24944        assert!(message.contains("graph-db --path"), "{message}");
24945        assert!(message.contains("refresh --json"), "{message}");
24946    }
24947
24948    fn paged_graph_ids(
24949        store: &impl GraphStore,
24950        cursor: Option<&str>,
24951    ) -> (Vec<String>, GraphDbPageReport) {
24952        let report = graph_db_report_from_store(
24953            Path::new("."),
24954            None,
24955            "fixture",
24956            GraphDbQuery::Kind {
24957                kind: "backlog".to_string(),
24958                cursor: cursor.map(str::to_string),
24959                limit: Some(2),
24960                property_filters: vec!["phase=open".to_string()],
24961            },
24962            store,
24963            current_graph_db_freshness(),
24964            Vec::new(),
24965        )
24966        .unwrap();
24967        (
24968            report.nodes.iter().map(|node| node.id.clone()).collect(),
24969            report.page.unwrap(),
24970        )
24971    }
24972
24973    #[test]
24974    fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
24975        let nodes = (0..5)
24976            .map(|idx| {
24977                let phase = if idx == 1 { "closed" } else { "open" };
24978                SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
24979                    .with_property("phase", phase)
24980            })
24981            .collect::<Vec<_>>();
24982        let projection = GraphProjection {
24983            nodes,
24984            edges: Vec::new(),
24985        };
24986        let sqlite = SqliteGraphStore::in_memory().unwrap();
24987        projection.upsert_into(&sqlite).unwrap();
24988        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
24989        projection.upsert_into(&convex).unwrap();
24990
24991        let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
24992        let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
24993        assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
24994        assert_eq!(sqlite_first_ids, convex_first_ids);
24995        assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
24996        assert!(sqlite_first_page.truncated);
24997        assert_eq!(
24998            sqlite_first_page.returned_nodes,
24999            convex_first_page.returned_nodes
25000        );
25001        assert_eq!(
25002            sqlite_first_page.property_filters,
25003            convex_first_page.property_filters
25004        );
25005        assert!(
25006            sqlite_first_page
25007                .diagnostics
25008                .iter()
25009                .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
25010            "expected SQLite kind query plan diagnostics, got {:?}",
25011            sqlite_first_page.diagnostics
25012        );
25013
25014        let cursor = sqlite_first_page.next_cursor.as_deref();
25015        let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
25016        let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
25017        assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
25018        assert_eq!(sqlite_next_ids, convex_next_ids);
25019        assert_eq!(sqlite_next_page.next_cursor, None);
25020        assert!(!sqlite_next_page.truncated);
25021        assert_eq!(
25022            sqlite_next_page.returned_nodes,
25023            convex_next_page.returned_nodes
25024        );
25025        assert_eq!(
25026            sqlite_next_page.property_filters,
25027            convex_next_page.property_filters
25028        );
25029    }
25030
25031    #[test]
25032    fn traversal_shortest_path_crosses_artifacts_and_symbols() {
25033        let dir = setup_traversal_project();
25034        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25035        let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25036        let main = resolve_traversal_node(&graph, "main").unwrap();
25037
25038        let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
25039        assert_eq!(path.first(), Some(&backlog.handle));
25040        assert_eq!(path.last(), Some(&main.handle));
25041        assert!(
25042            path.len() >= 3,
25043            "expected backlog -> symbol -> main, got {path:?}"
25044        );
25045    }
25046
25047    #[test]
25048    fn traversal_report_recommends_next_bugfix_nodes() {
25049        let dir = setup_traversal_project();
25050        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25051        let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
25052
25053        assert_eq!(report.mode, "neighborhood");
25054        assert!(
25055            report
25056                .recommendations
25057                .iter()
25058                .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
25059            "expected helper recommendation, got {:?}",
25060            report.recommendations
25061        );
25062        assert!(
25063            !report.exploration.source_windows.is_empty(),
25064            "expected exploration source windows"
25065        );
25066        assert!(
25067            report
25068                .exploration
25069                .no_reread_guidance
25070                .contains("avoid whole-file reads")
25071        );
25072    }
25073
25074    #[test]
25075    fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
25076        let dir = setup_traversal_project();
25077        std::thread::sleep(std::time::Duration::from_millis(50));
25078        std::fs::write(
25079            dir.path().join("main.rs"),
25080            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25081        )
25082        .unwrap();
25083
25084        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25085
25086        assert!(
25087            graph
25088                .warnings
25089                .iter()
25090                .any(|warning| warning.contains("index refreshed")
25091                    && warning.contains("graph traversal packet")),
25092            "expected refresh diagnostic, got {:?}",
25093            graph.warnings
25094        );
25095        assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
25096
25097        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
25098        let summary = db.compute_changes(dir.path()).unwrap();
25099        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
25100    }
25101
25102    #[test]
25103    fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
25104        let dir = setup_traversal_project();
25105        let db_path = dir.path().join(".tsift/index.db");
25106        let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
25107        std::thread::sleep(std::time::Duration::from_millis(50));
25108        std::fs::write(
25109            dir.path().join("main.rs"),
25110            "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
25111        )
25112        .unwrap();
25113
25114        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25115        let file = resolve_traversal_node(&graph, "main.rs").unwrap();
25116
25117        assert!(
25118            graph
25119                .warnings
25120                .iter()
25121                .any(|warning| warning.contains("falling back to raw source file nodes")),
25122            "expected raw-source fallback diagnostic, got {:?}",
25123            graph.warnings
25124        );
25125        assert!(
25126            file.detail
25127                .as_deref()
25128                .is_some_and(|detail| detail.contains("raw source fallback")),
25129            "expected raw-source detail, got {:?}",
25130            file.detail
25131        );
25132        assert!(
25133            file.expand.contains("source-read"),
25134            "expected source-read fallback command, got {}",
25135            file.expand
25136        );
25137        assert!(
25138            resolve_traversal_node(&graph, "helper").is_none(),
25139            "stale symbol evidence should be skipped when refresh is blocked"
25140        );
25141    }
25142
25143    #[test]
25144    fn traversal_cmd_supports_json_and_html_outputs() {
25145        let dir = setup_traversal_project();
25146        cmd_traverse(
25147            Some("#kgnv"),
25148            Some("main"),
25149            dir.path(),
25150            None,
25151            1,
25152            50,
25153            TraverseFormat::Json,
25154            false,
25155            false,
25156            false,
25157            None,
25158        )
25159        .unwrap();
25160        cmd_traverse(
25161            None,
25162            None,
25163            dir.path(),
25164            None,
25165            1,
25166            50,
25167            TraverseFormat::Html,
25168            false,
25169            false,
25170            false,
25171            None,
25172        )
25173        .unwrap();
25174    }
25175
25176    #[test]
25177    fn traversal_html_renders_inline_graph_visualization() {
25178        let dir = setup_traversal_project();
25179        seed_traversal_semantic_summaries(dir.path());
25180        let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25181        let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
25182        let html = traversal_report_html(&report).unwrap();
25183
25184        assert!(html.contains("id=\"graph-canvas\""));
25185        assert!(html.contains("semantic_concept"));
25186        assert!(html.contains("graph navigation"));
25187        assert!(html.contains("JSON.parse"));
25188    }
25189
25190    #[test]
25191    fn compact_helpers_trim_scores_and_snippets() {
25192        assert_eq!(format_score(0.12345, true), "0.12");
25193        assert_eq!(format_score(0.12345, false), "0.1235");
25194        let snippet = compact_snippet("    first line with useful context\nsecond");
25195        assert_eq!(snippet.as_deref(), Some("first line with useful context"));
25196    }
25197
25198    #[test]
25199    fn compact_members_caps_list() {
25200        let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
25201            .iter()
25202            .map(|n| graph::CommunityMember::new(*n))
25203            .collect();
25204        assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
25205    }
25206
25207    #[test]
25208    fn abbreviate_kind_maps_common_kinds() {
25209        assert_eq!(abbreviate_kind("function"), "fn");
25210        assert_eq!(abbreviate_kind("method"), "meth");
25211        assert_eq!(abbreviate_kind("class"), "cls");
25212        assert_eq!(abbreviate_kind("interface"), "iface");
25213        assert_eq!(abbreviate_kind("type_alias"), "type");
25214        assert_eq!(abbreviate_kind("data_class"), "data_cls");
25215        assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
25216        assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
25217        assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
25218        assert_eq!(abbreviate_kind("object"), "obj");
25219        assert_eq!(abbreviate_kind("heading"), "h");
25220        assert_eq!(abbreviate_kind("code_block"), "code");
25221        // short kinds pass through
25222        assert_eq!(abbreviate_kind("struct"), "struct");
25223        assert_eq!(abbreviate_kind("trait"), "trait");
25224        assert_eq!(abbreviate_kind("enum"), "enum");
25225        assert_eq!(abbreviate_kind("const"), "const");
25226        assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
25227    }
25228
25229    #[test]
25230    fn abbreviate_match_type_maps_search_types() {
25231        assert_eq!(abbreviate_match_type("exact_name"), "exact");
25232        assert_eq!(abbreviate_match_type("partial_tags"), "partial");
25233        assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
25234        assert_eq!(abbreviate_match_type("other_type"), "other_type");
25235    }
25236
25237    #[test]
25238    fn explain_compact_groups_edges_by_file() {
25239        let edges = vec![
25240            index::StoredEdge {
25241                caller_file: "src/main.rs".to_string(),
25242                caller_name: "main".to_string(),
25243                caller_line: 1,
25244                callee_name: "helper".to_string(),
25245                call_site_line: 2,
25246                tagpath_handle: None,
25247            },
25248            index::StoredEdge {
25249                caller_file: "src/main.rs".to_string(),
25250                caller_name: "main".to_string(),
25251                caller_line: 1,
25252                callee_name: "render".to_string(),
25253                call_site_line: 3,
25254                tagpath_handle: None,
25255            },
25256        ];
25257        let lines = format_edge_groups(&edges, false);
25258        assert_eq!(lines, vec!["  src/main.rs (2): helper, render"]);
25259    }
25260
25261    #[test]
25262    fn search_hit_groups_preserve_file_counts_and_samples() {
25263        let dir = tempfile::tempdir().unwrap();
25264        let root = dir.path();
25265        let main_rs = root.join("src/main.rs");
25266        fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
25267        fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
25268        let freshness = exact_search_file_timestamp(&main_rs);
25269        let hits = vec![
25270            sift::SearchHit {
25271                artifact_id: "a".to_string(),
25272                artifact_kind: sift::ContextArtifactKind::File,
25273                path: main_rs.display().to_string(),
25274                rank: 1,
25275                score: 10.0,
25276                confidence: sift::ScoreConfidence::High,
25277                location: Some("line 3".to_string()),
25278                snippet: "claudescore-3 anchor".to_string(),
25279                provenance: sift::ArtifactProvenance {
25280                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25281                    source: "ripgrep -F".to_string(),
25282                    synthetic: false,
25283                },
25284                freshness: freshness.clone(),
25285                budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
25286            },
25287            sift::SearchHit {
25288                artifact_id: "b".to_string(),
25289                artifact_kind: sift::ContextArtifactKind::File,
25290                path: main_rs.display().to_string(),
25291                rank: 2,
25292                score: 9.0,
25293                confidence: sift::ScoreConfidence::High,
25294                location: Some("line 7".to_string()),
25295                snippet: "claudescore-3 follow-up".to_string(),
25296                provenance: sift::ArtifactProvenance {
25297                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25298                    source: "ripgrep -F".to_string(),
25299                    synthetic: false,
25300                },
25301                freshness: freshness.clone(),
25302                budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
25303            },
25304            sift::SearchHit {
25305                artifact_id: "c".to_string(),
25306                artifact_kind: sift::ContextArtifactKind::File,
25307                path: main_rs.display().to_string(),
25308                rank: 3,
25309                score: 8.0,
25310                confidence: sift::ScoreConfidence::High,
25311                location: Some("line 9".to_string()),
25312                snippet: "claudescore-3 tail".to_string(),
25313                provenance: sift::ArtifactProvenance {
25314                    adapter: sift::AcquisitionAdapterKind::FileSystem,
25315                    source: "ripgrep -F".to_string(),
25316                    synthetic: false,
25317                },
25318                freshness,
25319                budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
25320            },
25321        ];
25322
25323        let groups = group_search_hits(&hits, root, false);
25324        assert_eq!(groups.len(), 1);
25325        assert_eq!(groups[0].path, "src/main.rs");
25326        assert_eq!(groups[0].hits, 3);
25327        assert_eq!(
25328            groups[0].samples,
25329            vec![
25330                "line 3: claudescore-3 anchor".to_string(),
25331                "line 7: claudescore-3 follow-up".to_string()
25332            ]
25333        );
25334        assert!(should_collapse_search_hits(&hits, root, false));
25335    }
25336
25337    #[test]
25338    fn dense_edge_groups_trigger_collapse() {
25339        let edges = vec![
25340            index::StoredEdge {
25341                caller_file: "src/main.rs".to_string(),
25342                caller_name: "main".to_string(),
25343                caller_line: 1,
25344                callee_name: "helper".to_string(),
25345                call_site_line: 2,
25346                tagpath_handle: None,
25347            },
25348            index::StoredEdge {
25349                caller_file: "src/main.rs".to_string(),
25350                caller_name: "beta".to_string(),
25351                caller_line: 5,
25352                callee_name: "helper".to_string(),
25353                call_site_line: 6,
25354                tagpath_handle: None,
25355            },
25356            index::StoredEdge {
25357                caller_file: "src/main.rs".to_string(),
25358                caller_name: "gamma".to_string(),
25359                caller_line: 9,
25360                callee_name: "helper".to_string(),
25361                call_site_line: 10,
25362                tagpath_handle: None,
25363            },
25364        ];
25365        assert!(should_collapse_edge_groups(&edges));
25366    }
25367
25368    // --- workspace indexing ---
25369
25370    fn setup_workspace() -> tempfile::TempDir {
25371        let dir = tempfile::tempdir().unwrap();
25372        let root = dir.path();
25373        std::fs::write(
25374            root.join(".gitmodules"),
25375            r#"[submodule "src/alpha"]
25376	path = src/alpha
25377	url = https://example.com/alpha
25378[submodule "src/beta"]
25379	path = src/beta
25380	url = https://example.com/beta
25381"#,
25382        )
25383        .unwrap();
25384        let alpha = root.join("src/alpha");
25385        let beta = root.join("src/beta");
25386        std::fs::create_dir_all(&alpha).unwrap();
25387        std::fs::create_dir_all(&beta).unwrap();
25388        std::fs::write(
25389            alpha.join("lib.rs"),
25390            "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
25391        )
25392        .unwrap();
25393        std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
25394        dir
25395    }
25396
25397    fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
25398        let dir = tempfile::tempdir().unwrap();
25399        let root = dir.path();
25400        std::fs::write(
25401            root.join(".gitmodules"),
25402            r#"[submodule "pkg/app/foo"]
25403	path = pkg/app/foo
25404	url = https://example.com/pkg-app-foo
25405[submodule "vendor/foo"]
25406	path = vendor/foo
25407	url = https://example.com/vendor-foo
25408"#,
25409        )
25410        .unwrap();
25411        let pkg_foo = root.join("pkg/app/foo");
25412        let vendor_foo = root.join("vendor/foo");
25413        std::fs::create_dir_all(&pkg_foo).unwrap();
25414        std::fs::create_dir_all(&vendor_foo).unwrap();
25415        std::fs::write(
25416            pkg_foo.join("lib.rs"),
25417            "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
25418        )
25419        .unwrap();
25420        std::fs::write(
25421            vendor_foo.join("lib.rs"),
25422            "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
25423        )
25424        .unwrap();
25425        dir
25426    }
25427
25428    #[test]
25429    fn workspace_index_creates_per_submodule_dbs() {
25430        let dir = setup_workspace();
25431        cmd_index(
25432            dir.path(),
25433            false,
25434            false,
25435            false,
25436            false,
25437            false,
25438            true,
25439            None,
25440            false,
25441            false,
25442            false,
25443            false,
25444            false,
25445            false,
25446        )
25447        .unwrap();
25448        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25449        assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
25450    }
25451
25452    #[test]
25453    fn workspace_index_single_submodule() {
25454        let dir = setup_workspace();
25455        cmd_index(
25456            dir.path(),
25457            false,
25458            false,
25459            false,
25460            false,
25461            false,
25462            false,
25463            Some("alpha"),
25464            false,
25465            false,
25466            false,
25467            false,
25468            false,
25469            false,
25470        )
25471        .unwrap();
25472        assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
25473        assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
25474    }
25475
25476    #[test]
25477    fn workspace_index_single_submodule_errors_on_unknown_scope() {
25478        let dir = setup_workspace();
25479
25480        let err = cmd_index(
25481            dir.path(),
25482            false,
25483            false,
25484            false,
25485            false,
25486            false,
25487            false,
25488            Some("missing"),
25489            false,
25490            false,
25491            false,
25492            false,
25493            false,
25494            false,
25495        )
25496        .unwrap_err();
25497
25498        let msg = err.to_string();
25499        assert!(msg.contains("unknown scope `missing`"));
25500        assert!(msg.contains("Available scopes: alpha, beta"));
25501        assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
25502    }
25503
25504    #[test]
25505    fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
25506        let dir = setup_workspace_with_duplicate_leaf_names();
25507        cmd_index(
25508            dir.path(),
25509            false,
25510            false,
25511            false,
25512            false,
25513            false,
25514            true,
25515            None,
25516            false,
25517            false,
25518            false,
25519            false,
25520            false,
25521            false,
25522        )
25523        .unwrap();
25524
25525        assert!(
25526            dir.path()
25527                .join(".tsift/indexes/pkg/app/foo/index.db")
25528                .exists()
25529        );
25530        assert!(
25531            dir.path()
25532                .join(".tsift/indexes/vendor/foo/index.db")
25533                .exists()
25534        );
25535    }
25536
25537    #[test]
25538    fn federated_search_across_submodules() {
25539        let dir = setup_workspace();
25540        cmd_index(
25541            dir.path(),
25542            false,
25543            false,
25544            false,
25545            false,
25546            false,
25547            true,
25548            None,
25549            false,
25550            false,
25551            false,
25552            false,
25553            false,
25554            false,
25555        )
25556        .unwrap();
25557        let (hits, _diag) = federated_symbol_search(
25558            dir.path(),
25559            "alpha_helper",
25560            10,
25561            &TagpathSearchOpts {
25562                no_tagpath: true,
25563                strict: false,
25564            },
25565        )
25566        .unwrap();
25567        assert!(
25568            !hits.is_empty(),
25569            "should find alpha_helper via federated search"
25570        );
25571    }
25572
25573    #[test]
25574    fn federated_search_respects_isolation() {
25575        let dir = setup_workspace();
25576        let tsift_dir = dir.path().join(".tsift");
25577        std::fs::create_dir_all(&tsift_dir).unwrap();
25578        std::fs::write(
25579            tsift_dir.join("config.toml"),
25580            r#"
25581[overrides.alpha]
25582tier = "isolated"
25583"#,
25584        )
25585        .unwrap();
25586        cmd_index(
25587            dir.path(),
25588            false,
25589            false,
25590            false,
25591            false,
25592            false,
25593            true,
25594            None,
25595            false,
25596            false,
25597            false,
25598            false,
25599            false,
25600            false,
25601        )
25602        .unwrap();
25603        let (hits, _diag) = federated_symbol_search(
25604            dir.path(),
25605            "alpha_helper",
25606            10,
25607            &TagpathSearchOpts {
25608                no_tagpath: true,
25609                strict: false,
25610            },
25611        )
25612        .unwrap();
25613        assert!(
25614            hits.is_empty(),
25615            "isolated submodule should not appear in federated search"
25616        );
25617    }
25618
25619    #[test]
25620    fn federated_lexical_search_respects_isolation() {
25621        let dir = setup_workspace();
25622        let tsift_dir = dir.path().join(".tsift");
25623        std::fs::create_dir_all(&tsift_dir).unwrap();
25624        std::fs::write(
25625            tsift_dir.join("config.toml"),
25626            r#"
25627[overrides.alpha]
25628tier = "isolated"
25629"#,
25630        )
25631        .unwrap();
25632        cmd_index(
25633            dir.path(),
25634            false,
25635            false,
25636            false,
25637            false,
25638            false,
25639            true,
25640            None,
25641            false,
25642            false,
25643            false,
25644            false,
25645            false,
25646            false,
25647        )
25648        .unwrap();
25649
25650        let response = federated_sift_search(
25651            dir.path(),
25652            &dir.path().join(".tsift/search-cache"),
25653            "fn",
25654            10,
25655            0,
25656            "lexical",
25657        )
25658        .unwrap();
25659
25660        assert!(
25661            !response.hits.is_empty(),
25662            "shared scopes should still contribute lexical hits"
25663        );
25664        assert!(
25665            response
25666                .hits
25667                .iter()
25668                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25669            "isolated scope should not leak lexical hits: {:?}",
25670            response.hits
25671        );
25672    }
25673
25674    #[test]
25675    fn federated_lexical_search_respects_private_tier() {
25676        let dir = setup_workspace();
25677        let tsift_dir = dir.path().join(".tsift");
25678        std::fs::create_dir_all(&tsift_dir).unwrap();
25679        std::fs::write(
25680            tsift_dir.join("config.toml"),
25681            r#"
25682[overrides.alpha]
25683tier = "private"
25684"#,
25685        )
25686        .unwrap();
25687        cmd_index(
25688            dir.path(),
25689            false,
25690            false,
25691            false,
25692            false,
25693            false,
25694            true,
25695            None,
25696            false,
25697            false,
25698            false,
25699            false,
25700            false,
25701            false,
25702        )
25703        .unwrap();
25704
25705        let response = federated_sift_search(
25706            dir.path(),
25707            &dir.path().join(".tsift/search-cache"),
25708            "fn",
25709            10,
25710            0,
25711            "lexical",
25712        )
25713        .unwrap();
25714
25715        assert!(
25716            !response.hits.is_empty(),
25717            "shared scopes should still contribute lexical hits"
25718        );
25719        assert!(
25720            response
25721                .hits
25722                .iter()
25723                .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
25724            "private scope should not leak lexical hits: {:?}",
25725            response.hits
25726        );
25727    }
25728
25729    #[test]
25730    fn scoped_search_finds_submodule_symbols() {
25731        let dir = setup_workspace();
25732        cmd_index(
25733            dir.path(),
25734            false,
25735            false,
25736            false,
25737            false,
25738            false,
25739            true,
25740            None,
25741            false,
25742            false,
25743            false,
25744            false,
25745            false,
25746            false,
25747        )
25748        .unwrap();
25749        let cfg = config::Config::load(dir.path()).unwrap();
25750        let db_path = cfg.db_path_for(dir.path(), "alpha");
25751        let db = index::IndexDb::open(&db_path).unwrap();
25752        let hits = db.symbol_search("alpha_main", 10).unwrap();
25753        assert!(!hits.is_empty());
25754        assert_eq!(hits[0].name, "alpha_main");
25755    }
25756
25757    #[test]
25758    fn scoped_search_cmd_errors_on_unknown_scope() {
25759        let dir = setup_workspace();
25760
25761        let err = cmd_search(
25762            "alpha_main".to_string(),
25763            Some(dir.path().to_path_buf()),
25764            5,
25765            Some("lexical".to_string()),
25766            Some("missing".to_string()),
25767            false,
25768            false,
25769            false,
25770            0,
25771            false,
25772            false,
25773            false,
25774            false,
25775            false,
25776            false,
25777            false,
25778        )
25779        .unwrap_err();
25780
25781        let msg = err.to_string();
25782        assert!(msg.contains("unknown scope `missing`"));
25783        assert!(msg.contains("Available scopes: alpha, beta"));
25784    }
25785
25786    #[test]
25787    fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
25788        let dir = setup_workspace_with_duplicate_leaf_names();
25789        cmd_index(
25790            dir.path(),
25791            false,
25792            false,
25793            false,
25794            false,
25795            false,
25796            true,
25797            None,
25798            false,
25799            false,
25800            false,
25801            false,
25802            false,
25803            false,
25804        )
25805        .unwrap();
25806
25807        let err = cmd_search(
25808            "vendor_only".to_string(),
25809            Some(dir.path().to_path_buf()),
25810            5,
25811            Some("lexical".to_string()),
25812            Some("foo".to_string()),
25813            false,
25814            false,
25815            false,
25816            0,
25817            false,
25818            false,
25819            false,
25820            false,
25821            false,
25822            false,
25823            false,
25824        )
25825        .unwrap_err();
25826
25827        let msg = err.to_string();
25828        assert!(msg.contains("ambiguous scope `foo`"));
25829        assert!(msg.contains("pkg/app/foo"));
25830        assert!(msg.contains("vendor/foo"));
25831    }
25832
25833    #[test]
25834    fn scoped_graph_query() {
25835        let dir = setup_workspace();
25836        cmd_index(
25837            dir.path(),
25838            false,
25839            false,
25840            false,
25841            false,
25842            false,
25843            true,
25844            None,
25845            false,
25846            false,
25847            false,
25848            false,
25849            false,
25850            false,
25851        )
25852        .unwrap();
25853        let cfg = config::Config::load(dir.path()).unwrap();
25854        let db_path = cfg.db_path_for(dir.path(), "alpha");
25855        let db = index::IndexDb::open(&db_path).unwrap();
25856        let callees = db.callees_of("alpha_main").unwrap();
25857        let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
25858        assert!(names.contains(&"alpha_helper"));
25859    }
25860
25861    fn assert_workspace_query_requires_scope(err: anyhow::Error) {
25862        let msg = err.to_string();
25863        assert!(msg.contains("require `--scope <scope>`"), "{msg}");
25864        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
25865        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
25866        assert!(
25867            !msg.contains("no index found at"),
25868            "workspace query should fail with scope guidance, got: {msg}"
25869        );
25870    }
25871
25872    fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
25873        let msg = err.to_string();
25874        assert!(
25875            msg.contains("requires `--scope <scope>` or `--federated`"),
25876            "{msg}"
25877        );
25878        assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
25879        assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
25880        assert!(
25881            !msg.contains("autoindexing index"),
25882            "workspace search should fail before creating a shared root index: {msg}"
25883        );
25884    }
25885
25886    #[test]
25887    fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
25888        let dir = setup_workspace();
25889        cmd_index(
25890            dir.path(),
25891            false,
25892            false,
25893            false,
25894            false,
25895            false,
25896            true,
25897            None,
25898            false,
25899            false,
25900            false,
25901            false,
25902            false,
25903            false,
25904        )
25905        .unwrap();
25906
25907        let err = cmd_graph(
25908            "alpha_main",
25909            dir.path(),
25910            false,
25911            false,
25912            None,
25913            20,
25914            false,
25915            false,
25916            false,
25917            false,
25918            false,
25919            false,
25920            false,
25921            TagpathSearchOpts::default(),
25922        )
25923        .unwrap_err();
25924
25925        assert_workspace_query_requires_scope(err);
25926    }
25927
25928    #[test]
25929    fn graph_cmd_infers_scope_from_nested_workspace_path() {
25930        let dir = setup_workspace();
25931        cmd_index(
25932            dir.path(),
25933            false,
25934            false,
25935            false,
25936            false,
25937            false,
25938            true,
25939            None,
25940            false,
25941            false,
25942            false,
25943            false,
25944            false,
25945            false,
25946        )
25947        .unwrap();
25948        let nested = dir.path().join("src/alpha/nested");
25949        std::fs::create_dir_all(&nested).unwrap();
25950
25951        let result = cmd_graph(
25952            "alpha_main",
25953            &nested,
25954            false,
25955            false,
25956            None,
25957            20,
25958            false,
25959            false,
25960            false,
25961            false,
25962            false,
25963            false,
25964            false,
25965            TagpathSearchOpts::default(),
25966        );
25967
25968        assert!(result.is_ok());
25969    }
25970
25971    #[test]
25972    fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
25973        let dir = setup_workspace();
25974        cmd_index(
25975            dir.path(),
25976            false,
25977            false,
25978            false,
25979            false,
25980            false,
25981            true,
25982            None,
25983            false,
25984            false,
25985            false,
25986            false,
25987            false,
25988            false,
25989        )
25990        .unwrap();
25991
25992        let err = cmd_communities(
25993            dir.path(),
25994            None,
25995            1,
25996            10,
25997            false,
25998            false,
25999            false,
26000            false,
26001            false,
26002            false,
26003            TagpathSearchOpts::default(),
26004        )
26005        .unwrap_err();
26006
26007        assert_workspace_query_requires_scope(err);
26008    }
26009
26010    #[test]
26011    fn communities_cmd_infers_scope_from_nested_workspace_path() {
26012        let dir = setup_workspace();
26013        cmd_index(
26014            dir.path(),
26015            false,
26016            false,
26017            false,
26018            false,
26019            false,
26020            true,
26021            None,
26022            false,
26023            false,
26024            false,
26025            false,
26026            false,
26027            false,
26028        )
26029        .unwrap();
26030        let nested = dir.path().join("src/alpha/nested");
26031        std::fs::create_dir_all(&nested).unwrap();
26032
26033        let result = cmd_communities(
26034            &nested,
26035            None,
26036            1,
26037            10,
26038            false,
26039            false,
26040            false,
26041            false,
26042            false,
26043            false,
26044            TagpathSearchOpts::default(),
26045        );
26046
26047        assert!(result.is_ok());
26048    }
26049
26050    #[test]
26051    fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
26052        let dir = setup_workspace();
26053        cmd_index(
26054            dir.path(),
26055            false,
26056            false,
26057            false,
26058            false,
26059            false,
26060            true,
26061            None,
26062            false,
26063            false,
26064            false,
26065            false,
26066            false,
26067            false,
26068        )
26069        .unwrap();
26070
26071        let err = cmd_path(
26072            "alpha_main",
26073            "alpha_helper",
26074            dir.path(),
26075            None,
26076            false,
26077            false,
26078            false,
26079            false,
26080            false,
26081            TagpathSearchOpts::default(),
26082        )
26083        .unwrap_err();
26084
26085        assert_workspace_query_requires_scope(err);
26086    }
26087
26088    #[test]
26089    fn path_cmd_infers_scope_from_nested_workspace_path() {
26090        let dir = setup_workspace();
26091        cmd_index(
26092            dir.path(),
26093            false,
26094            false,
26095            false,
26096            false,
26097            false,
26098            true,
26099            None,
26100            false,
26101            false,
26102            false,
26103            false,
26104            false,
26105            false,
26106        )
26107        .unwrap();
26108        let nested = dir.path().join("src/alpha/nested");
26109        std::fs::create_dir_all(&nested).unwrap();
26110
26111        let result = cmd_path(
26112            "alpha_main",
26113            "alpha_helper",
26114            &nested,
26115            None,
26116            false,
26117            false,
26118            false,
26119            false,
26120            false,
26121            TagpathSearchOpts::default(),
26122        );
26123
26124        assert!(result.is_ok());
26125    }
26126
26127    #[test]
26128    fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26129        let dir = setup_graph_index();
26130        let db_path = dir.path().join(".tsift/index.db");
26131        let _lock = hold_rollback_journal_lock(&db_path);
26132
26133        let result = cmd_path(
26134            "main",
26135            "helper",
26136            dir.path(),
26137            None,
26138            false,
26139            false,
26140            false,
26141            false,
26142            false,
26143            TagpathSearchOpts::default(),
26144        );
26145
26146        assert!(result.is_ok());
26147    }
26148
26149    #[test]
26150    fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
26151        let dir = setup_workspace();
26152        cmd_index(
26153            dir.path(),
26154            false,
26155            false,
26156            false,
26157            false,
26158            false,
26159            true,
26160            None,
26161            false,
26162            false,
26163            false,
26164            false,
26165            false,
26166            false,
26167        )
26168        .unwrap();
26169
26170        let err = cmd_explain(
26171            "alpha_main",
26172            dir.path(),
26173            None,
26174            15,
26175            false,
26176            false,
26177            false,
26178            false,
26179            false,
26180            false,
26181            false,
26182            false,
26183        )
26184        .unwrap_err();
26185
26186        assert_workspace_query_requires_scope(err);
26187    }
26188
26189    #[test]
26190    fn explain_cmd_infers_scope_from_nested_workspace_path() {
26191        let dir = setup_workspace();
26192        cmd_index(
26193            dir.path(),
26194            false,
26195            false,
26196            false,
26197            false,
26198            false,
26199            true,
26200            None,
26201            false,
26202            false,
26203            false,
26204            false,
26205            false,
26206            false,
26207        )
26208        .unwrap();
26209        let nested = dir.path().join("src/alpha/nested");
26210        std::fs::create_dir_all(&nested).unwrap();
26211
26212        let result = cmd_explain(
26213            "alpha_main",
26214            &nested,
26215            None,
26216            15,
26217            false,
26218            false,
26219            false,
26220            false,
26221            false,
26222            false,
26223            false,
26224            false,
26225        );
26226
26227        assert!(result.is_ok());
26228    }
26229
26230    #[test]
26231    fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
26232        let dir = setup_graph_index();
26233        let db_path = dir.path().join(".tsift/index.db");
26234        let _lock = hold_rollback_journal_lock(&db_path);
26235
26236        let result = cmd_explain(
26237            "main",
26238            dir.path(),
26239            None,
26240            15,
26241            false,
26242            false,
26243            false,
26244            false,
26245            false,
26246            false,
26247            false,
26248            false,
26249        );
26250
26251        assert!(result.is_ok());
26252    }
26253
26254    // --- community detection ---
26255
26256    #[test]
26257    fn community_detection_groups_related() {
26258        let dir = setup_graph_index();
26259        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26260        let edges = db.all_edges().unwrap();
26261        let result = graph::detect_communities(&edges);
26262        assert!(result.node_count > 0);
26263        assert!(!result.communities.is_empty());
26264    }
26265
26266    #[test]
26267    fn community_cmd_autoindexes_missing_index_by_default() {
26268        let dir = tempfile::tempdir().unwrap();
26269        let result = cmd_communities(
26270            dir.path(),
26271            None,
26272            2,
26273            10,
26274            false,
26275            false,
26276            false,
26277            false,
26278            false,
26279            false,
26280            TagpathSearchOpts::default(),
26281        );
26282
26283        assert!(result.is_ok());
26284        assert!(dir.path().join(".tsift/index.db").exists());
26285    }
26286
26287    // --- path ---
26288
26289    #[test]
26290    fn path_finds_connected_symbols() {
26291        let dir = setup_graph_index();
26292        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26293        let edges = db.all_edges().unwrap();
26294        let result = graph::shortest_path(&edges, "main", "helper");
26295        assert!(result.is_some());
26296        let path = result.unwrap();
26297        assert_eq!(path.hops, 1);
26298    }
26299
26300    #[test]
26301    fn path_returns_none_for_unknown() {
26302        let dir = setup_graph_index();
26303        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26304        let edges = db.all_edges().unwrap();
26305        assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
26306    }
26307
26308    #[test]
26309    fn path_cmd_autoindexes_missing_index_by_default() {
26310        let dir = tempfile::tempdir().unwrap();
26311        let result = cmd_path(
26312            "a",
26313            "b",
26314            dir.path(),
26315            None,
26316            false,
26317            false,
26318            false,
26319            false,
26320            false,
26321            TagpathSearchOpts::default(),
26322        );
26323
26324        assert!(result.is_ok());
26325        assert!(dir.path().join(".tsift/index.db").exists());
26326    }
26327
26328    // --- explain ---
26329
26330    #[test]
26331    fn explain_shows_symbol_info() {
26332        let dir = setup_graph_index();
26333        let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
26334        let symbols = db.symbol_info("main").unwrap();
26335        assert!(!symbols.is_empty());
26336        assert_eq!(symbols[0].name, "main");
26337        assert_eq!(symbols[0].kind, "function");
26338    }
26339
26340    #[test]
26341    fn explain_cmd_autoindexes_missing_index_by_default() {
26342        let dir = tempfile::tempdir().unwrap();
26343        let result = cmd_explain(
26344            "main",
26345            dir.path(),
26346            None,
26347            15,
26348            false,
26349            false,
26350            false,
26351            false,
26352            false,
26353            false,
26354            false,
26355            false,
26356        );
26357
26358        assert!(result.is_ok());
26359        assert!(dir.path().join(".tsift/index.db").exists());
26360    }
26361
26362    fn hold_write_lock(db_path: &std::path::Path) -> Connection {
26363        let conn = Connection::open(db_path).unwrap();
26364        conn.execute_batch("BEGIN IMMEDIATE").unwrap();
26365        conn
26366    }
26367
26368    fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
26369        use fs4::fs_std::FileExt;
26370        use std::io::Write;
26371
26372        let mut file = std::fs::OpenOptions::new()
26373            .read(true)
26374            .write(true)
26375            .create(true)
26376            .truncate(false)
26377            .open(lock_path)
26378            .unwrap();
26379        assert!(file.try_lock_exclusive().unwrap());
26380        writeln!(file, "{}", std::process::id()).unwrap();
26381        file
26382    }
26383
26384    fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
26385        let conn = Connection::open(db_path).unwrap();
26386        conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
26387            .unwrap();
26388        std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
26389        conn
26390    }
26391
26392    fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
26393        let conn = Connection::open(db_path).unwrap();
26394        conn.execute_batch(
26395            "PRAGMA journal_mode=WAL;
26396             PRAGMA wal_autocheckpoint=0;
26397             CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
26398             INSERT INTO wal_lock_probe DEFAULT VALUES;
26399             PRAGMA locking_mode=EXCLUSIVE;
26400             BEGIN EXCLUSIVE;",
26401        )
26402        .unwrap();
26403        assert!(substrate::wal_sidecar_path(db_path).exists());
26404        conn
26405    }
26406
26407    #[test]
26408    fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
26409        let dir = setup_graph_index();
26410        let db_path = dir.path().join(".tsift/index.db");
26411        let _lock = hold_wal_database_lock(&db_path);
26412
26413        let err = cmd_index(
26414            dir.path(),
26415            false,
26416            false,
26417            false,
26418            false,
26419            false,
26420            false,
26421            None,
26422            false,
26423            false,
26424            false,
26425            false,
26426            false,
26427            false,
26428        )
26429        .unwrap_err();
26430
26431        let msg = err.to_string();
26432        assert!(msg.contains("indexing"));
26433        assert!(msg.contains("lock diagnostics:"));
26434        assert!(msg.contains("lock: absent"));
26435        assert!(msg.contains("wal: present") || msg.contains("shm: present"));
26436        assert!(msg.contains("wedged writer holding live WAL sidecars"));
26437        assert!(msg.contains("snapshot fallback"));
26438    }
26439
26440    #[test]
26441    fn search_cmd_succeeds_while_writer_lock_is_held() {
26442        let dir = setup_graph_index();
26443        let db_path = dir.path().join(".tsift/index.db");
26444        let _lock = hold_write_lock(&db_path);
26445
26446        let result = cmd_search(
26447            "main".to_string(),
26448            Some(dir.path().to_path_buf()),
26449            5,
26450            Some("lexical".to_string()),
26451            None,
26452            false,
26453            false,
26454            false,
26455            0,
26456            true,
26457            false,
26458            false,
26459            false,
26460            false,
26461            false,
26462            false,
26463        );
26464
26465        assert!(result.is_ok());
26466    }
26467
26468    #[test]
26469    fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
26470        let dir = setup_graph_index();
26471        let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
26472
26473        let result = cmd_search(
26474            "main".to_string(),
26475            Some(dir.path().to_path_buf()),
26476            5,
26477            Some("lexical".to_string()),
26478            None,
26479            false,
26480            false,
26481            false,
26482            0,
26483            true,
26484            false,
26485            false,
26486            false,
26487            false,
26488            false,
26489            false,
26490        );
26491
26492        assert!(result.is_ok());
26493    }
26494
26495    #[test]
26496    fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
26497        let dir = setup_graph_index();
26498        let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
26499
26500        let result = cmd_search(
26501            "main".to_string(),
26502            Some(dir.path().to_path_buf()),
26503            5,
26504            Some("lexical".to_string()),
26505            None,
26506            false,
26507            false,
26508            false,
26509            0,
26510            true,
26511            false,
26512            false,
26513            false,
26514            false,
26515            false,
26516            false,
26517        );
26518
26519        assert!(result.is_ok());
26520    }
26521
26522    #[test]
26523    fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
26524        let dir = setup_graph_index();
26525        std::thread::sleep(std::time::Duration::from_millis(50));
26526        std::fs::write(
26527            dir.path().join("main.rs"),
26528            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26529        )
26530        .unwrap();
26531
26532        let err = cmd_search(
26533            "helper".to_string(),
26534            Some(dir.path().to_path_buf()),
26535            5,
26536            Some("lexical".to_string()),
26537            None,
26538            false,
26539            false,
26540            false,
26541            0,
26542            false,
26543            false,
26544            false,
26545            false,
26546            false,
26547            false,
26548            false,
26549        )
26550        .unwrap_err();
26551
26552        assert!(err.to_string().contains("search aborted"));
26553        assert!(err.to_string().contains("index is stale"));
26554        assert!(err.to_string().contains("--no-autoindex"));
26555    }
26556
26557    #[test]
26558    fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
26559        let dir = setup_graph_index();
26560        std::thread::sleep(std::time::Duration::from_millis(50));
26561        std::fs::write(
26562            dir.path().join("main.rs"),
26563            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26564        )
26565        .unwrap();
26566        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26567
26568        let err = cmd_search(
26569            "helper".to_string(),
26570            Some(dir.path().to_path_buf()),
26571            5,
26572            Some("lexical".to_string()),
26573            None,
26574            false,
26575            false,
26576            false,
26577            0,
26578            false,
26579            false,
26580            false,
26581            false,
26582            false,
26583            false,
26584            false,
26585        )
26586        .unwrap_err();
26587
26588        assert!(err.to_string().contains("search aborted"));
26589        assert!(err.to_string().contains("index is stale"));
26590        assert!(!err.to_string().contains("database is locked"));
26591    }
26592
26593    #[test]
26594    fn search_cmd_autoindexes_stale_index_by_default() {
26595        let dir = setup_graph_index();
26596        std::thread::sleep(std::time::Duration::from_millis(50));
26597        std::fs::write(
26598            dir.path().join("main.rs"),
26599            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26600        )
26601        .unwrap();
26602
26603        let result = cmd_search(
26604            "helper".to_string(),
26605            Some(dir.path().to_path_buf()),
26606            5,
26607            Some("lexical".to_string()),
26608            None,
26609            false,
26610            false,
26611            true,
26612            0,
26613            false,
26614            false,
26615            false,
26616            false,
26617            false,
26618            false,
26619            false,
26620        );
26621
26622        assert!(result.is_ok());
26623
26624        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26625        let summary = db.compute_changes(dir.path()).unwrap();
26626        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26627    }
26628
26629    #[test]
26630    fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
26631        let dir = setup_graph_index();
26632        std::thread::sleep(std::time::Duration::from_millis(50));
26633        std::fs::write(
26634            dir.path().join("main.rs"),
26635            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26636        )
26637        .unwrap();
26638        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
26639
26640        let result = cmd_search(
26641            "helper".to_string(),
26642            Some(dir.path().to_path_buf()),
26643            5,
26644            Some("lexical".to_string()),
26645            None,
26646            false,
26647            false,
26648            true,
26649            0,
26650            false,
26651            false,
26652            false,
26653            false,
26654            false,
26655            false,
26656            false,
26657        );
26658
26659        assert!(result.is_ok());
26660
26661        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26662        let summary = db.compute_changes(dir.path()).unwrap();
26663        assert_eq!(summary.modified, 1);
26664    }
26665
26666    #[test]
26667    fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
26668        let dir = setup_graph_index();
26669        std::thread::sleep(std::time::Duration::from_millis(50));
26670        std::fs::write(
26671            dir.path().join("main.rs"),
26672            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
26673        )
26674        .unwrap();
26675        let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
26676
26677        let err = cmd_search(
26678            "helper".to_string(),
26679            Some(dir.path().to_path_buf()),
26680            5,
26681            Some("lexical".to_string()),
26682            None,
26683            false,
26684            false,
26685            true,
26686            0,
26687            false,
26688            false,
26689            false,
26690            false,
26691            false,
26692            false,
26693            false,
26694        )
26695        .unwrap_err();
26696
26697        let msg = err.to_string();
26698        assert!(msg.contains("autoindexing index"));
26699        assert!(msg.contains("lock diagnostics:"));
26700        assert!(msg.contains("journal: present"));
26701        assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
26702    }
26703
26704    #[test]
26705    fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
26706        let dir = setup_graph_index();
26707        let nested = dir.path().join("src/nested");
26708        std::fs::create_dir_all(&nested).unwrap();
26709
26710        let result = cmd_search(
26711            "helper".to_string(),
26712            Some(nested.clone()),
26713            5,
26714            Some("lexical".to_string()),
26715            None,
26716            false,
26717            false,
26718            true,
26719            0,
26720            false,
26721            false,
26722            false,
26723            false,
26724            false,
26725            false,
26726            false,
26727        );
26728
26729        assert!(result.is_ok());
26730        assert!(!nested.join(".tsift/index.db").exists());
26731    }
26732
26733    #[test]
26734    fn exact_search_returns_literal_matches() {
26735        let dir = tempfile::tempdir().unwrap();
26736        std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
26737
26738        let response = run_exact_search_with_timeout(dir.path(), "claudescore-3", 5, 0).unwrap();
26739
26740        assert_eq!(response.strategy, "exact");
26741        assert_eq!(response.hits.len(), 1);
26742        assert!(response.hits[0].path.ends_with("notes.txt"));
26743        assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
26744        assert!(response.hits[0].snippet.contains("claudescore-3"));
26745    }
26746
26747    #[test]
26748    fn exact_search_skips_stale_index_precheck() {
26749        let dir = setup_graph_index();
26750        std::thread::sleep(std::time::Duration::from_millis(50));
26751        std::fs::write(
26752            dir.path().join("main.rs"),
26753            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
26754        )
26755        .unwrap();
26756
26757        let result = cmd_search(
26758            "println!(\"updated\")".to_string(),
26759            Some(dir.path().to_path_buf()),
26760            5,
26761            Some("exact".to_string()),
26762            None,
26763            false,
26764            false,
26765            false,
26766            0,
26767            false,
26768            false,
26769            false,
26770            false,
26771            false,
26772            false,
26773            false,
26774        );
26775
26776        assert!(result.is_ok());
26777    }
26778
26779    #[test]
26780    fn workspace_exact_search_does_not_require_shared_root_index() {
26781        let dir = setup_workspace();
26782        cmd_index(
26783            dir.path(),
26784            false,
26785            false,
26786            false,
26787            false,
26788            false,
26789            true,
26790            None,
26791            false,
26792            false,
26793            false,
26794            false,
26795            false,
26796            false,
26797        )
26798        .unwrap();
26799
26800        let result = cmd_search(
26801            "alpha_helper".to_string(),
26802            Some(dir.path().to_path_buf()),
26803            5,
26804            Some("exact".to_string()),
26805            None,
26806            false,
26807            false,
26808            false,
26809            0,
26810            false,
26811            false,
26812            false,
26813            false,
26814            false,
26815            false,
26816            false,
26817        );
26818
26819        assert!(result.is_ok());
26820        assert!(!dir.path().join(".tsift/index.db").exists());
26821    }
26822
26823    #[test]
26824    fn identifier_like_query_prefers_exact_search() {
26825        assert!(query_prefers_exact_search("claudescore-3"));
26826        assert!(query_prefers_exact_search("alpha_helper"));
26827        assert!(query_prefers_exact_search("src/main.rs"));
26828        assert!(query_prefers_exact_search("crate::module"));
26829        assert!(!query_prefers_exact_search("authenticate"));
26830        assert!(!query_prefers_exact_search("fn main"));
26831        assert!(!query_prefers_exact_search("."));
26832    }
26833
26834    #[test]
26835    fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
26836        assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
26837        assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
26838        assert_eq!(
26839            resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
26840            "hybrid"
26841        );
26842    }
26843
26844    #[test]
26845    fn workspace_identifier_like_search_auto_uses_exact_backend() {
26846        let dir = setup_workspace();
26847        cmd_index(
26848            dir.path(),
26849            false,
26850            false,
26851            false,
26852            false,
26853            false,
26854            true,
26855            None,
26856            false,
26857            false,
26858            false,
26859            false,
26860            false,
26861            false,
26862        )
26863        .unwrap();
26864
26865        let result = cmd_search(
26866            "alpha_helper".to_string(),
26867            Some(dir.path().to_path_buf()),
26868            5,
26869            None,
26870            None,
26871            false,
26872            false,
26873            false,
26874            0,
26875            false,
26876            false,
26877            false,
26878            false,
26879            false,
26880            false,
26881            false,
26882        );
26883
26884        assert!(result.is_ok());
26885        assert!(!dir.path().join(".tsift/index.db").exists());
26886    }
26887
26888    #[test]
26889    fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
26890        let dir = setup_graph_index();
26891        let nested = dir.path().join("src/nested");
26892        std::fs::create_dir_all(&nested).unwrap();
26893        std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
26894
26895        let result = cmd_index(
26896            &nested, false, false, false, false, false, false, None, false, false, false, false,
26897            false, false,
26898        );
26899
26900        assert!(result.is_ok());
26901        assert!(dir.path().join(".tsift/index.db").exists());
26902        assert!(!nested.join(".tsift/index.db").exists());
26903    }
26904
26905    #[test]
26906    fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
26907        let dir = setup_workspace();
26908        let nested = dir.path().join("docs/nested");
26909        std::fs::create_dir_all(&nested).unwrap();
26910
26911        let result = cmd_index(
26912            &nested, false, false, false, false, false, true, None, false, false, false, false,
26913            false, false,
26914        );
26915
26916        let cfg = config::Config::load(dir.path()).unwrap();
26917
26918        assert!(result.is_ok());
26919        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
26920        assert!(cfg.db_path_for(dir.path(), "beta").exists());
26921    }
26922
26923    #[test]
26924    fn status_cmd_autoindexes_missing_workspace_scopes() {
26925        let dir = setup_workspace();
26926        let cfg = config::Config::load(dir.path()).unwrap();
26927        let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
26928        let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
26929        let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
26930        alpha_db.apply_changes(&alpha.source_root).unwrap();
26931
26932        let beta_db_path = cfg.db_path_for(dir.path(), "beta");
26933        assert!(!beta_db_path.exists());
26934
26935        cmd_status(
26936            dir.path(),
26937            StatusCommandOptions {
26938                fix: false,
26939                no_fix: false,
26940                json_output: true,
26941                compact: false,
26942                pretty: false,
26943                terse: false,
26944                schema: false,
26945            },
26946        )
26947        .unwrap();
26948
26949        assert!(beta_db_path.exists());
26950        let report = status::check_status(dir.path()).unwrap();
26951        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
26952    }
26953
26954    #[test]
26955    fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
26956        let dir = setup_workspace();
26957        let cfg = config::Config::load(dir.path()).unwrap();
26958
26959        cmd_status(
26960            dir.path(),
26961            StatusCommandOptions {
26962                fix: false,
26963                no_fix: false,
26964                json_output: true,
26965                compact: false,
26966                pretty: false,
26967                terse: false,
26968                schema: false,
26969            },
26970        )
26971        .unwrap();
26972
26973        assert!(cfg.db_path_for(dir.path(), "alpha").exists());
26974        assert!(cfg.db_path_for(dir.path(), "beta").exists());
26975        let report = status::check_status(dir.path()).unwrap();
26976        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
26977    }
26978
26979    #[test]
26980    fn status_cmd_fix_refreshes_stale_index() {
26981        let dir = setup_graph_index();
26982        std::thread::sleep(std::time::Duration::from_millis(50));
26983        std::fs::write(
26984            dir.path().join("main.rs"),
26985            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
26986        )
26987        .unwrap();
26988
26989        let report = status::check_status(dir.path()).unwrap();
26990        assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
26991
26992        cmd_status(
26993            dir.path(),
26994            StatusCommandOptions {
26995                fix: false,
26996                no_fix: false,
26997                json_output: true,
26998                compact: false,
26999                pretty: false,
27000                terse: false,
27001                schema: false,
27002            },
27003        )
27004        .unwrap();
27005
27006        let report = status::check_status(dir.path()).unwrap();
27007        assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
27008    }
27009
27010    #[test]
27011    fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
27012        let dir = setup_graph_index();
27013        let db_path = dir.path().join(".tsift/index.db");
27014        let _lock = hold_wal_database_lock(&db_path);
27015
27016        cmd_status(
27017            dir.path(),
27018            StatusCommandOptions {
27019                fix: false,
27020                no_fix: false,
27021                json_output: true,
27022                compact: false,
27023                pretty: false,
27024                terse: false,
27025                schema: false,
27026            },
27027        )
27028        .unwrap();
27029
27030        let report = status::check_status(dir.path()).unwrap();
27031        assert!(matches!(
27032            report.index,
27033            status::IndexStatus::Fresh {
27034                recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
27035                ..
27036            }
27037        ));
27038        let locks = status::check_locks(dir.path(), None, None).unwrap();
27039        assert!(matches!(
27040            locks.writer_lock,
27041            status::WriterLockStatus::Absent { .. }
27042        ));
27043        assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
27044        assert!(
27045            locks
27046                .recommended_action
27047                .contains("wedged writer holding live WAL sidecars")
27048        );
27049    }
27050
27051    #[test]
27052    fn locks_report_uses_ancestor_project_root_for_nested_paths() {
27053        let dir = setup_graph_index();
27054        let nested = dir.path().join("src/nested");
27055        std::fs::create_dir_all(&nested).unwrap();
27056
27057        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27058        let report = status::check_locks(&root, Some(&nested), None).unwrap();
27059
27060        assert_eq!(report.source_root, dir.path());
27061        assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
27062    }
27063
27064    #[test]
27065    fn workspace_locks_report_infers_scope_from_nested_path() {
27066        let dir = setup_workspace();
27067        cmd_index(
27068            dir.path(),
27069            false,
27070            false,
27071            false,
27072            false,
27073            false,
27074            true,
27075            None,
27076            false,
27077            false,
27078            false,
27079            false,
27080            false,
27081            false,
27082        )
27083        .unwrap();
27084        let nested = dir.path().join("src/alpha/nested");
27085        std::fs::create_dir_all(&nested).unwrap();
27086
27087        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27088        let report = status::check_locks(&root, Some(&nested), None).unwrap();
27089        let cfg = config::Config::load(dir.path()).unwrap();
27090
27091        assert_eq!(report.label, "submodule `alpha` index");
27092        assert_eq!(report.source_root, dir.path().join("src/alpha"));
27093        assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
27094        assert_eq!(
27095            report.reindex_command,
27096            format!("tsift index --submodule alpha {}", dir.path().display())
27097        );
27098    }
27099
27100    #[test]
27101    fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
27102        let dir = setup_workspace();
27103        cmd_index(
27104            dir.path(),
27105            false,
27106            false,
27107            false,
27108            false,
27109            false,
27110            true,
27111            None,
27112            false,
27113            false,
27114            false,
27115            false,
27116            false,
27117            false,
27118        )
27119        .unwrap();
27120
27121        let alpha = dir.path().join("src/alpha/lib.rs");
27122        std::thread::sleep(std::time::Duration::from_millis(50));
27123        std::fs::write(
27124            &alpha,
27125            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27126        )
27127        .unwrap();
27128
27129        let result = cmd_search(
27130            "alpha_helper".to_string(),
27131            Some(dir.path().to_path_buf()),
27132            5,
27133            Some("lexical".to_string()),
27134            Some("alpha".to_string()),
27135            false,
27136            false,
27137            true,
27138            0,
27139            false,
27140            false,
27141            false,
27142            false,
27143            false,
27144            false,
27145            false,
27146        );
27147
27148        assert!(result.is_ok());
27149
27150        let cfg = config::Config::load(dir.path()).unwrap();
27151        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27152        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27153        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27154    }
27155
27156    #[test]
27157    fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27158        let dir = setup_workspace();
27159        cmd_index(
27160            dir.path(),
27161            false,
27162            false,
27163            false,
27164            false,
27165            false,
27166            true,
27167            None,
27168            false,
27169            false,
27170            false,
27171            false,
27172            false,
27173            false,
27174        )
27175        .unwrap();
27176
27177        let alpha = dir.path().join("src/alpha/lib.rs");
27178        std::thread::sleep(std::time::Duration::from_millis(50));
27179        std::fs::write(
27180            &alpha,
27181            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27182        )
27183        .unwrap();
27184
27185        let cfg = config::Config::load(dir.path()).unwrap();
27186        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27187
27188        let err = cmd_search(
27189            "alpha_helper".to_string(),
27190            Some(dir.path().to_path_buf()),
27191            5,
27192            Some("lexical".to_string()),
27193            Some("alpha".to_string()),
27194            false,
27195            false,
27196            false,
27197            0,
27198            false,
27199            false,
27200            false,
27201            false,
27202            false,
27203            false,
27204            false,
27205        )
27206        .unwrap_err();
27207
27208        assert!(err.to_string().contains("search aborted"));
27209        assert!(err.to_string().contains("submodule `alpha` index"));
27210        assert!(!err.to_string().contains("database is locked"));
27211    }
27212
27213    #[test]
27214    fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
27215        let dir = setup_workspace();
27216        cmd_index(
27217            dir.path(),
27218            false,
27219            false,
27220            false,
27221            false,
27222            false,
27223            true,
27224            None,
27225            false,
27226            false,
27227            false,
27228            false,
27229            false,
27230            false,
27231        )
27232        .unwrap();
27233
27234        let alpha = dir.path().join("src/alpha/lib.rs");
27235        std::thread::sleep(std::time::Duration::from_millis(50));
27236        std::fs::write(
27237            &alpha,
27238            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27239        )
27240        .unwrap();
27241
27242        let result = cmd_search(
27243            "alpha_helper".to_string(),
27244            Some(dir.path().to_path_buf()),
27245            5,
27246            Some("lexical".to_string()),
27247            None,
27248            true,
27249            false,
27250            true,
27251            0,
27252            false,
27253            false,
27254            false,
27255            false,
27256            false,
27257            false,
27258            false,
27259        );
27260
27261        assert!(result.is_ok());
27262
27263        let cfg = config::Config::load(dir.path()).unwrap();
27264        let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
27265        let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
27266        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27267    }
27268
27269    #[test]
27270    fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
27271        let dir = setup_workspace();
27272        cmd_index(
27273            dir.path(),
27274            false,
27275            false,
27276            false,
27277            false,
27278            false,
27279            true,
27280            None,
27281            false,
27282            false,
27283            false,
27284            false,
27285            false,
27286            false,
27287        )
27288        .unwrap();
27289
27290        let alpha = dir.path().join("src/alpha/lib.rs");
27291        std::thread::sleep(std::time::Duration::from_millis(50));
27292        std::fs::write(
27293            &alpha,
27294            "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
27295        )
27296        .unwrap();
27297
27298        let cfg = config::Config::load(dir.path()).unwrap();
27299        let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
27300
27301        let err = cmd_search(
27302            "alpha_helper".to_string(),
27303            Some(dir.path().to_path_buf()),
27304            5,
27305            Some("lexical".to_string()),
27306            None,
27307            true,
27308            false,
27309            false,
27310            30,
27311            false,
27312            false,
27313            false,
27314            false,
27315            false,
27316            false,
27317            false,
27318        )
27319        .unwrap_err();
27320
27321        assert!(err.to_string().contains("stale"));
27322        assert!(err.to_string().contains("submodule `alpha` index"));
27323        assert!(!err.to_string().contains("database is locked"));
27324    }
27325
27326    #[test]
27327    fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
27328        let dir = setup_workspace();
27329        cmd_index(
27330            dir.path(),
27331            false,
27332            false,
27333            false,
27334            false,
27335            false,
27336            true,
27337            None,
27338            false,
27339            false,
27340            false,
27341            false,
27342            false,
27343            false,
27344        )
27345        .unwrap();
27346
27347        let err = cmd_search(
27348            "alpha_helper".to_string(),
27349            Some(dir.path().to_path_buf()),
27350            5,
27351            Some("lexical".to_string()),
27352            None,
27353            false,
27354            false,
27355            true,
27356            0,
27357            false,
27358            false,
27359            false,
27360            false,
27361            false,
27362            false,
27363            false,
27364        )
27365        .unwrap_err();
27366
27367        assert_workspace_search_requires_explicit_target(err);
27368        assert!(!dir.path().join(".tsift/index.db").exists());
27369    }
27370
27371    #[test]
27372    fn workspace_search_cmd_infers_scope_from_nested_path() {
27373        let dir = setup_workspace();
27374        cmd_index(
27375            dir.path(),
27376            false,
27377            false,
27378            false,
27379            false,
27380            false,
27381            true,
27382            None,
27383            false,
27384            false,
27385            false,
27386            false,
27387            false,
27388            false,
27389        )
27390        .unwrap();
27391        let nested = dir.path().join("src/alpha/nested");
27392        std::fs::create_dir_all(&nested).unwrap();
27393
27394        let result = cmd_search(
27395            "alpha_helper".to_string(),
27396            Some(nested),
27397            5,
27398            Some("lexical".to_string()),
27399            None,
27400            false,
27401            false,
27402            false,
27403            0,
27404            false,
27405            false,
27406            false,
27407            false,
27408            false,
27409            false,
27410            false,
27411        );
27412
27413        assert!(result.is_ok());
27414    }
27415
27416    #[test]
27417    fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
27418        let dir = setup_workspace_with_duplicate_leaf_names();
27419        cmd_index(
27420            dir.path(),
27421            false,
27422            false,
27423            false,
27424            false,
27425            false,
27426            true,
27427            None,
27428            false,
27429            false,
27430            false,
27431            false,
27432            false,
27433            false,
27434        )
27435        .unwrap();
27436        let nested = dir.path().join("vendor/foo/nested");
27437        std::fs::create_dir_all(&nested).unwrap();
27438
27439        let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
27440        let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
27441        let cfg = config::Config::load(dir.path()).unwrap();
27442
27443        assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
27444    }
27445
27446    #[test]
27447    fn graph_cmd_succeeds_while_writer_lock_is_held() {
27448        let dir = setup_graph_index();
27449        let db_path = dir.path().join(".tsift/index.db");
27450        let _lock = hold_write_lock(&db_path);
27451
27452        let result = cmd_graph(
27453            "main",
27454            dir.path(),
27455            false,
27456            false,
27457            None,
27458            20,
27459            false,
27460            true,
27461            false,
27462            false,
27463            false,
27464            false,
27465            false,
27466            TagpathSearchOpts::default(),
27467        );
27468
27469        assert!(result.is_ok());
27470    }
27471
27472    #[test]
27473    fn graph_cmd_autoindexes_stale_index_by_default() {
27474        let dir = setup_graph_index();
27475        std::thread::sleep(std::time::Duration::from_millis(50));
27476        std::fs::write(
27477            dir.path().join("main.rs"),
27478            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
27479        )
27480        .unwrap();
27481
27482        let result = cmd_graph(
27483            "helper",
27484            dir.path(),
27485            true,
27486            false,
27487            None,
27488            20,
27489            false,
27490            true,
27491            false,
27492            false,
27493            false,
27494            false,
27495            false,
27496            TagpathSearchOpts::default(),
27497        );
27498
27499        assert!(result.is_ok());
27500        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27501        let summary = db.compute_changes(dir.path()).unwrap();
27502        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27503    }
27504
27505    #[test]
27506    fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27507        let dir = setup_graph_index();
27508        let db_path = dir.path().join(".tsift/index.db");
27509        let _lock = hold_rollback_journal_lock(&db_path);
27510
27511        let result = cmd_graph(
27512            "main",
27513            dir.path(),
27514            false,
27515            false,
27516            None,
27517            20,
27518            false,
27519            true,
27520            false,
27521            false,
27522            false,
27523            false,
27524            false,
27525            TagpathSearchOpts::default(),
27526        );
27527
27528        assert!(result.is_ok());
27529    }
27530
27531    #[test]
27532    fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
27533        let dir = setup_graph_index();
27534        let nested = dir.path().join("src/nested");
27535        std::fs::create_dir_all(&nested).unwrap();
27536
27537        let result = cmd_graph(
27538            "helper",
27539            &nested,
27540            true,
27541            false,
27542            None,
27543            20,
27544            false,
27545            false,
27546            false,
27547            false,
27548            false,
27549            false,
27550            false,
27551            TagpathSearchOpts::default(),
27552        );
27553
27554        assert!(result.is_ok());
27555    }
27556
27557    #[test]
27558    fn communities_cmd_succeeds_while_writer_lock_is_held() {
27559        let dir = setup_graph_index();
27560        let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27561
27562        let result = cmd_communities(
27563            dir.path(),
27564            None,
27565            1,
27566            10,
27567            false,
27568            false,
27569            false,
27570            false,
27571            false,
27572            false,
27573            TagpathSearchOpts::default(),
27574        );
27575
27576        assert!(result.is_ok());
27577    }
27578
27579    #[test]
27580    fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27581        let dir = setup_graph_index();
27582        let db_path = dir.path().join(".tsift/index.db");
27583        let _lock = hold_rollback_journal_lock(&db_path);
27584
27585        let result = cmd_communities(
27586            dir.path(),
27587            None,
27588            1,
27589            10,
27590            false,
27591            false,
27592            false,
27593            false,
27594            false,
27595            false,
27596            TagpathSearchOpts::default(),
27597        );
27598
27599        assert!(result.is_ok());
27600    }
27601
27602    #[test]
27603    fn lint_finds_entities_from_project_root_index_db() {
27604        let dir = tempfile::tempdir().unwrap();
27605        std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
27606        std::fs::write(
27607            dir.path().join("README.md"),
27608            "alpha_helper should be backticked.\n",
27609        )
27610        .unwrap();
27611        cmd_index(
27612            dir.path(),
27613            false,
27614            false,
27615            false,
27616            false,
27617            false,
27618            false,
27619            None,
27620            false,
27621            false,
27622            false,
27623            false,
27624            false,
27625            false,
27626        )
27627        .unwrap();
27628
27629        let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
27630            .unwrap()
27631            .unwrap();
27632        let entities = lint::collect_entities_from_index_path(&root).unwrap();
27633        let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
27634
27635        assert!(
27636            result
27637                .annotations
27638                .iter()
27639                .any(|ann| ann.text == "alpha_helper")
27640        );
27641    }
27642
27643    // --- search timeout ---
27644
27645    #[test]
27646    fn search_direct_runs_ok() {
27647        let dir = tempfile::tempdir().unwrap();
27648        let search_dir = dir.path().to_path_buf();
27649        let cache_dir = search_dir.join(".tsift/search-cache");
27650        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27651        let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical");
27652        assert!(result.is_ok(), "direct search should succeed");
27653        assert!(
27654            cache_dir.exists(),
27655            "search should create the configured cache dir"
27656        );
27657    }
27658
27659    #[test]
27660    fn search_timeout_zero_disables_timeout() {
27661        let dir = tempfile::tempdir().unwrap();
27662        let search_dir = dir.path().to_path_buf();
27663        let cache_dir = search_dir.join(".tsift/search-cache");
27664        std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
27665        let result = run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[]);
27666        assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
27667        assert!(
27668            cache_dir.exists(),
27669            "timeout=0 should keep using the stable search cache dir"
27670        );
27671    }
27672
27673    #[test]
27674    fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
27675        let dir = tempfile::tempdir().unwrap();
27676        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
27677        cmd_index(
27678            dir.path(),
27679            false,
27680            false,
27681            false,
27682            false,
27683            false,
27684            false,
27685            None,
27686            false,
27687            false,
27688            false,
27689            false,
27690            false,
27691            false,
27692        )
27693        .unwrap();
27694        let db_path = dir.path().join(".tsift/index.db");
27695        std::fs::remove_file(&db_path).unwrap();
27696        let search_target = SearchIndexTarget {
27697            label: "index".to_string(),
27698            db_path,
27699            source_root: dir.path().to_path_buf(),
27700            scope_name: None,
27701            reindex_cmd: format!("tsift index {}", dir.path().display()),
27702        };
27703
27704        let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
27705
27706        assert!(message.contains("timed out after 1s"));
27707        assert!(message.contains("index is missing"));
27708        assert!(message.contains("Run `tsift index"));
27709        assert!(!message.contains("search root looks fresh"));
27710    }
27711
27712    #[test]
27713    fn search_worker_output_path_uses_json_suffix() {
27714        let path = next_search_worker_output_path();
27715        assert!(path.extension().is_some_and(|ext| ext == "json"));
27716    }
27717
27718    // --- index quiet mode ---
27719
27720    #[test]
27721    fn index_quiet_suppresses_file_list() {
27722        let dir = setup_graph_index();
27723        let result = cmd_index(
27724            dir.path(),
27725            false,
27726            true,
27727            false,
27728            false,
27729            true,
27730            false,
27731            None,
27732            false,
27733            false,
27734            false,
27735            false,
27736            false,
27737            false,
27738        );
27739        assert!(result.is_ok());
27740    }
27741
27742    #[test]
27743    fn index_exit_code_implies_quiet() {
27744        let dir = setup_graph_index();
27745        let result = cmd_index(
27746            dir.path(),
27747            false,
27748            true,
27749            false,
27750            false,
27751            false,
27752            false,
27753            None,
27754            false,
27755            false,
27756            false,
27757            false,
27758            false,
27759            false,
27760        );
27761        assert!(result.is_ok());
27762    }
27763
27764    #[test]
27765    fn index_quiet_json_omits_changes() {
27766        let dir = setup_graph_index();
27767        let result = cmd_index(
27768            dir.path(),
27769            false,
27770            true,
27771            false,
27772            false,
27773            true,
27774            false,
27775            None,
27776            true,
27777            false,
27778            false,
27779            false,
27780            false,
27781            false,
27782        );
27783        assert!(result.is_ok());
27784    }
27785
27786    #[test]
27787    fn cli_workflow_defaults_to_search_topic() {
27788        let cli = parse_cli(["tsift", "workflow"]);
27789        match cli.command {
27790            Some(Commands::Workflow { topic, json }) => {
27791                assert_eq!(topic, "search");
27792                assert!(!json);
27793            }
27794            _ => panic!("expected Workflow command"),
27795        }
27796    }
27797
27798    #[test]
27799    fn search_workflow_recipe_preserves_handles_across_expansions() {
27800        let recipe = workflow::search_workflow_recipe();
27801        let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
27802        assert_eq!(
27803            step_names,
27804            vec![
27805                "exact-anchor",
27806                "semantic-search",
27807                "explain-symbol",
27808                "summarize-selection",
27809                "digest-expansion"
27810            ]
27811        );
27812        assert!(
27813            recipe
27814                .handle_contract
27815                .iter()
27816                .any(|item| item.contains("originating command"))
27817        );
27818        assert!(
27819            recipe.steps[1]
27820                .preserves
27821                .iter()
27822                .any(|item| item.contains("sfam-*"))
27823        );
27824        assert!(
27825            recipe.steps[2]
27826                .preserves
27827                .iter()
27828                .any(|item| item.contains("ecall-*"))
27829        );
27830        assert!(
27831            recipe.steps[4]
27832                .preserves
27833                .iter()
27834                .any(|item| item.contains("artifact handles"))
27835        );
27836    }
27837
27838    // --- JSON compact vs pretty ---
27839
27840    #[test]
27841    fn to_json_compact_default() {
27842        let val = serde_json::json!({"a": 1, "b": [2, 3]});
27843        let compact = to_json(&val, false, false).unwrap();
27844        assert!(!compact.contains('\n'));
27845        assert!(
27846            compact.contains("\"a\":1")
27847                || compact.contains("\"a\": 1")
27848                || compact.contains("\"a\":")
27849        );
27850    }
27851
27852    #[test]
27853    fn to_json_pretty_indents() {
27854        let val = serde_json::json!({"a": 1, "b": [2, 3]});
27855        let pretty = to_json(&val, true, false).unwrap();
27856        assert!(pretty.contains('\n'));
27857        assert!(pretty.contains("  "));
27858    }
27859
27860    #[test]
27861    fn to_json_compact_is_shorter() {
27862        let val =
27863            serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
27864        let compact = to_json(&val, false, false).unwrap();
27865        let pretty = to_json(&val, true, false).unwrap();
27866        assert!(compact.len() < pretty.len());
27867    }
27868
27869    #[test]
27870    fn terse_renames_keys() {
27871        let val =
27872            serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
27873        let result = to_json(&val, false, true).unwrap();
27874        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27875        assert!(parsed["_s"].is_object());
27876        let d = &parsed["d"];
27877        assert_eq!(d["cf"], "a.rs");
27878        assert_eq!(d["cn"], "main");
27879        assert_eq!(d["csl"], 10);
27880    }
27881
27882    #[test]
27883    fn terse_schema_only_includes_used_keys() {
27884        let val = serde_json::json!({"name": "test", "score": 0.5});
27885        let result = to_json(&val, false, true).unwrap();
27886        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27887        let schema = parsed["_s"].as_object().unwrap();
27888        assert_eq!(schema["n"], "name");
27889        assert_eq!(schema["sc"], "score");
27890        assert!(!schema.contains_key("cf"));
27891    }
27892
27893    #[test]
27894    fn terse_nested_arrays() {
27895        let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
27896        let result = to_json(&val, false, true).unwrap();
27897        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27898        let d = &parsed["d"];
27899        assert_eq!(d["crs"][0]["cn"], "a");
27900        assert_eq!(d["crs"][0]["cf"], "b.rs");
27901    }
27902
27903    #[test]
27904    fn terse_preserves_unknown_keys() {
27905        let val = serde_json::json!({"custom_field": "value", "name": "test"});
27906        let result = to_json(&val, false, true).unwrap();
27907        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27908        let d = &parsed["d"];
27909        assert_eq!(d["custom_field"], "value");
27910        assert_eq!(d["n"], "test");
27911    }
27912
27913    // --- ultra-terse ---
27914
27915    #[test]
27916    fn ultra_terse_strips_properties_from_graph_nodes() {
27917        let val = serde_json::json!({
27918            "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
27919        });
27920        let result = to_json_schema(&val, false, true, true, false).unwrap();
27921        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27922        let node = &parsed["d"]["nodes"][0];
27923        assert_eq!(node["id"], "fn:main");
27924        assert_eq!(node["k"], "fn");
27925        assert_eq!(node["n"], "main");
27926        assert!(node.get("properties").is_none());
27927    }
27928
27929    #[test]
27930    fn ultra_terse_strips_properties_from_graph_edges() {
27931        let val = serde_json::json!({
27932            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
27933        });
27934        let result = to_json_schema(&val, false, true, true, false).unwrap();
27935        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27936        let edge = &parsed["d"]["edges"][0];
27937        assert_eq!(edge["from_id"], "a");
27938        assert_eq!(edge["to_id"], "b");
27939        assert_eq!(edge["k"], "c");
27940        assert!(edge.get("properties").is_none());
27941    }
27942
27943    #[test]
27944    fn ultra_terse_abbreviates_edge_kinds() {
27945        let val = serde_json::json!({
27946            "edges": [
27947                {"from_id": "a", "to_id": "b", "kind": "defines"},
27948                {"from_id": "a", "to_id": "c", "kind": "contains"},
27949                {"from_id": "a", "to_id": "d", "kind": "imports"},
27950                {"from_id": "a", "to_id": "e", "kind": "mentions"},
27951                {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
27952                {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
27953                {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
27954                {"from_id": "a", "to_id": "i", "kind": "uses"},
27955                {"from_id": "a", "to_id": "j", "kind": "parent"},
27956                {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
27957            ]
27958        });
27959        let result = to_json_schema(&val, false, true, true, false).unwrap();
27960        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27961        let edges = &parsed["d"]["edges"].as_array().unwrap();
27962        assert_eq!(edges[0]["k"], "d");
27963        assert_eq!(edges[1]["k"], "ct");
27964        assert_eq!(edges[2]["k"], "i");
27965        assert_eq!(edges[3]["k"], "m");
27966        assert_eq!(edges[4]["k"], "sr");
27967        assert_eq!(edges[5]["k"], "bt");
27968        assert_eq!(edges[6]["k"], "sctx");
27969        assert_eq!(edges[7]["k"], "u");
27970        assert_eq!(edges[8]["k"], "p");
27971        assert_eq!(edges[9]["k"], "unknown_edge");
27972    }
27973
27974    #[test]
27975    fn ultra_terse_strips_provenance_freshness_from_edges() {
27976        let val = serde_json::json!({
27977            "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
27978        });
27979        let result = to_json_schema(&val, false, true, true, false).unwrap();
27980        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27981        let edge = &parsed["d"]["edges"][0];
27982        assert!(edge.get("provenance").is_none());
27983        assert!(edge.get("freshness").is_none());
27984        assert_eq!(edge["k"], "c");
27985    }
27986
27987    #[test]
27988    fn ultra_terse_truncates_snippets() {
27989        let long_snippet = "x".repeat(120);
27990        let val = serde_json::json!({"snippet": long_snippet});
27991        let result = to_json_schema(&val, false, true, true, false).unwrap();
27992        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
27993        let snipped = parsed["d"]["sn"].as_str().unwrap();
27994        assert_eq!(snipped.len(), 80);
27995        assert!(snipped.ends_with("..."));
27996    }
27997
27998    #[test]
27999    fn ultra_terse_truncates_abbreviated_snippet_key() {
28000        let long_snippet = "y".repeat(100);
28001        let val = serde_json::json!({"snippet": long_snippet});
28002        let result = to_json_schema(&val, false, true, true, false).unwrap();
28003        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28004        let snipped = parsed["d"]["sn"].as_str().unwrap();
28005        assert_eq!(snipped.len(), 80);
28006        assert!(snipped.ends_with("..."));
28007    }
28008
28009    #[test]
28010    fn ultra_terse_compacts_coverage_snapshot() {
28011        let val = serde_json::json!({
28012            "mode": "incremental",
28013            "total_sector_count": 10,
28014            "dirty_sector_count": 2,
28015            "active_rebuild": Some("rebuild-1"),
28016            "completed_dirty_sector_count": 1,
28017            "mounted_sector_count": 8,
28018            "rebuilding_sector_count": 1,
28019            "resumed_sector_count": 3,
28020            "reused_sector_count": 5
28021        });
28022        let result = to_json_schema(&val, false, true, true, false).unwrap();
28023        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28024        let d = &parsed["d"];
28025        assert_eq!(d["mode"], "incremental");
28026        assert_eq!(d["total_sector_count"], 10);
28027        assert_eq!(d["dirty_sector_count"], 2);
28028        assert!(d.get("active_rebuild").is_none());
28029        assert!(d.get("completed_dirty_sector_count").is_none());
28030        assert!(d.get("mounted_sector_count").is_none());
28031        assert!(d.get("rebuilding_sector_count").is_none());
28032        assert!(d.get("resumed_sector_count").is_none());
28033        assert!(d.get("reused_sector_count").is_none());
28034    }
28035
28036    #[test]
28037    fn ultra_terse_short_snippet_unchanged() {
28038        let val = serde_json::json!({"snippet": "short text"});
28039        let result = to_json_schema(&val, false, true, true, false).unwrap();
28040        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28041        assert_eq!(parsed["d"]["sn"], "short text");
28042    }
28043
28044    #[test]
28045    fn ultra_terse_non_graph_object_properties_preserved() {
28046        let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
28047        let result = to_json_schema(&val, false, true, true, false).unwrap();
28048        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28049        assert!(parsed["d"]["config"]["properties"].is_object());
28050    }
28051
28052    // --- schema-then-values ---
28053
28054    #[test]
28055    fn schema_converts_homogeneous_arrays() {
28056        let val = serde_json::json!({"symbols": [
28057            {"name": "foo", "kind": "fn", "line": 10},
28058            {"name": "bar", "kind": "fn", "line": 20}
28059        ]});
28060        let result = to_json_schema(&val, false, false, false, true).unwrap();
28061        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28062        let syms = &parsed["symbols"];
28063        let columns = syms["_c"]
28064            .as_array()
28065            .unwrap()
28066            .iter()
28067            .map(|value| value.as_str().unwrap())
28068            .collect::<Vec<_>>();
28069        let row0 = syms["_r"][0].as_array().unwrap();
28070        let row1 = syms["_r"][1].as_array().unwrap();
28071        let name_index = columns.iter().position(|column| *column == "name").unwrap();
28072        let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
28073        let line_index = columns.iter().position(|column| *column == "line").unwrap();
28074        assert_eq!(row0[name_index], "foo");
28075        assert_eq!(row0[kind_index], "fn");
28076        assert_eq!(row0[line_index], 10);
28077        assert_eq!(row1[name_index], "bar");
28078        assert_eq!(row1[kind_index], "fn");
28079        assert_eq!(row1[line_index], 20);
28080    }
28081
28082    #[test]
28083    fn schema_skips_short_arrays() {
28084        let val = serde_json::json!({"items": [{"name": "only"}]});
28085        let result = to_json_schema(&val, false, false, false, true).unwrap();
28086        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28087        assert!(parsed["items"].is_array());
28088        assert_eq!(parsed["items"][0]["name"], "only");
28089    }
28090
28091    #[test]
28092    fn schema_skips_heterogeneous_arrays() {
28093        let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
28094        let result = to_json_schema(&val, false, false, false, true).unwrap();
28095        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28096        assert!(parsed["items"].is_array());
28097        assert_eq!(parsed["items"][0]["a"], 1);
28098    }
28099
28100    #[test]
28101    fn schema_with_terse_combines() {
28102        let val = serde_json::json!({"callers": [
28103            {"caller_name": "a", "caller_file": "x.rs"},
28104            {"caller_name": "b", "caller_file": "y.rs"}
28105        ]});
28106        let result = to_json_schema(&val, false, true, false, true).unwrap();
28107        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28108        assert!(parsed["_s"].is_object());
28109        let d = &parsed["d"];
28110        let crs = &d["crs"];
28111        assert!(crs["_c"].is_array());
28112        assert!(crs["_r"].is_array());
28113        let columns = crs["_c"]
28114            .as_array()
28115            .unwrap()
28116            .iter()
28117            .map(|value| value.as_str().unwrap())
28118            .collect::<Vec<_>>();
28119        let row = crs["_r"][0].as_array().unwrap();
28120        let name_index = columns.iter().position(|column| *column == "cn").unwrap();
28121        let file_index = columns.iter().position(|column| *column == "cf").unwrap();
28122        assert_eq!(row[name_index], "a");
28123        assert_eq!(row[file_index], "x.rs");
28124    }
28125
28126    #[test]
28127    fn schema_preserves_non_object_arrays() {
28128        let val = serde_json::json!({"tags": ["a", "b", "c"]});
28129        let result = to_json_schema(&val, false, false, false, true).unwrap();
28130        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
28131        assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
28132    }
28133
28134    #[test]
28135    fn cli_accepts_global_schema_flag() {
28136        let cli = parse_cli(["tsift", "--schema", "search", "test"]);
28137        assert!(cli.schema);
28138        assert!(matches!(cli.command, Some(Commands::Search { .. })));
28139    }
28140
28141    #[test]
28142    fn cli_accepts_global_envelope_flag() {
28143        let cli = parse_cli([
28144            "tsift",
28145            "--envelope",
28146            "context-pack",
28147            "tasks/software/tsift.md",
28148        ]);
28149        assert!(cli.envelope);
28150        assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
28151    }
28152
28153    #[test]
28154    fn cli_accepts_locks_command() {
28155        let cli = parse_cli(["tsift", "locks"]);
28156        assert!(matches!(cli.command, Some(Commands::Locks { .. })));
28157    }
28158
28159    #[test]
28160    fn cli_parses_memory_budget_guard_command() {
28161        let cli = parse_cli([
28162            "tsift",
28163            "memory",
28164            "budget-guard",
28165            "--file",
28166            "tool.log",
28167            "--budget-tokens",
28168            "1000",
28169            "--json",
28170        ]);
28171        match cli.command {
28172            Some(Commands::Memory {
28173                command:
28174                    crate::cli::MemoryCommand::BudgetGuard {
28175                        file,
28176                        budget_tokens,
28177                        json,
28178                        ..
28179                    },
28180            }) => {
28181                assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
28182                assert_eq!(budget_tokens, 1000);
28183                assert!(json);
28184            }
28185            _ => panic!("expected memory budget-guard command"),
28186        }
28187    }
28188
28189    #[test]
28190    fn cli_parses_memory_capture_agent_doc_closeout_command() {
28191        let cli = parse_cli([
28192            "tsift",
28193            "memory",
28194            "capture-agent-doc-closeout",
28195            ".",
28196            "--session-path",
28197            "tasks/software/tsift.md",
28198            "--prompt-target",
28199            "do [#tsiftmemhooks]",
28200            "--response-summary",
28201            "wired closeout capture",
28202            "--commit-hash",
28203            "abc123",
28204            "--session-check-status",
28205            "clean",
28206            "--json",
28207        ]);
28208        match cli.command {
28209            Some(Commands::Memory {
28210                command:
28211                    crate::cli::MemoryCommand::CaptureAgentDocCloseout {
28212                        path,
28213                        session_path,
28214                        prompt_target,
28215                        response_summary,
28216                        commit_hash,
28217                        session_check_status,
28218                        json,
28219                    },
28220            }) => {
28221                assert_eq!(path, std::path::PathBuf::from("."));
28222                assert_eq!(
28223                    session_path,
28224                    std::path::PathBuf::from("tasks/software/tsift.md")
28225                );
28226                assert_eq!(prompt_target, "do [#tsiftmemhooks]");
28227                assert_eq!(response_summary, "wired closeout capture");
28228                assert_eq!(commit_hash.as_deref(), Some("abc123"));
28229                assert_eq!(session_check_status, "clean");
28230                assert!(json);
28231            }
28232            _ => panic!("expected memory capture-agent-doc-closeout command"),
28233        }
28234    }
28235
28236    #[test]
28237    fn cli_locks_accepts_scope_flag() {
28238        let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
28239        match cli.command {
28240            Some(Commands::Locks { scope, .. }) => {
28241                assert_eq!(scope.as_deref(), Some("alpha"));
28242            }
28243            _ => panic!("expected Locks command"),
28244        }
28245    }
28246
28247    #[test]
28248    fn cli_search_accepts_autoindex_flag() {
28249        let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
28250        match cli.command {
28251            Some(Commands::Search {
28252                autoindex,
28253                no_autoindex,
28254                ..
28255            }) => {
28256                assert!(autoindex);
28257                assert!(!no_autoindex);
28258            }
28259            _ => panic!("expected Search command"),
28260        }
28261    }
28262
28263    #[test]
28264    fn cli_search_accepts_exact_flag() {
28265        let cli = parse_cli(["tsift", "search", "test", "--exact"]);
28266        match cli.command {
28267            Some(Commands::Search {
28268                exact, strategy, ..
28269            }) => {
28270                assert!(exact);
28271                assert!(strategy.is_none());
28272            }
28273            _ => panic!("expected Search command"),
28274        }
28275    }
28276
28277    #[test]
28278    fn cli_parses_diff_digest_command() {
28279        let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
28280        match cli.command {
28281            Some(Commands::DiffDigest {
28282                json,
28283                path,
28284                cached,
28285                revision,
28286                max_parsed_files,
28287            }) => {
28288                assert!(json);
28289                assert_eq!(path, PathBuf::from("."));
28290                assert!(!cached);
28291                assert!(revision.is_none());
28292                assert_eq!(max_parsed_files, 25);
28293            }
28294            _ => panic!("expected DiffDigest command"),
28295        }
28296    }
28297
28298    #[test]
28299    fn cli_rejects_conflicting_diff_digest_modes() {
28300        match try_parse_cli([
28301            "tsift",
28302            "diff-digest",
28303            "--cached",
28304            "--revision",
28305            "HEAD",
28306            ".",
28307        ]) {
28308            Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
28309            Err(err) => {
28310                assert!(err.to_string().contains("--cached"));
28311                assert!(err.to_string().contains("--revision"));
28312            }
28313        }
28314    }
28315
28316    #[test]
28317    fn cli_parses_test_digest_command() {
28318        let cli = parse_cli([
28319            "tsift",
28320            "test-digest",
28321            "--path",
28322            ".",
28323            "--input",
28324            "target/test.log",
28325            "--runner",
28326            "cargo",
28327            "--json",
28328        ]);
28329        match cli.command {
28330            Some(Commands::TestDigest {
28331                json,
28332                path,
28333                input,
28334                runner,
28335            }) => {
28336                assert!(json);
28337                assert_eq!(path, PathBuf::from("."));
28338                assert_eq!(input, Some(PathBuf::from("target/test.log")));
28339                assert_eq!(runner.as_deref(), Some("cargo"));
28340            }
28341            _ => panic!("expected TestDigest command"),
28342        }
28343    }
28344
28345    #[test]
28346    fn cli_parses_log_digest_command() {
28347        let cli = parse_cli([
28348            "tsift",
28349            "log-digest",
28350            "--path",
28351            ".",
28352            "--input",
28353            "target/build.log",
28354            "--json",
28355        ]);
28356        match cli.command {
28357            Some(Commands::LogDigest { json, path, input }) => {
28358                assert!(json);
28359                assert_eq!(path, PathBuf::from("."));
28360                assert_eq!(input, Some(PathBuf::from("target/build.log")));
28361            }
28362            _ => panic!("expected LogDigest command"),
28363        }
28364    }
28365
28366    #[test]
28367    fn cli_parses_metric_digest_command() {
28368        let cli = parse_cli([
28369            "tsift",
28370            "metric-digest",
28371            "--input",
28372            "target/runs.json",
28373            "--baseline",
28374            "target/prior.json",
28375            "--metric",
28376            "session_mae",
28377            "--lower-is-better",
28378            "session_mae",
28379            "--history",
28380            "4",
28381            "--top",
28382            "2",
28383            "--json",
28384        ]);
28385        match cli.command {
28386            Some(Commands::MetricDigest {
28387                input,
28388                baseline,
28389                metrics,
28390                lower_is_better,
28391                history,
28392                top,
28393                json,
28394                ..
28395            }) => {
28396                assert!(json);
28397                assert_eq!(input, Some(PathBuf::from("target/runs.json")));
28398                assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
28399                assert_eq!(metrics, vec!["session_mae"]);
28400                assert_eq!(lower_is_better, vec!["session_mae"]);
28401                assert_eq!(history, 4);
28402                assert_eq!(top, 2);
28403            }
28404            _ => panic!("expected MetricDigest command"),
28405        }
28406    }
28407
28408    #[test]
28409    fn cli_parses_dci_benchmark_command() {
28410        let cli = parse_cli([
28411            "tsift",
28412            "dci-benchmark",
28413            "--fixture",
28414            "fixtures/dci-search-benchmark.json",
28415            "--json",
28416        ]);
28417        match cli.command {
28418            Some(Commands::DciBenchmark { fixture, json }) => {
28419                assert!(json);
28420                assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
28421            }
28422            _ => panic!("expected DciBenchmark command"),
28423        }
28424    }
28425
28426    #[test]
28427    fn cli_parses_session_digest_command() {
28428        let cli = parse_cli([
28429            "tsift",
28430            "session-digest",
28431            "--path",
28432            ".",
28433            "--input",
28434            "target/session.md",
28435            "--source",
28436            "markdown",
28437            "--json",
28438        ]);
28439        match cli.command {
28440            Some(Commands::SessionDigest {
28441                json,
28442                path,
28443                input,
28444                source,
28445            }) => {
28446                assert!(json);
28447                assert_eq!(path, PathBuf::from("."));
28448                assert_eq!(input, Some(PathBuf::from("target/session.md")));
28449                assert_eq!(source.as_deref(), Some("markdown"));
28450            }
28451            _ => panic!("expected SessionDigest command"),
28452        }
28453    }
28454
28455    #[test]
28456    fn cli_parses_session_cost_command() {
28457        let cli = parse_cli([
28458            "tsift",
28459            "session-cost",
28460            "--input",
28461            "target/session.jsonl",
28462            "--source",
28463            "codex-jsonl",
28464            "--json",
28465        ]);
28466        match cli.command {
28467            Some(Commands::SessionCost {
28468                json,
28469                input,
28470                source,
28471            }) => {
28472                assert!(json);
28473                assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
28474                assert_eq!(source.as_deref(), Some("codex-jsonl"));
28475            }
28476            _ => panic!("expected SessionCost command"),
28477        }
28478    }
28479
28480    #[test]
28481    fn cli_parses_session_review_command() {
28482        let cli = parse_cli([
28483            "tsift",
28484            "session-review",
28485            "tasks/software/tsift.md",
28486            "--next-context",
28487            "--json",
28488        ]);
28489        match cli.command {
28490            Some(Commands::SessionReview {
28491                json,
28492                next_context,
28493                path,
28494                ..
28495            }) => {
28496                assert!(json);
28497                assert!(next_context);
28498                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28499            }
28500            _ => panic!("expected SessionReview command"),
28501        }
28502    }
28503
28504    #[test]
28505    fn cli_search_accepts_budget_flags() {
28506        let cli = parse_cli([
28507            "tsift",
28508            "search",
28509            "alpha_helper",
28510            "--max-items",
28511            "3",
28512            "--max-bytes",
28513            "96",
28514        ]);
28515        match cli.command {
28516            Some(Commands::Search {
28517                max_items,
28518                max_bytes,
28519                ..
28520            }) => {
28521                assert_eq!(max_items, Some(3));
28522                assert_eq!(max_bytes, Some(96));
28523            }
28524            _ => panic!("expected Search command"),
28525        }
28526    }
28527
28528    #[test]
28529    fn cli_search_accepts_budget_preset() {
28530        let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
28531        match cli.command {
28532            Some(Commands::Search { budget, .. }) => {
28533                assert_eq!(budget, Some(ResponseBudgetPreset::Small));
28534            }
28535            _ => panic!("expected Search command"),
28536        }
28537    }
28538
28539    #[test]
28540    fn cli_search_accepts_ast_facet_filters() {
28541        let cli = parse_cli([
28542            "tsift",
28543            "search",
28544            "setup",
28545            "--lang",
28546            "markdown",
28547            "--kind",
28548            "list_item",
28549            "--node-kind",
28550            "list_item",
28551            "--section",
28552            "Install",
28553            "--parent",
28554            "Run setup.",
28555            "--child",
28556            "Confirm setup.",
28557            "--fence-language",
28558            "rust",
28559            "--list-depth",
28560            "1",
28561            "--heading-level",
28562            "2",
28563        ]);
28564        match cli.command {
28565            Some(Commands::Search {
28566                lang,
28567                kind,
28568                node_kind,
28569                section,
28570                parent,
28571                child,
28572                fence_language,
28573                list_depth,
28574                heading_level,
28575                ..
28576            }) => {
28577                assert_eq!(lang, vec!["markdown"]);
28578                assert_eq!(kind, vec!["list_item"]);
28579                assert_eq!(node_kind, vec!["list_item"]);
28580                assert_eq!(section, vec!["Install"]);
28581                assert_eq!(parent, vec!["Run setup."]);
28582                assert_eq!(child, vec!["Confirm setup."]);
28583                assert_eq!(fence_language, vec!["rust"]);
28584                assert_eq!(list_depth, vec![1]);
28585                assert_eq!(heading_level, vec![2]);
28586            }
28587            _ => panic!("expected Search command"),
28588        }
28589    }
28590
28591    #[test]
28592    fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
28593        let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
28594        assert_eq!(small.preview_items(), 3);
28595        assert_eq!(small.preview_bytes(), 120);
28596        assert_eq!(small.follow_up_items(), 4);
28597
28598        let overridden =
28599            ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
28600        assert_eq!(overridden.preview_items(), 7);
28601        assert_eq!(overridden.preview_bytes(), 120);
28602        assert_eq!(overridden.follow_up_items(), 7);
28603
28604        let envelope_default = ResponseBudget::from_cli(None, None, None, true);
28605        assert!(envelope_default.is_active());
28606    }
28607
28608    #[test]
28609    fn cli_explain_accepts_budget_flags() {
28610        let cli = parse_cli([
28611            "tsift",
28612            "explain",
28613            "alpha_helper",
28614            "--max-items",
28615            "2",
28616            "--max-bytes",
28617            "80",
28618        ]);
28619        match cli.command {
28620            Some(Commands::Explain {
28621                max_items,
28622                max_bytes,
28623                ..
28624            }) => {
28625                assert_eq!(max_items, Some(2));
28626                assert_eq!(max_bytes, Some(80));
28627            }
28628            _ => panic!("expected Explain command"),
28629        }
28630    }
28631
28632    #[test]
28633    fn cli_session_review_accepts_budget_flags() {
28634        let cli = parse_cli([
28635            "tsift",
28636            "session-review",
28637            "tasks/software/tsift.md",
28638            "--max-items",
28639            "4",
28640            "--max-bytes",
28641            "120",
28642        ]);
28643        match cli.command {
28644            Some(Commands::SessionReview {
28645                max_items,
28646                max_bytes,
28647                ..
28648            }) => {
28649                assert_eq!(max_items, Some(4));
28650                assert_eq!(max_bytes, Some(120));
28651            }
28652            _ => panic!("expected SessionReview command"),
28653        }
28654    }
28655
28656    #[test]
28657    fn cli_parses_context_pack_command() {
28658        let cli = parse_cli([
28659            "tsift",
28660            "context-pack",
28661            "tasks/software/tsift.md",
28662            "--test-input",
28663            "target/test.log",
28664            "--runner",
28665            "cargo",
28666            "--log-input",
28667            "target/build.log",
28668            "--max-items",
28669            "3",
28670            "--max-bytes",
28671            "96",
28672            "--json",
28673        ]);
28674        match cli.command {
28675            Some(Commands::ContextPack {
28676                path,
28677                test_input,
28678                runner,
28679                log_input,
28680                json,
28681                max_items,
28682                max_bytes,
28683                budget,
28684                convex_snapshot,
28685            }) => {
28686                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
28687                assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
28688                assert_eq!(runner.as_deref(), Some("cargo"));
28689                assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
28690                assert!(json);
28691                assert_eq!(max_items, Some(3));
28692                assert_eq!(max_bytes, Some(96));
28693                assert!(budget.is_none());
28694                assert!(convex_snapshot.is_none());
28695            }
28696            _ => panic!("expected ContextPack command"),
28697        }
28698    }
28699
28700    #[test]
28701    fn cli_parses_token_savings_command() {
28702        let cli = parse_cli([
28703            "tsift",
28704            "token-savings",
28705            "--fixture",
28706            "fixtures/tsift-token-savings.json",
28707            "--fail-under",
28708            "--json",
28709        ]);
28710        match cli.command {
28711            Some(Commands::TokenSavings {
28712                fixture,
28713                fail_under,
28714                json,
28715            }) => {
28716                assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
28717                assert!(fail_under);
28718                assert!(json);
28719            }
28720            _ => panic!("expected TokenSavings command"),
28721        }
28722    }
28723
28724    #[test]
28725    fn token_savings_report_records_fixture_thresholds() {
28726        let raw_symbols = [
28727            "validate_user",
28728            "validateUser",
28729            "ValidateUser",
28730            "validate-user",
28731            "VALIDATE_USER",
28732            "Validate_User",
28733            "raw_symbol",
28734            "rawSymbol",
28735            "RawSymbol",
28736            "raw-symbol",
28737            "RAW_SYMBOL",
28738            "Raw_Symbol",
28739        ]
28740        .iter()
28741        .enumerate()
28742        .map(|(idx, identifier)| TokenSavingsRawSymbol {
28743            identifier: (*identifier).to_string(),
28744            file: format!("src/example_{idx}.rs"),
28745            line: (idx + 1) as u64,
28746            context: "function".to_string(),
28747        })
28748        .collect();
28749        let fixture = TokenSavingsFixture {
28750            schema_version: 1,
28751            description: "fixture".to_string(),
28752            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28753            cases: vec![TokenSavingsFixtureCase {
28754                name: "search-preview".to_string(),
28755                surface: "search".to_string(),
28756                minimum_savings_percent: 40.0,
28757                raw_symbols,
28758                tagpath_families: vec![
28759                    TokenSavingsFamily {
28760                        canonical: "validate_user".to_string(),
28761                        count: 6,
28762                        aliases: BTreeMap::new(),
28763                    },
28764                    TokenSavingsFamily {
28765                        canonical: "raw_symbol".to_string(),
28766                        count: 6,
28767                        aliases: BTreeMap::new(),
28768                    },
28769                ],
28770                context_pack_inputs: None,
28771                session_review_inputs: None,
28772                source_read_inputs: None,
28773                markdown_projection_inputs: None,
28774            }],
28775        };
28776
28777        let report = build_token_savings_report(&fixture).unwrap();
28778
28779        assert!(report.pass);
28780        assert_eq!(report.cases[0].raw_symbol_count, 12);
28781        assert_eq!(report.cases[0].family_count, 2);
28782        assert_eq!(report.cases[0].status, "pass");
28783        assert!(report.cases[0].byte_delta > 0);
28784        assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
28785        assert!(report.cases[0].savings_percent >= 40.0);
28786    }
28787
28788    #[test]
28789    fn token_savings_source_read_inputs_preserve_required_anchors() {
28790        let fixture = TokenSavingsFixture {
28791            schema_version: 1,
28792            description: "fixture".to_string(),
28793            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28794            cases: vec![TokenSavingsFixtureCase {
28795                name: "source-read".to_string(),
28796                surface: "source-read".to_string(),
28797                minimum_savings_percent: 40.0,
28798                raw_symbols: Vec::new(),
28799                tagpath_families: Vec::new(),
28800                context_pack_inputs: None,
28801                session_review_inputs: None,
28802                source_read_inputs: Some(TokenSavingsSourceReadInputs {
28803                    reads: vec![TokenSavingsSourceReadInput {
28804                        command: "sed -n '40,160p' src/main.rs".to_string(),
28805                        file: "src/main.rs".to_string(),
28806                        raw_start: 40,
28807                        raw_lines: 121,
28808                        raw_excerpt: "line 40\n".repeat(121),
28809                        envelope_start: 40,
28810                        envelope_lines: 121,
28811                        required_line_anchors: vec![40, 120, 160],
28812                    }],
28813                }),
28814                markdown_projection_inputs: None,
28815            }],
28816        };
28817
28818        let report = build_token_savings_report(&fixture).unwrap();
28819
28820        assert!(report.pass);
28821        assert_eq!(report.cases[0].surface, "source-read");
28822        assert!(report.cases[0].savings_percent >= 40.0);
28823    }
28824
28825    #[test]
28826    fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
28827        let fixture = TokenSavingsFixture {
28828            schema_version: 1,
28829            description: "fixture".to_string(),
28830            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28831            cases: vec![TokenSavingsFixtureCase {
28832                name: "source-read".to_string(),
28833                surface: "source-read".to_string(),
28834                minimum_savings_percent: 40.0,
28835                raw_symbols: Vec::new(),
28836                tagpath_families: Vec::new(),
28837                context_pack_inputs: None,
28838                session_review_inputs: None,
28839                source_read_inputs: Some(TokenSavingsSourceReadInputs {
28840                    reads: vec![TokenSavingsSourceReadInput {
28841                        command: "cat src/main.rs".to_string(),
28842                        file: "src/main.rs".to_string(),
28843                        raw_start: 1,
28844                        raw_lines: 200,
28845                        raw_excerpt: "line\n".repeat(200),
28846                        envelope_start: 1,
28847                        envelope_lines: 80,
28848                        required_line_anchors: vec![120],
28849                    }],
28850                }),
28851                markdown_projection_inputs: None,
28852            }],
28853        };
28854
28855        let err = match build_token_savings_report(&fixture) {
28856            Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
28857            Err(err) => err,
28858        };
28859
28860        assert!(err.to_string().contains("hides required line anchor 120"));
28861    }
28862
28863    #[test]
28864    fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
28865        let fixture = TokenSavingsFixture {
28866            schema_version: 1,
28867            description: "fixture".to_string(),
28868            token_estimate: "ceil(utf8_bytes / 4)".to_string(),
28869            cases: vec![TokenSavingsFixtureCase {
28870                name: "markdown-projection".to_string(),
28871                surface: "context-pack".to_string(),
28872                minimum_savings_percent: 40.0,
28873                raw_symbols: Vec::new(),
28874                tagpath_families: Vec::new(),
28875                context_pack_inputs: None,
28876                session_review_inputs: None,
28877                source_read_inputs: None,
28878                markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
28879                    documents: vec![TokenSavingsMarkdownProjectionInput {
28880                        command: "context-pack markdown body".to_string(),
28881                        file: "tasks/software/tsift.md".to_string(),
28882                        raw_markdown: "# Heading\n\n".repeat(120),
28883                        outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
28884                        selected_nodes: vec!["mdast-selected".to_string()],
28885                        expand:
28886                            "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
28887                                .to_string(),
28888                    }],
28889                }),
28890            }],
28891        };
28892
28893        let report = build_token_savings_report(&fixture).unwrap();
28894
28895        assert!(report.pass);
28896        assert_eq!(report.cases[0].surface, "context-pack");
28897        assert!(report.cases[0].savings_percent >= 40.0);
28898    }
28899
28900    #[test]
28901    fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
28902        let mut content = String::from("# Cache Root\n\n");
28903        for idx in 0..96 {
28904            content.push_str(&format!(
28905                "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
28906            ));
28907        }
28908
28909        let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
28910        assert!(!first.cache_hit);
28911        assert!(first.nodes.len() > 200);
28912
28913        let sections = markdown_section_spans(&content).unwrap();
28914        let list_items = markdown_block_spans(&content, "list_item").unwrap();
28915        let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
28916        let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
28917
28918        assert!(second.cache_hit);
28919        assert_eq!(second.nodes.len(), first.nodes.len());
28920        assert_eq!(sections.len(), 97);
28921        assert_eq!(list_items.len(), 96);
28922        assert_eq!(code_blocks.len(), 96);
28923        let first_code = first
28924            .nodes
28925            .iter()
28926            .find(|node| node.kind == "code_block")
28927            .expect("expected a Markdown code block");
28928        let first_code_node = markdown_ast_node(
28929            Path::new("/repo"),
28930            "semantic-edit",
28931            first_code,
28932            content.as_bytes(),
28933            &first.nodes,
28934            8,
28935        );
28936        assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
28937        assert_eq!(
28938            first_code_node.metadata.embedded_symbols[0].name,
28939            "sample_0"
28940        );
28941        assert_eq!(
28942            first_code_node.metadata.embedded_symbols[0].language,
28943            "rust"
28944        );
28945    }
28946
28947    #[test]
28948    fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
28949        let response = empty_search_response(Path::new("/repo"), "lexical");
28950        let symbol_hits = vec![index::SymbolHit {
28951            name: "alpha_helper_with_a_long_name".to_string(),
28952            kind: "function".to_string(),
28953            language: "rust".to_string(),
28954            file: "/repo/src/lib.rs".to_string(),
28955            line: 12,
28956            end_line: None,
28957            node_kind: None,
28958            start_byte: None,
28959            end_byte: None,
28960            body_start_byte: None,
28961            body_end_byte: None,
28962            tags: None,
28963            score: 0.98,
28964            match_type: "exact_name".to_string(),
28965            tagpath_handle: None,
28966        }];
28967
28968        let report = build_relative_search_budget_report(
28969            "alpha_helper_with_a_long_name",
28970            "lexical",
28971            Path::new("/repo"),
28972            &response,
28973            &symbol_hits,
28974            ResponseBudget::new(Some(1), Some(12)),
28975            &SearchFacetFilters::default(),
28976        );
28977
28978        assert_eq!(report.symbols.len(), 1);
28979        assert!(report.symbols[0].handle.starts_with("sfam-"));
28980        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
28981        assert_eq!(report.symbols[0].name, "alpha_hel...");
28982        assert_eq!(report.symbols[0].file, "src/lib.rs");
28983        assert!(report.symbols[0].expand.contains("tsift search"));
28984    }
28985
28986    #[test]
28987    fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
28988        let dir = tempfile::tempdir().unwrap();
28989        let src_dir = dir.path().join("src");
28990        fs::create_dir_all(&src_dir).unwrap();
28991        let source = "fn alpha_helper() {\n    beta();\n}\n";
28992        let file = src_dir.join("lib.rs");
28993        fs::write(&file, source).unwrap();
28994        let body_start = source.find("{\n").unwrap() + 1;
28995        let body_end = source.rfind("\n}").unwrap() + 1;
28996
28997        let response = empty_search_response(dir.path(), "lexical");
28998        let symbol_hits = vec![index::SymbolHit {
28999            name: "alpha_helper".to_string(),
29000            kind: "function".to_string(),
29001            language: "rust".to_string(),
29002            file: file.to_string_lossy().to_string(),
29003            line: 0,
29004            end_line: Some(2),
29005            node_kind: Some("function_item".to_string()),
29006            start_byte: Some(0),
29007            end_byte: Some(i64::try_from(source.len()).unwrap()),
29008            body_start_byte: Some(i64::try_from(body_start).unwrap()),
29009            body_end_byte: Some(i64::try_from(body_end).unwrap()),
29010            tags: Some("alpha,helper".to_string()),
29011            score: 0.98,
29012            match_type: "exact_name".to_string(),
29013            tagpath_handle: None,
29014        }];
29015
29016        let report = build_relative_search_budget_report(
29017            "alpha helper",
29018            "lexical",
29019            dir.path(),
29020            &response,
29021            &symbol_hits,
29022            ResponseBudget::new(Some(5), Some(96)),
29023            &SearchFacetFilters::default(),
29024        );
29025
29026        let symbol = &report.symbols[0];
29027        assert_eq!(symbol.language, "rust");
29028        assert_eq!(symbol.end_line, Some(2));
29029        let ast = symbol
29030            .ast
29031            .as_ref()
29032            .expect("search symbol preview should expose an AST span artifact");
29033        assert_eq!(ast.artifact_kind, "ast_span");
29034        assert!(ast.span.handle.starts_with("span-"));
29035        assert_eq!(ast.span.node_kind, "function_item");
29036        assert_eq!(ast.span.start_byte, 0);
29037        assert_eq!(ast.span.end_byte, source.len());
29038        assert_eq!(ast.span.body_start_byte, Some(body_start));
29039        assert_eq!(ast.span.body_end_byte, Some(body_end));
29040        assert!(ast.expand.source_window.contains("source-read"));
29041        assert!(
29042            ast.expand
29043                .source_body
29044                .as_ref()
29045                .unwrap()
29046                .contains("source-read")
29047        );
29048        assert!(ast.expand.symbol_read.contains("symbol-read"));
29049        assert!(ast.expand.markdown_ast.is_none());
29050    }
29051
29052    #[test]
29053    fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
29054        let dir = tempfile::tempdir().unwrap();
29055        let source = "# Guide\n\n## Install\n\n- Run setup.\n";
29056        let file = dir.path().join("README.md");
29057        fs::write(&file, source).unwrap();
29058        let heading_start = source.find("## Install").unwrap();
29059        let heading_end = source.len();
29060
29061        let response = empty_search_response(dir.path(), "lexical");
29062        let symbol_hits = vec![index::SymbolHit {
29063            name: "Install".to_string(),
29064            kind: "heading".to_string(),
29065            language: "markdown".to_string(),
29066            file: file.to_string_lossy().to_string(),
29067            line: 2,
29068            end_line: Some(4),
29069            node_kind: Some("atx_heading".to_string()),
29070            start_byte: Some(i64::try_from(heading_start).unwrap()),
29071            end_byte: Some(i64::try_from(heading_end).unwrap()),
29072            body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
29073            body_end_byte: Some(i64::try_from(heading_end).unwrap()),
29074            tags: Some("install".to_string()),
29075            score: 1.0,
29076            match_type: "exact_name".to_string(),
29077            tagpath_handle: None,
29078        }];
29079
29080        let report = build_relative_search_budget_report(
29081            "Install",
29082            "lexical",
29083            dir.path(),
29084            &response,
29085            &symbol_hits,
29086            ResponseBudget::new(Some(5), Some(96)),
29087            &SearchFacetFilters::default(),
29088        );
29089
29090        let ast = report.symbols[0]
29091            .ast
29092            .as_ref()
29093            .expect("Markdown search symbol should expose an AST span artifact");
29094        assert_eq!(ast.span.node_kind, "atx_heading");
29095        assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
29096        let markdown_ast = ast
29097            .expand
29098            .markdown_ast
29099            .as_ref()
29100            .expect("Markdown symbols should include markdown-ast expansion");
29101        assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
29102        assert!(markdown_ast.contains("--node"), "{markdown_ast}");
29103        assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
29104        assert!(ast.expand.source_window.contains("source-read"));
29105        assert!(ast.expand.symbol_read.contains("symbol-read"));
29106    }
29107
29108    #[test]
29109    fn search_budget_report_exposes_markdown_embedded_code_symbols() {
29110        let dir = tempfile::tempdir().unwrap();
29111        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29112        let file = dir.path().join("README.md");
29113        fs::write(&file, source).unwrap();
29114        let fence_start = source.find("```rust").unwrap();
29115        let body_start = source.find("fn sample").unwrap();
29116        let body_end = body_start + "fn sample() {}\n".len();
29117
29118        let response = empty_search_response(dir.path(), "lexical");
29119        let symbol_hits = vec![index::SymbolHit {
29120            name: "rust".to_string(),
29121            kind: "code_block".to_string(),
29122            language: "markdown".to_string(),
29123            file: file.to_string_lossy().to_string(),
29124            line: 2,
29125            end_line: Some(4),
29126            node_kind: Some("fenced_code_block".to_string()),
29127            start_byte: Some(i64::try_from(fence_start).unwrap()),
29128            end_byte: Some(i64::try_from(source.len()).unwrap()),
29129            body_start_byte: Some(i64::try_from(body_start).unwrap()),
29130            body_end_byte: Some(i64::try_from(body_end).unwrap()),
29131            tags: Some("rust".to_string()),
29132            score: 1.0,
29133            match_type: "exact_name".to_string(),
29134            tagpath_handle: None,
29135        }];
29136
29137        let report = build_relative_search_budget_report(
29138            "rust",
29139            "lexical",
29140            dir.path(),
29141            &response,
29142            &symbol_hits,
29143            ResponseBudget::new(Some(5), Some(96)),
29144            &SearchFacetFilters::default(),
29145        );
29146
29147        let embedded = &report.symbols[0]
29148            .ast
29149            .as_ref()
29150            .unwrap()
29151            .span
29152            .markdown
29153            .as_ref()
29154            .unwrap()
29155            .embedded_symbols;
29156        assert_eq!(embedded.len(), 1);
29157        assert_eq!(embedded[0].name, "sample");
29158        assert_eq!(embedded[0].kind, "function");
29159        assert_eq!(embedded[0].language, "rust");
29160        assert_eq!(embedded[0].node_kind, "function_item");
29161        assert!(embedded[0].handle.starts_with("span-"));
29162        assert_eq!(embedded[0].start_byte, body_start);
29163        assert_eq!(embedded[0].start_line, 4);
29164    }
29165
29166    fn test_lexical_search_hit(
29167        path: &Path,
29168        rank: usize,
29169        score: f64,
29170        snippet: &str,
29171    ) -> sift::SearchHit {
29172        sift::SearchHit {
29173            artifact_id: format!("hit-{rank}"),
29174            artifact_kind: sift::ContextArtifactKind::File,
29175            budget: sift::ArtifactBudget::from_text(snippet, 1),
29176            confidence: sift::ScoreConfidence::High,
29177            freshness: sift::ArtifactFreshness {
29178                modified_unix_secs: None,
29179                observed_unix_secs: 0,
29180            },
29181            location: Some("line 1".to_string()),
29182            path: path.to_string_lossy().to_string(),
29183            provenance: sift::ArtifactProvenance {
29184                adapter: sift::AcquisitionAdapterKind::FileSystem,
29185                source: "test lexical hit".to_string(),
29186                synthetic: false,
29187            },
29188            rank,
29189            score,
29190            snippet: snippet.to_string(),
29191        }
29192    }
29193
29194    fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
29195        summarize::Summary {
29196            id: 0,
29197            symbol_name: symbol_name.to_string(),
29198            file_path: file_path.to_string(),
29199            content_hash: "hash".to_string(),
29200            summary: summary.to_string(),
29201            entities: None,
29202            relationships: None,
29203            concept_labels: None,
29204            extracted_at: "2026-06-02T00:00:00Z".to_string(),
29205            model: "test".to_string(),
29206            tokens_input: None,
29207            tokens_output: None,
29208        }
29209    }
29210
29211    #[test]
29212    fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
29213        let dir = tempfile::tempdir().unwrap();
29214        let src_dir = dir.path().join("src");
29215        fs::create_dir_all(&src_dir).unwrap();
29216        let source = "fn alpha_helper() {}\n";
29217        let file = src_dir.join("lib.rs");
29218        let broad_file = dir.path().join("README.md");
29219        fs::write(&file, source).unwrap();
29220        fs::write(
29221            &broad_file,
29222            "alpha helper alpha helper alpha helper in prose\n",
29223        )
29224        .unwrap();
29225
29226        let mut response = empty_search_response(dir.path(), "lexical");
29227        response.hits.push(test_lexical_search_hit(
29228            &broad_file,
29229            1,
29230            240.0,
29231            "alpha helper alpha helper alpha helper in prose",
29232        ));
29233        let symbol_hits = vec![index::SymbolHit {
29234            name: "alpha_helper".to_string(),
29235            kind: "function".to_string(),
29236            language: "rust".to_string(),
29237            file: file.to_string_lossy().to_string(),
29238            line: 0,
29239            end_line: Some(0),
29240            node_kind: Some("function_item".to_string()),
29241            start_byte: Some(0),
29242            end_byte: Some(i64::try_from(source.len()).unwrap()),
29243            body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29244            body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
29245            tags: Some("alpha,helper".to_string()),
29246            score: 0.8,
29247            match_type: "all_tags".to_string(),
29248            tagpath_handle: None,
29249        }];
29250
29251        let report = build_relative_search_budget_report(
29252            "alpha helper",
29253            "lexical",
29254            dir.path(),
29255            &response,
29256            &symbol_hits,
29257            ResponseBudget::new(Some(5), Some(128)),
29258            &SearchFacetFilters::default(),
29259        );
29260
29261        assert_eq!(report.ranked[0].source, "symbol_span");
29262        assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
29263        assert!(report.ranked[0].score > report.ranked[1].score);
29264        assert_eq!(report.ranked[1].source, "lexical_file");
29265    }
29266
29267    #[test]
29268    fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
29269        let dir = tempfile::tempdir().unwrap();
29270        let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
29271        let file = dir.path().join("README.md");
29272        fs::write(&file, source).unwrap();
29273        let summary_db =
29274            summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
29275        summary_db
29276            .insert(&test_summary(
29277                "rust",
29278                "README.md",
29279                "Rust fence contains a sample function.",
29280            ))
29281            .unwrap();
29282
29283        let fence_start = source.find("```rust").unwrap();
29284        let body_start = source.find("fn sample").unwrap();
29285        let body_end = body_start + "fn sample() {}\n".len();
29286        let response = empty_search_response(dir.path(), "lexical");
29287        let symbol_hits = vec![index::SymbolHit {
29288            name: "rust".to_string(),
29289            kind: "code_block".to_string(),
29290            language: "markdown".to_string(),
29291            file: file.to_string_lossy().to_string(),
29292            line: 2,
29293            end_line: Some(4),
29294            node_kind: Some("fenced_code_block".to_string()),
29295            start_byte: Some(i64::try_from(fence_start).unwrap()),
29296            end_byte: Some(i64::try_from(source.len()).unwrap()),
29297            body_start_byte: Some(i64::try_from(body_start).unwrap()),
29298            body_end_byte: Some(i64::try_from(body_end).unwrap()),
29299            tags: Some("rust".to_string()),
29300            score: 1.0,
29301            match_type: "exact_name".to_string(),
29302            tagpath_handle: None,
29303        }];
29304
29305        let report = build_relative_search_budget_report(
29306            "rust",
29307            "lexical",
29308            dir.path(),
29309            &response,
29310            &symbol_hits,
29311            ResponseBudget::new(Some(5), Some(128)),
29312            &SearchFacetFilters::default(),
29313        );
29314
29315        let symbol = &report.symbols[0];
29316        assert_eq!(symbol.summary_refs, 1);
29317        assert_eq!(symbol.graph_neighbors, 1);
29318        assert!(
29319            report.ranked[0]
29320                .reasons
29321                .iter()
29322                .any(|reason| reason == "summary_refs:1")
29323        );
29324        assert!(
29325            report.ranked[0]
29326                .reasons
29327                .iter()
29328                .any(|reason| reason == "graph_neighbors:1")
29329        );
29330    }
29331
29332    fn markdown_search_facet_fixture() -> tempfile::TempDir {
29333        let dir = tempfile::tempdir().unwrap();
29334        let source = r#"# Guide
29335
29336## Install
29337
29338- Run setup.
29339  - Confirm setup.
29340
29341```rust
29342fn sample() {}
29343```
29344"#;
29345        fs::write(dir.path().join("README.md"), source).unwrap();
29346        let index_dir = dir.path().join(".tsift");
29347        fs::create_dir_all(&index_dir).unwrap();
29348        run_index_update(
29349            &index_dir.join("index.db"),
29350            dir.path(),
29351            "indexing markdown search facet fixture".to_string(),
29352            dir.path(),
29353            None,
29354            false,
29355            false,
29356        )
29357        .unwrap();
29358        dir
29359    }
29360
29361    fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
29362        let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
29363        db.symbol_search(query, 20).unwrap()
29364    }
29365
29366    #[test]
29367    fn search_facet_filters_match_scalar_symbol_fields() {
29368        let dir = tempfile::tempdir().unwrap();
29369        let hits = vec![
29370            index::SymbolHit {
29371                name: "alpha_helper".to_string(),
29372                kind: "function".to_string(),
29373                language: "rust".to_string(),
29374                file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
29375                line: 0,
29376                end_line: None,
29377                node_kind: Some("function_item".to_string()),
29378                start_byte: None,
29379                end_byte: None,
29380                body_start_byte: None,
29381                body_end_byte: None,
29382                tags: None,
29383                score: 1.0,
29384                match_type: "exact_name".to_string(),
29385                tagpath_handle: None,
29386            },
29387            index::SymbolHit {
29388                name: "Install".to_string(),
29389                kind: "heading".to_string(),
29390                language: "markdown".to_string(),
29391                file: dir.path().join("README.md").to_string_lossy().to_string(),
29392                line: 0,
29393                end_line: None,
29394                node_kind: Some("atx_heading".to_string()),
29395                start_byte: None,
29396                end_byte: None,
29397                body_start_byte: None,
29398                body_end_byte: None,
29399                tags: None,
29400                score: 0.9,
29401                match_type: "exact_name".to_string(),
29402                tagpath_handle: None,
29403            },
29404        ];
29405
29406        let filtered = apply_search_facet_filters(
29407            dir.path(),
29408            hits,
29409            &SearchFacetFilters {
29410                languages: vec!["rust".to_string()],
29411                kinds: vec!["function".to_string()],
29412                node_kinds: vec!["function_item".to_string()],
29413                ..SearchFacetFilters::default()
29414            },
29415        );
29416
29417        assert_eq!(filtered.len(), 1);
29418        assert_eq!(filtered[0].name, "alpha_helper");
29419    }
29420
29421    #[test]
29422    fn search_facet_filters_match_markdown_sections_and_block_metadata() {
29423        let dir = markdown_search_facet_fixture();
29424
29425        let nested_list = apply_search_facet_filters(
29426            dir.path(),
29427            markdown_search_facet_hits(dir.path(), "setup"),
29428            &SearchFacetFilters {
29429                sections: vec!["Install".to_string()],
29430                parents: vec!["Run setup.".to_string()],
29431                list_depths: vec![1],
29432                ..SearchFacetFilters::default()
29433            },
29434        );
29435        assert_eq!(nested_list.len(), 1);
29436        assert_eq!(nested_list[0].name, "Confirm setup.");
29437
29438        let parent_list = apply_search_facet_filters(
29439            dir.path(),
29440            markdown_search_facet_hits(dir.path(), "setup"),
29441            &SearchFacetFilters {
29442                children: vec!["Confirm setup.".to_string()],
29443                ..SearchFacetFilters::default()
29444            },
29445        );
29446        assert_eq!(parent_list.len(), 1);
29447        assert_eq!(parent_list[0].name, "Run setup.");
29448
29449        let heading = apply_search_facet_filters(
29450            dir.path(),
29451            markdown_search_facet_hits(dir.path(), "Install"),
29452            &SearchFacetFilters {
29453                heading_levels: vec![2],
29454                node_kinds: vec!["atx_heading".to_string()],
29455                ..SearchFacetFilters::default()
29456            },
29457        );
29458        assert_eq!(heading.len(), 1);
29459        assert_eq!(heading[0].name, "Install");
29460
29461        let fence = apply_search_facet_filters(
29462            dir.path(),
29463            markdown_search_facet_hits(dir.path(), "rust"),
29464            &SearchFacetFilters {
29465                fence_languages: vec!["rust".to_string()],
29466                kinds: vec!["code_block".to_string()],
29467                ..SearchFacetFilters::default()
29468            },
29469        );
29470        assert_eq!(fence.len(), 1);
29471        assert_eq!(fence[0].kind, "code_block");
29472
29473        let embedded_child = apply_search_facet_filters(
29474            dir.path(),
29475            markdown_search_facet_hits(dir.path(), "rust"),
29476            &SearchFacetFilters {
29477                children: vec!["sample".to_string()],
29478                kinds: vec!["code_block".to_string()],
29479                ..SearchFacetFilters::default()
29480            },
29481        );
29482        assert_eq!(embedded_child.len(), 1);
29483        assert_eq!(embedded_child[0].name, "rust");
29484    }
29485
29486    #[test]
29487    fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
29488        let response = empty_search_response(Path::new("/repo"), "lexical");
29489        let symbol_hits = vec![
29490            index::SymbolHit {
29491                name: "alpha_helper".to_string(),
29492                kind: "function".to_string(),
29493                language: "rust".to_string(),
29494                file: "/repo/src/lib.rs".to_string(),
29495                line: 12,
29496                end_line: None,
29497                node_kind: None,
29498                start_byte: None,
29499                end_byte: None,
29500                body_start_byte: None,
29501                body_end_byte: None,
29502                tags: Some("alpha,helper".to_string()),
29503                score: 0.98,
29504                match_type: "exact_name".to_string(),
29505                tagpath_handle: None,
29506            },
29507            index::SymbolHit {
29508                name: "alphaHelper".to_string(),
29509                kind: "method".to_string(),
29510                language: "rust".to_string(),
29511                file: "/repo/src/main.rs".to_string(),
29512                line: 34,
29513                end_line: None,
29514                node_kind: None,
29515                start_byte: None,
29516                end_byte: None,
29517                body_start_byte: None,
29518                body_end_byte: None,
29519                tags: Some("alpha,helper".to_string()),
29520                score: 0.93,
29521                match_type: "tag_overlap".to_string(),
29522                tagpath_handle: None,
29523            },
29524            index::SymbolHit {
29525                name: "alpha_helper".to_string(),
29526                kind: "function".to_string(),
29527                language: "rust".to_string(),
29528                file: "/repo/src/worker.rs".to_string(),
29529                line: 56,
29530                end_line: None,
29531                node_kind: None,
29532                start_byte: None,
29533                end_byte: None,
29534                body_start_byte: None,
29535                body_end_byte: None,
29536                tags: Some("alpha,helper".to_string()),
29537                score: 0.91,
29538                match_type: "tag_overlap".to_string(),
29539                tagpath_handle: None,
29540            },
29541        ];
29542
29543        let report = build_relative_search_budget_report(
29544            "alpha helper",
29545            "lexical",
29546            Path::new("/repo"),
29547            &response,
29548            &symbol_hits,
29549            ResponseBudget::new(Some(5), Some(48)),
29550            &SearchFacetFilters::default(),
29551        );
29552
29553        assert_eq!(report.symbol_total, 1);
29554        assert_eq!(report.raw_symbol_total, 3);
29555        assert_eq!(report.symbols.len(), 1);
29556        assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
29557        assert_eq!(report.symbols[0].match_count, 3);
29558        assert_eq!(report.symbols[0].surface_count, 2);
29559        assert_eq!(report.symbols[0].file_count, 3);
29560        assert_eq!(
29561            report.symbols[0].surface_examples,
29562            vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
29563        );
29564        assert!(report.symbols[0].name.contains("(+1 variant)"));
29565        assert!(report.symbols[0].file.contains("(+2 files)"));
29566        assert!(report.symbols[0].expand.contains("tsift search"));
29567        assert!(report.symbols[0].expand.contains("alpha helper"));
29568    }
29569
29570    #[test]
29571    fn search_budget_report_carries_active_filters() {
29572        let response = empty_search_response(Path::new("/repo"), "lexical");
29573        let symbol_hits = vec![index::SymbolHit {
29574            name: "alpha_helper".to_string(),
29575            kind: "function".to_string(),
29576            language: "rust".to_string(),
29577            file: "/repo/src/lib.rs".to_string(),
29578            line: 12,
29579            end_line: None,
29580            node_kind: Some("function_item".to_string()),
29581            start_byte: None,
29582            end_byte: None,
29583            body_start_byte: None,
29584            body_end_byte: None,
29585            tags: Some("alpha,helper".to_string()),
29586            score: 0.98,
29587            match_type: "exact_name".to_string(),
29588            tagpath_handle: None,
29589        }];
29590        let filters = SearchFacetFilters {
29591            languages: vec!["rust".to_string()],
29592            kinds: vec!["function".to_string()],
29593            node_kinds: vec!["function_item".to_string()],
29594            ..SearchFacetFilters::default()
29595        };
29596
29597        let report = build_relative_search_budget_report(
29598            "alpha helper",
29599            "lexical",
29600            Path::new("/repo"),
29601            &response,
29602            &symbol_hits,
29603            ResponseBudget::new(Some(5), Some(48)),
29604            &filters,
29605        );
29606
29607        assert_eq!(report.filters, filters);
29608        assert_eq!(
29609            search_facet_filters_summary(&report.filters),
29610            "lang=rust kind=function node-kind=function_item"
29611        );
29612    }
29613
29614    #[test]
29615    fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
29616        let mut response = empty_search_response(Path::new("/repo"), "lexical");
29617        response.indexed_artifacts = 450;
29618        let symbol_hits = vec![
29619            index::SymbolHit {
29620                name: "alpha_helper".to_string(),
29621                kind: "function".to_string(),
29622                language: "rust".to_string(),
29623                file: "/repo/src/lib.rs".to_string(),
29624                line: 12,
29625                end_line: None,
29626                node_kind: None,
29627                start_byte: None,
29628                end_byte: None,
29629                body_start_byte: None,
29630                body_end_byte: None,
29631                tags: Some("alpha,helper".to_string()),
29632                score: 0.98,
29633                match_type: "exact_name".to_string(),
29634                tagpath_handle: None,
29635            },
29636            index::SymbolHit {
29637                name: "beta_helper".to_string(),
29638                kind: "function".to_string(),
29639                language: "rust".to_string(),
29640                file: "/repo/src/beta.rs".to_string(),
29641                line: 21,
29642                end_line: None,
29643                node_kind: None,
29644                start_byte: None,
29645                end_byte: None,
29646                body_start_byte: None,
29647                body_end_byte: None,
29648                tags: Some("beta,helper".to_string()),
29649                score: 0.92,
29650                match_type: "tag_overlap".to_string(),
29651                tagpath_handle: None,
29652            },
29653        ];
29654
29655        let report = build_relative_search_budget_report(
29656            "helper",
29657            "lexical",
29658            Path::new("/repo"),
29659            &response,
29660            &symbol_hits,
29661            ResponseBudget::new(Some(1), Some(64)),
29662            &SearchFacetFilters::default(),
29663        );
29664
29665        let guard = report
29666            .scale_guard
29667            .as_ref()
29668            .expect("broad previews should emit a scale guard");
29669        assert_eq!(guard.level, "high-hit");
29670        assert_eq!(guard.signals.indexed_artifacts, 450);
29671        assert_eq!(guard.signals.raw_symbol_matches, 2);
29672        assert!(
29673            guard
29674                .narrow_commands
29675                .iter()
29676                .any(|command| command.contains("--exact"))
29677        );
29678        assert!(
29679            guard
29680                .narrow_commands
29681                .iter()
29682                .any(|command| command.contains("alpha helper"))
29683        );
29684        assert!(
29685            guard
29686                .narrow_commands
29687                .last()
29688                .unwrap()
29689                .contains("workflow search")
29690        );
29691    }
29692
29693    #[test]
29694    fn explain_budget_report_limits_edges_and_members() {
29695        let symbols = vec![index::StoredSymbol {
29696            name: "alpha_helper".to_string(),
29697            kind: "function".to_string(),
29698            language: "rust".to_string(),
29699            signature: None,
29700            file: "src/lib.rs".to_string(),
29701            line: 10,
29702            end_line: None,
29703            node_kind: None,
29704            start_byte: None,
29705            end_byte: None,
29706            body_start_byte: None,
29707            body_end_byte: None,
29708            parent_module: None,
29709            visibility: None,
29710            tags: None,
29711            tagpath_handle: None,
29712        }];
29713        let callers = vec![
29714            index::StoredEdge {
29715                caller_file: "src/main.rs".to_string(),
29716                caller_name: "main".to_string(),
29717                caller_line: 1,
29718                callee_name: "alpha_helper".to_string(),
29719                call_site_line: 3,
29720                tagpath_handle: None,
29721            },
29722            index::StoredEdge {
29723                caller_file: "src/worker.rs".to_string(),
29724                caller_name: "worker".to_string(),
29725                caller_line: 5,
29726                callee_name: "alpha_helper".to_string(),
29727                call_site_line: 8,
29728                tagpath_handle: None,
29729            },
29730        ];
29731        let community = graph::Community {
29732            id: 1,
29733            members: vec![
29734                graph::CommunityMember::new("alpha_helper"),
29735                graph::CommunityMember::new("main"),
29736                graph::CommunityMember::new("worker"),
29737            ],
29738            modularity_contribution: 0.5,
29739        };
29740
29741        let report = build_explain_budget_report(
29742            "alpha_helper",
29743            Path::new("/repo"),
29744            &symbols,
29745            &callers,
29746            2,
29747            false,
29748            &[],
29749            0,
29750            false,
29751            Some(&community),
29752            ResponseBudget::new(Some(1), Some(24)),
29753        );
29754
29755        assert_eq!(report.definitions.len(), 1);
29756        assert_eq!(report.callers.len(), 1);
29757        assert!(report.truncated);
29758        assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
29759        assert_eq!(
29760            report.definitions[0].tag_alias.as_deref(),
29761            Some("alpha/helper")
29762        );
29763        assert!(report.callers[0].handle.starts_with("ecall-"));
29764        assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
29765    }
29766
29767    #[test]
29768    fn session_review_next_context_budget_limits_lists() {
29769        let report = session_review::SessionReviewReport {
29770            root: "/repo".to_string(),
29771            target: "tasks/software/tsift.md".to_string(),
29772            target_kind: "file".to_string(),
29773            sessions_considered: 1,
29774            sessions_matched: 1,
29775            claude_sessions: 1,
29776            codex_sessions: 0,
29777            agent_doc_logs: 0,
29778            prompt_target_count: 2,
29779            command_groups: 0,
29780            file_groups: 2,
29781            symbol_groups: 1,
29782            failure_groups: 1,
29783            runtime_event_groups: 0,
29784            restart_churn_groups: 0,
29785            closeout_groups: 0,
29786            usage_samples: 1,
29787            prompt_tokens: 120,
29788            cached_input_tokens: 80,
29789            cache_creation_input_tokens: 0,
29790            output_tokens: 40,
29791            reasoning_output_tokens: 0,
29792            total_tokens: 240,
29793            cached_input_ratio: Some(40.0),
29794            largest_turn_total_tokens: 240,
29795            aggregate_cost: session_review::SessionReviewCostSummary {
29796                scope: "bounded_matched_sessions".to_string(),
29797                sessions: 1,
29798                usage_samples: 1,
29799                prompt_tokens: 120,
29800                cached_input_tokens: 80,
29801                cache_creation_input_tokens: 0,
29802                output_tokens: 40,
29803                reasoning_output_tokens: 0,
29804                total_tokens: 240,
29805                cached_input_ratio: Some(40.0),
29806                largest_turn_total_tokens: 240,
29807            },
29808            latest_session_cost: Some(session_review::SessionReviewCostSummary {
29809                scope: "latest_matched_session".to_string(),
29810                sessions: 1,
29811                usage_samples: 1,
29812                prompt_tokens: 120,
29813                cached_input_tokens: 80,
29814                cache_creation_input_tokens: 0,
29815                output_tokens: 40,
29816                reasoning_output_tokens: 0,
29817                total_tokens: 240,
29818                cached_input_ratio: Some(66.67),
29819                largest_turn_total_tokens: 240,
29820            }),
29821            guardrails: vec![
29822                session_cost::SessionCostGuardrail {
29823                    kind: "cache_resend".to_string(),
29824                    severity: "warn".to_string(),
29825                    message: "cached input ratio was high".to_string(),
29826                    guidance: "compact or restart the session".to_string(),
29827                },
29828                session_cost::SessionCostGuardrail {
29829                    kind: "prompt_budget".to_string(),
29830                    severity: "warn".to_string(),
29831                    message: "largest prompt turn reached 999999 tokens".to_string(),
29832                    guidance: "compact the session before another large turn".to_string(),
29833                },
29834                session_cost::SessionCostGuardrail {
29835                    kind: "restart_loop".to_string(),
29836                    severity: "warn".to_string(),
29837                    message: "restart churn detected".to_string(),
29838                    guidance: "restart cleanly".to_string(),
29839                },
29840                session_cost::SessionCostGuardrail {
29841                    kind: "noop_closeout".to_string(),
29842                    severity: "warn".to_string(),
29843                    message: "commit_already_current appeared 8 times".to_string(),
29844                    guidance: "avoid reopening without new edits".to_string(),
29845                },
29846            ],
29847            loop_clusters: vec![],
29848            file_read_diagnostics: vec![],
29849            prompt_targets: vec![
29850                session_review::SessionReviewPromptTarget {
29851                    text: "do one".to_string(),
29852                    occurrences: 1,
29853                },
29854                session_review::SessionReviewPromptTarget {
29855                    text: "do two".to_string(),
29856                    occurrences: 1,
29857                },
29858            ],
29859            commands: vec![],
29860            touched_files: vec![],
29861            touched_symbols: vec![],
29862            failures: vec![],
29863            runtime_events: vec![],
29864            restart_churn: vec![],
29865            closeout: vec![],
29866            largest_turns: vec![],
29867            sessions: vec![session_review::SessionReviewSession {
29868                source: "claude_jsonl".to_string(),
29869                path: "/tmp/session.jsonl".to_string(),
29870                matched_by: vec!["path".to_string()],
29871                modified_unix_secs: None,
29872                prompt_target_count: 2,
29873                command_groups: 0,
29874                file_groups: 2,
29875                symbol_groups: 1,
29876                failure_groups: 1,
29877                runtime_event_groups: 0,
29878                restart_churn_groups: 0,
29879                closeout_groups: 0,
29880                usage_samples: 1,
29881                prompt_tokens: 120,
29882                cached_input_tokens: 80,
29883                cache_creation_input_tokens: 0,
29884                output_tokens: 40,
29885                reasoning_output_tokens: 0,
29886                total_tokens: 240,
29887                largest_turn_total_tokens: 240,
29888            }],
29889            next_context: session_review::SessionReviewNextContext {
29890                target: "tasks/software/tsift.md".to_string(),
29891                active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
29892                last_verification: session_review::SessionReviewVerificationState {
29893                    status: "green".to_string(),
29894                    detail: "cargo test".to_string(),
29895                },
29896                touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
29897                touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
29898                unresolved_failures: vec![session_review::SessionReviewFailure {
29899                    kind: "timeout".to_string(),
29900                    message: "search timed out".to_string(),
29901                    occurrences: 1,
29902                    command: None,
29903                    session_path: None,
29904                }],
29905                next_digest_commands: vec![
29906                    "tsift session-review --next-context tasks/software/tsift.md".to_string(),
29907                    "tsift diff-digest .".to_string(),
29908                    "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
29909                    "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
29910                ],
29911            },
29912            warnings: vec![],
29913        };
29914
29915        let budget_report = build_session_review_next_context_budget_report(
29916            &report,
29917            ResponseBudget::new(Some(1), Some(12)),
29918            None,
29919        );
29920
29921        assert!(budget_report.truncated);
29922        assert_eq!(budget_report.prompt_targets, vec!["do one"]);
29923        assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
29924        assert!(
29925            budget_report.touched_symbol_refs[0]
29926                .handle
29927                .starts_with("ncsym-")
29928        );
29929        assert_eq!(
29930            budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
29931            Some("alpha/helper")
29932        );
29933        assert!(
29934            budget_report.unresolved_failures[0]
29935                .handle
29936                .starts_with("snf-")
29937        );
29938        assert_eq!(budget_report.next_digest_commands.len(), 4);
29939        assert_eq!(
29940            budget_report.next_digest_commands[2],
29941            "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
29942        );
29943        assert_eq!(budget_report.next_token_actions.len(), 1);
29944        assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
29945
29946        let full_action_report = build_session_review_next_context_budget_report(
29947            &report,
29948            ResponseBudget::new(Some(4), Some(120)),
29949            None,
29950        );
29951        assert_eq!(
29952            full_action_report
29953                .next_token_actions
29954                .iter()
29955                .map(|action| action.kind.as_str())
29956                .collect::<Vec<_>>(),
29957            vec![
29958                "prompt_budget",
29959                "cache_resend",
29960                "restart_loop",
29961                "noop_closeout"
29962            ]
29963        );
29964        assert_eq!(
29965            full_action_report.next_token_actions[0]
29966                .compact_command
29967                .as_deref(),
29968            Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
29969        );
29970        assert_eq!(
29971            full_action_report.next_token_actions[0]
29972                .restart_command
29973                .as_deref(),
29974            Some("agent-doc start \"tasks/software/tsift.md\"")
29975        );
29976        assert!(
29977            full_action_report.next_token_actions[0]
29978                .digest_commands
29979                .iter()
29980                .any(|command| command
29981                    == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
29982        );
29983    }
29984
29985    #[test]
29986    fn context_pack_diff_preview_limits_files_and_symbols() {
29987        let report = diff_digest::DiffDigestReport {
29988            root: "/repo".to_string(),
29989            mode: diff_digest::DiffDigestMode::WorkingTree,
29990            revision: None,
29991            files_changed: 2,
29992            files_with_current_summaries: 1,
29993            symbols_touched: 3,
29994            call_edges_added: 1,
29995            call_edges_removed: 0,
29996            files: vec![
29997                diff_digest::DiffDigestFile {
29998                    path: "src/lib.rs".to_string(),
29999                    status: diff_digest::DiffDigestFileStatus::Modified,
30000                    touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
30001                    summary_state: diff_digest::DiffDigestSummaryState::Current,
30002                    current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30003                        symbol: "alpha_helper".to_string(),
30004                        summary: "alpha helper handles the main alpha workflow".to_string(),
30005                    }],
30006                    added_call_edges: vec!["alpha->beta".to_string()],
30007                    removed_call_edges: vec![],
30008                    warnings: vec!["stale parse".to_string()],
30009                },
30010                diff_digest::DiffDigestFile {
30011                    path: "src/main.rs".to_string(),
30012                    status: diff_digest::DiffDigestFileStatus::Added,
30013                    touched_symbols: vec!["main".to_string()],
30014                    summary_state: diff_digest::DiffDigestSummaryState::Missing,
30015                    current_summaries: vec![],
30016                    added_call_edges: vec![],
30017                    removed_call_edges: vec![],
30018                    warnings: vec![],
30019                },
30020            ],
30021        };
30022
30023        let preview =
30024            build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
30025
30026        assert!(preview.truncated);
30027        assert_eq!(preview.files.len(), 1);
30028        assert_eq!(preview.files[0].path, "src/lib.rs");
30029        assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
30030        assert!(
30031            preview.files[0].touched_symbol_refs[0]
30032                .handle
30033                .starts_with("cdsym-")
30034        );
30035        assert_eq!(
30036            preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
30037            Some("alpha/he...")
30038        );
30039        assert!(
30040            preview.files[0].summary_refs[0]
30041                .handle
30042                .starts_with("cdsum-")
30043        );
30044        assert_eq!(
30045            preview.files[0].summary_refs[0].tag_alias.as_deref(),
30046            Some("alpha/he...")
30047        );
30048        assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
30049        assert_eq!(
30050            preview.files[0].summary_refs[0].expand,
30051            "tsift summarize --file \"src/lib.rs\""
30052        );
30053        assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
30054    }
30055
30056    #[test]
30057    fn context_pack_status_reminders_include_stale_index_state() {
30058        let dir = setup_graph_index();
30059        std::thread::sleep(std::time::Duration::from_millis(50));
30060        std::fs::write(
30061            dir.path().join("main.rs"),
30062            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
30063        )
30064        .unwrap();
30065
30066        let reminders = context_pack_status_reminders(dir.path());
30067
30068        assert_eq!(reminders.len(), 1);
30069        assert!(reminders[0].contains("index stale"));
30070        assert!(reminders[0].contains("tsift index ."));
30071    }
30072
30073    // #gdbgatecold regression-lock: the trusted context-pack pipeline must
30074    // share its index-inspection across `prepare_agent_doc_index_gate` and
30075    // `context_pack_status_reminders` (both call `IndexDb::inspect_read_only`
30076    // on the same `(root, .tsift/index.db)` key). With the scope guard
30077    // active in `build_context_pack_report_with_profile`, the second call
30078    // hits the cache, so we should record one miss and at least one hit.
30079    #[test]
30080    fn build_context_pack_reuses_inspect_within_scope() {
30081        let dir = setup_graph_index();
30082        init_git_repo(dir.path());
30083        let _guard = index::InspectScopeGuard::new();
30084        let _ = build_context_pack_report(
30085            dir.path(),
30086            None,
30087            None,
30088            None,
30089            ResponseBudget::new(Some(2), Some(96)),
30090        )
30091        .unwrap();
30092        let (hits, misses) = index::inspect_scope_stats();
30093        assert!(
30094            hits >= 1,
30095            "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
30096        );
30097        assert!(
30098            misses >= 1,
30099            "expected at least one initial inspect miss (hits={hits}, misses={misses})"
30100        );
30101    }
30102
30103    // #gdbgatecold scope-isolation: outside of any scope, every call to
30104    // `IndexDb::inspect_read_only` must hit the disk fresh. This locks in
30105    // the contract that the search/status fast-paths never reuse a cached
30106    // inspection across consecutive top-level calls.
30107    #[test]
30108    fn inspect_read_only_outside_scope_does_not_cache() {
30109        let dir = setup_graph_index();
30110        let db_path = dir.path().join(".tsift/index.db");
30111        let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30112        let (hits, misses) = index::inspect_scope_stats();
30113        assert_eq!(
30114            (hits, misses),
30115            (0, 0),
30116            "no scope guard => no hits/misses recorded"
30117        );
30118        let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
30119        let (hits, _) = index::inspect_scope_stats();
30120        assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
30121    }
30122
30123    #[test]
30124    fn context_pack_refreshes_stale_index_before_handoff() {
30125        let dir = setup_graph_index();
30126        init_git_repo(dir.path());
30127        std::thread::sleep(std::time::Duration::from_millis(50));
30128        std::fs::write(
30129            dir.path().join("main.rs"),
30130            "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
30131        )
30132        .unwrap();
30133
30134        let report = build_context_pack_report(
30135            dir.path(),
30136            None,
30137            None,
30138            None,
30139            ResponseBudget::new(Some(2), Some(96)),
30140        )
30141        .unwrap();
30142
30143        assert!(
30144            report
30145                .status_reminders
30146                .iter()
30147                .any(|reminder| reminder.contains("index refreshed")
30148                    && reminder.contains("context-pack handoff")),
30149            "expected context-pack refresh diagnostic, got {:?}",
30150            report.status_reminders
30151        );
30152        assert!(
30153            !report
30154                .status_reminders
30155                .iter()
30156                .any(|reminder| reminder.contains("index stale")),
30157            "stale reminder should be gone after refresh: {:?}",
30158            report.status_reminders
30159        );
30160
30161        let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
30162        let summary = db.compute_changes(dir.path()).unwrap();
30163        assert_eq!(summary.new + summary.modified + summary.deleted, 0);
30164    }
30165
30166    #[test]
30167    fn context_pack_materializes_source_handles_into_graph_store() {
30168        let dir = tempfile::tempdir().unwrap();
30169        let packet = ExplorationPacket {
30170            budget: exploration_budget_for_counts(2, 1),
30171            relationship_map: vec![ExplorationRelation {
30172                from: "file:main.rs".to_string(),
30173                relation: "touches_symbol".to_string(),
30174                to: "symbol:helper".to_string(),
30175                label: Some("modified diff".to_string()),
30176            }],
30177            source_windows: vec![ExplorationSourceWindow {
30178                handle: "xwin-test".to_string(),
30179                file: "main.rs".to_string(),
30180                start: 1,
30181                end: 32,
30182                reason: "changed file".to_string(),
30183                expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
30184            }],
30185            worker_context: vec![ExplorationWorkerContext {
30186                handle: "xwrk-test".to_string(),
30187                target: "tasks/software/tsift.md".to_string(),
30188                summary: "do #kgnv".to_string(),
30189                expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
30190                    .to_string(),
30191            }],
30192            no_reread_guidance: "use windows".to_string(),
30193        };
30194
30195        let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
30196        assert_eq!(packet.source_windows[0].handle, "xwin-test");
30197
30198        let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
30199        let source_handles = store.nodes_by_kind("source_handle").unwrap();
30200        assert_eq!(source_handles.len(), 1);
30201        assert_eq!(
30202            source_handles[0].properties.get("file"),
30203            Some(&"main.rs".to_string())
30204        );
30205        assert_eq!(
30206            store
30207                .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
30208                .unwrap()
30209                .len(),
30210            1
30211        );
30212        let worker_context = store.nodes_by_kind("worker_context").unwrap();
30213        assert_eq!(worker_context.len(), 1);
30214        assert_eq!(
30215            store
30216                .outgoing_edges("xwrk-test", Some("scopes_source"))
30217                .unwrap()
30218                .len(),
30219            1
30220        );
30221    }
30222
30223    #[test]
30224    fn context_pack_records_graph_orchestration_observability() {
30225        let dir = setup_traversal_project();
30226        init_git_repo(dir.path());
30227        let session = dir.path().join("tasks/software/tsift.md");
30228        refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
30229
30230        let report = build_context_pack_report(
30231            &session,
30232            None,
30233            None,
30234            None,
30235            ResponseBudget::new(Some(4), Some(160)),
30236        )
30237        .unwrap();
30238
30239        assert_eq!(
30240            report.graph_orchestration.contract_version,
30241            CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
30242        );
30243        assert_eq!(
30244            report
30245                .graph_orchestration
30246                .projection_freshness
30247                .status
30248                .as_str(),
30249            "current"
30250        );
30251        assert!(!report.graph_orchestration.projection_hashes.is_empty());
30252        assert_eq!(report.graph_orchestration.readiness.status, "blocked");
30253        assert_eq!(
30254            report.graph_orchestration.readiness.reason,
30255            "summary_cache_empty"
30256        );
30257        assert!(report.graph_orchestration.readiness.fail_closed);
30258        assert!(
30259            report
30260                .graph_orchestration
30261                .readiness
30262                .next_commands
30263                .iter()
30264                .any(|command| command == "tsift summarize --extract ."),
30265            "{:?}",
30266            report.graph_orchestration.readiness.next_commands
30267        );
30268        assert!(
30269            report
30270                .graph_orchestration
30271                .evidence_packet_ids
30272                .iter()
30273                .all(|id| !id.starts_with("gevd-")),
30274            "evidence packet ids should be empty when readiness is blocked: {:?}",
30275            report.graph_orchestration.evidence_packet_ids
30276        );
30277        assert!(
30278            report
30279                .graph_orchestration
30280                .conflict_matrix_decisions
30281                .iter()
30282                .any(|decision| decision.contains("readiness blocked")),
30283            "conflict-matrix decisions should reference readiness block: {:?}",
30284            report.graph_orchestration.conflict_matrix_decisions
30285        );
30286        assert!(
30287            !report
30288                .graph_orchestration
30289                .follow_up_commands
30290                .iter()
30291                .any(|command| command.contains("conflict-matrix")),
30292            "conflict-matrix command should not appear when readiness is blocked: {:?}",
30293            report.graph_orchestration.follow_up_commands
30294        );
30295        assert!(
30296            report
30297                .graph_orchestration
30298                .follow_up_commands
30299                .iter()
30300                .any(|command| command == "tsift summarize --extract ."),
30301            "{:?}",
30302            report.graph_orchestration.follow_up_commands
30303        );
30304        assert!(
30305            !report
30306                .graph_orchestration
30307                .worker_ownership_blocks
30308                .is_empty()
30309        );
30310    }
30311
30312    #[test]
30313    fn convex_sync_report_chunks_upserts_and_tombstones() {
30314        let dir = setup_traversal_project();
30315        let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
30316        let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
30317        let mut snapshot = projection.to_convex_rows();
30318        snapshot.nodes.push(ConvexNodeRow {
30319            external_id: "stale-node".to_string(),
30320            kind: "backlog".to_string(),
30321            label: "stale".to_string(),
30322            properties: BTreeMap::new(),
30323            provenance: Vec::new(),
30324            freshness: None,
30325        });
30326        snapshot.edges.clear();
30327        snapshot.edges.push(ConvexEdgeRow {
30328            edge_key: "stale-edge".to_string(),
30329            from_external_id: "stale-node".to_string(),
30330            to_external_id: "stale-node".to_string(),
30331            kind: "mentions".to_string(),
30332            properties: BTreeMap::new(),
30333            provenance: Vec::new(),
30334            freshness: None,
30335        });
30336        let snapshot_path = dir.path().join("convex-snapshot.json");
30337        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30338
30339        let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
30340
30341        assert_eq!(report.freshness.status, "stale");
30342        assert!(report.freshness.fail_closed);
30343        assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
30344        assert!(
30345            report.edge_upserts.len() > 1,
30346            "snapshot without edges should upsert local edges"
30347        );
30348        assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
30349        assert_eq!(
30350            report.chunks.first().map(|chunk| chunk.operation.as_str()),
30351            Some("delete_edges"),
30352            "edge tombstones should be planned before node tombstones"
30353        );
30354        assert!(
30355            report
30356                .chunks
30357                .iter()
30358                .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
30359            "expected chunked edge upserts, got {:?}",
30360            report.chunks
30361        );
30362    }
30363
30364    #[test]
30365    fn convex_snapshot_validation_fails_closed_when_stale() {
30366        let dir = setup_traversal_project();
30367        build_traversal_graph(dir.path(), dir.path(), None).unwrap();
30368        let snapshot = ConvexProjectionRows::default();
30369        let snapshot_path = dir.path().join("empty-convex-snapshot.json");
30370        fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
30371
30372        let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
30373        assert!(
30374            err.to_string()
30375                .contains("Convex graph projection is not current"),
30376            "{err}"
30377        );
30378    }
30379
30380    #[test]
30381    fn convex_sync_report_marks_live_apply_mode_without_network() {
30382        let dir = setup_traversal_project();
30383        let report =
30384            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30385
30386        assert!(!report.dry_run);
30387        assert!(
30388            !report
30389                .diagnostics
30390                .iter()
30391                .any(|diagnostic| diagnostic.contains("dry-run only")),
30392            "apply-mode report should not claim dry-run diagnostics"
30393        );
30394        assert!(
30395            report
30396                .chunks
30397                .iter()
30398                .any(|chunk| chunk.operation == "upsert_nodes"),
30399            "live apply mode should still expose chunked idempotent operations"
30400        );
30401    }
30402
30403    #[test]
30404    fn convex_sync_apply_round_trips_with_http_backend() {
30405        use std::net::TcpListener;
30406        use std::sync::{Arc, Mutex};
30407
30408        let dir = setup_traversal_project();
30409        let report =
30410            build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
30411        let expected_chunks = report.chunks.len();
30412        assert!(expected_chunks > 0);
30413
30414        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
30415        let endpoint = format!("http://{}", listener.local_addr().unwrap());
30416        let operations = Arc::new(Mutex::new(Vec::<String>::new()));
30417        let server_operations = Arc::clone(&operations);
30418        let server = std::thread::spawn(move || {
30419            for _ in 0..expected_chunks {
30420                let (mut stream, _) = listener.accept().unwrap();
30421                let mut reader = BufReader::new(stream.try_clone().unwrap());
30422                let mut request_line = String::new();
30423                reader.read_line(&mut request_line).unwrap();
30424                assert!(request_line.starts_with("POST "));
30425
30426                let mut content_length = 0usize;
30427                loop {
30428                    let mut line = String::new();
30429                    reader.read_line(&mut line).unwrap();
30430                    if line == "\r\n" {
30431                        break;
30432                    }
30433                    if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
30434                        content_length = value.trim().parse().unwrap();
30435                    }
30436                }
30437
30438                let mut body = vec![0u8; content_length];
30439                reader.read_exact(&mut body).unwrap();
30440                let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
30441                server_operations
30442                    .lock()
30443                    .unwrap()
30444                    .push(request["operation"].as_str().unwrap().to_string());
30445
30446                let response = br#"{"status":"ok","message":"accepted"}"#;
30447                write!(
30448                    stream,
30449                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
30450                    response.len()
30451                )
30452                .unwrap();
30453                stream.write_all(response).unwrap();
30454            }
30455        });
30456
30457        cmd_convex_sync(
30458            ConvexSyncOptions {
30459                path: dir.path(),
30460                scope: None,
30461                snapshot: None,
30462                chunk_size: 100,
30463                remote_snapshot: false,
30464                apply: true,
30465                endpoint: Some(&endpoint),
30466                auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
30467            },
30468            OutputFormat {
30469                json_output: false,
30470                compact: true,
30471                pretty: false,
30472                terse: false,
30473                ultra_terse: false,
30474                schema: false,
30475                envelope: false,
30476            },
30477        )
30478        .unwrap();
30479        server.join().unwrap();
30480
30481        let operations = operations.lock().unwrap().clone();
30482        assert!(operations.contains(&"upsert_nodes".to_string()));
30483        assert!(operations.contains(&"upsert_edges".to_string()));
30484    }
30485
30486    #[test]
30487    fn context_pack_diff_preview_attaches_tag_ontology_refs() {
30488        let root = tempfile::tempdir().unwrap();
30489        fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
30490        fs::write(
30491            root.path().join(".naming/tags/alpha.md"),
30492            "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
30493        )
30494        .unwrap();
30495        let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
30496        let report = diff_digest::DiffDigestReport {
30497            root: root.path().display().to_string(),
30498            mode: diff_digest::DiffDigestMode::WorkingTree,
30499            revision: None,
30500            files_changed: 1,
30501            files_with_current_summaries: 1,
30502            symbols_touched: 1,
30503            call_edges_added: 0,
30504            call_edges_removed: 0,
30505            files: vec![diff_digest::DiffDigestFile {
30506                path: "src/lib.rs".to_string(),
30507                status: diff_digest::DiffDigestFileStatus::Modified,
30508                touched_symbols: vec!["alpha_helper".to_string()],
30509                summary_state: diff_digest::DiffDigestSummaryState::Current,
30510                current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
30511                    symbol: "alpha_helper".to_string(),
30512                    summary: "alpha helper summary".to_string(),
30513                }],
30514                added_call_edges: vec![],
30515                removed_call_edges: vec![],
30516                warnings: vec![],
30517            }],
30518        };
30519
30520        let preview = build_context_pack_diff_preview(
30521            &report,
30522            ResponseBudget::new(Some(1), Some(80)),
30523            Some(&ontology),
30524        );
30525
30526        let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
30527        assert!(symbol_ref.handle.starts_with("tont-"));
30528        assert_eq!(symbol_ref.tag, "alpha");
30529        assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
30530        assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
30531        assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
30532        assert_eq!(
30533            preview.files[0].summary_refs[0].ontology_refs[0].path,
30534            ".naming/tags/alpha.md"
30535        );
30536    }
30537
30538    #[test]
30539    fn context_pack_test_preview_limits_failure_groups() {
30540        let report = test_digest::TestDigestReport {
30541            root: "/repo".to_string(),
30542            runner: "cargo".to_string(),
30543            failures: 2,
30544            grouped_failures: 2,
30545            counts: test_digest::TestDigestCounts {
30546                passed: Some(8),
30547                failed: Some(2),
30548                skipped: Some(1),
30549            },
30550            failure_groups: vec![
30551                test_digest::TestDigestFailure {
30552                    tests: vec!["suite::alpha_failure".to_string()],
30553                    message: "assertion failed".to_string(),
30554                    path: Some("src/lib.rs".to_string()),
30555                    line: Some(42),
30556                    column: None,
30557                    occurrences: 1,
30558                    summary_state: test_digest::TestDigestSummaryState::Current,
30559                    current_summaries: vec![test_digest::TestDigestSummarySnippet {
30560                        symbol: "alpha_failure".to_string(),
30561                        summary: "failure summary for alpha test".to_string(),
30562                    }],
30563                },
30564                test_digest::TestDigestFailure {
30565                    tests: vec!["suite::beta_failure".to_string()],
30566                    message: "panic".to_string(),
30567                    path: Some("src/main.rs".to_string()),
30568                    line: Some(7),
30569                    column: None,
30570                    occurrences: 1,
30571                    summary_state: test_digest::TestDigestSummaryState::Missing,
30572                    current_summaries: vec![],
30573                },
30574            ],
30575            warnings: vec!["warning text".to_string()],
30576        };
30577
30578        let preview =
30579            build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30580
30581        assert!(preview.truncated);
30582        assert_eq!(preview.failure_groups.len(), 1);
30583        assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
30584        assert_eq!(preview.failure_groups[0].message, "assertion f...");
30585        assert!(
30586            preview.failure_groups[0].summary_refs[0]
30587                .handle
30588                .starts_with("ctsum-")
30589        );
30590        assert_eq!(
30591            preview.failure_groups[0].summary_refs[0].expand,
30592            "tsift summarize --file \"src/lib.rs\""
30593        );
30594        assert_eq!(preview.warnings, vec!["warning text"]);
30595    }
30596
30597    #[test]
30598    fn context_pack_log_preview_limits_signals_and_refs() {
30599        let report = log_digest::LogDigestReport {
30600            root: "/repo".to_string(),
30601            total_lines: 12,
30602            non_empty_lines: 10,
30603            signal_groups: 2,
30604            repeated_line_groups: 2,
30605            repeated_line_occurrences: 3,
30606            file_ref_groups: 2,
30607            symbol_ref_groups: 2,
30608            stack_groups: 1,
30609            signals: vec![
30610                log_digest::LogDigestSignal {
30611                    severity: "error".to_string(),
30612                    message: "src/lib.rs:42 boom".to_string(),
30613                    path: Some("src/lib.rs".to_string()),
30614                    line: Some(42),
30615                    column: None,
30616                    occurrences: 2,
30617                    summary_state: log_digest::LogDigestSummaryState::Current,
30618                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
30619                        symbol: "alpha_helper".to_string(),
30620                        summary: "alpha helper cached log summary".to_string(),
30621                    }],
30622                },
30623                log_digest::LogDigestSignal {
30624                    severity: "warn".to_string(),
30625                    message: "slow path".to_string(),
30626                    path: None,
30627                    line: None,
30628                    column: None,
30629                    occurrences: 1,
30630                    summary_state: log_digest::LogDigestSummaryState::Unavailable,
30631                    current_summaries: vec![],
30632                },
30633            ],
30634            repeated_lines: vec![
30635                log_digest::LogDigestRepeatedLine {
30636                    line: "retrying work item alpha".to_string(),
30637                    occurrences: 3,
30638                },
30639                log_digest::LogDigestRepeatedLine {
30640                    line: "retrying work item beta".to_string(),
30641                    occurrences: 2,
30642                },
30643            ],
30644            file_refs: vec![
30645                log_digest::LogDigestFileRef {
30646                    path: "src/lib.rs".to_string(),
30647                    line: Some(42),
30648                    column: None,
30649                    occurrences: 2,
30650                    summary_state: log_digest::LogDigestSummaryState::Current,
30651                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
30652                        symbol: "alpha_helper".to_string(),
30653                        summary: "alpha helper cached file summary".to_string(),
30654                    }],
30655                },
30656                log_digest::LogDigestFileRef {
30657                    path: "src/main.rs".to_string(),
30658                    line: Some(7),
30659                    column: None,
30660                    occurrences: 1,
30661                    summary_state: log_digest::LogDigestSummaryState::Missing,
30662                    current_summaries: vec![],
30663                },
30664            ],
30665            symbol_refs: vec![
30666                log_digest::LogDigestSymbolRef {
30667                    symbol: "alpha_helper".to_string(),
30668                    occurrences: 2,
30669                    summary_state: log_digest::LogDigestSummaryState::Current,
30670                    current_summaries: vec![log_digest::LogDigestSummarySnippet {
30671                        symbol: "alpha_helper".to_string(),
30672                        summary: "alpha helper cached symbol summary".to_string(),
30673                    }],
30674                },
30675                log_digest::LogDigestSymbolRef {
30676                    symbol: "beta_helper".to_string(),
30677                    occurrences: 1,
30678                    summary_state: log_digest::LogDigestSummaryState::Missing,
30679                    current_summaries: vec![],
30680                },
30681            ],
30682            stack_traces: vec![log_digest::LogDigestStackGroup {
30683                frames: vec!["frame one".to_string()],
30684                occurrences: 1,
30685            }],
30686            warnings: vec!["warning text".to_string()],
30687        };
30688
30689        let preview =
30690            build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
30691
30692        assert!(preview.truncated);
30693        assert_eq!(preview.signals.len(), 1);
30694        assert_eq!(preview.signals[0].message, "src/lib.rs:...");
30695        assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
30696        assert_eq!(preview.file_refs.len(), 1);
30697        assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
30698        assert!(
30699            preview.signals[0].summary_refs[0]
30700                .handle
30701                .starts_with("clsum-")
30702        );
30703        assert!(
30704            preview.file_refs[0].summary_refs[0]
30705                .handle
30706                .starts_with("clfsum-")
30707        );
30708        assert!(
30709            preview.symbol_refs[0].summary_refs[0]
30710                .handle
30711                .starts_with("clssum-")
30712        );
30713        assert_eq!(
30714            preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
30715            Some("alpha/helper")
30716        );
30717        assert_eq!(
30718            preview.symbol_refs[0].summary_refs[0].expand,
30719            "tsift summarize \"alpha_helper\""
30720        );
30721        assert_eq!(preview.warnings, vec!["warning text"]);
30722    }
30723
30724    #[test]
30725    fn cli_search_rejects_exact_with_strategy_flag() {
30726        let cli = try_parse_cli([
30727            "tsift",
30728            "search",
30729            "test",
30730            "--exact",
30731            "--strategy",
30732            "lexical",
30733        ]);
30734        assert!(cli.is_err());
30735    }
30736
30737    #[test]
30738    fn cli_search_autoindexes_by_default() {
30739        let cli = parse_cli(["tsift", "search", "test"]);
30740        match cli.command {
30741            Some(Commands::Search {
30742                autoindex,
30743                no_autoindex,
30744                ..
30745            }) => {
30746                assert!(!autoindex);
30747                assert!(!no_autoindex);
30748                assert!(autoindex || !no_autoindex);
30749            }
30750            _ => panic!("expected Search command"),
30751        }
30752    }
30753
30754    #[test]
30755    fn cli_search_accepts_no_autoindex_flag() {
30756        let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
30757        match cli.command {
30758            Some(Commands::Search {
30759                autoindex,
30760                no_autoindex,
30761                ..
30762            }) => {
30763                assert!(!autoindex);
30764                assert!(no_autoindex);
30765            }
30766            _ => panic!("expected Search command"),
30767        }
30768    }
30769
30770    #[test]
30771    fn cli_search_rejects_conflicting_autoindex_flags() {
30772        let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
30773        assert!(cli.is_err());
30774    }
30775
30776    // --- relativize paths ---
30777
30778    #[test]
30779    fn cli_accepts_global_absolute_flag() {
30780        let cli = parse_cli(["tsift", "--absolute", "status"]);
30781        assert!(cli.absolute);
30782        assert!(matches!(cli.command, Some(Commands::Status { .. })));
30783    }
30784
30785    #[test]
30786    fn cli_accepts_global_tabular_flag() {
30787        let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
30788        assert!(cli.tabular);
30789        assert!(matches!(cli.command, Some(Commands::Search { .. })));
30790    }
30791
30792    #[test]
30793    fn cli_tabular_with_graph() {
30794        let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
30795        assert!(cli.tabular);
30796        assert!(matches!(cli.command, Some(Commands::Graph { .. })));
30797    }
30798
30799    #[test]
30800    fn cli_tabular_with_communities() {
30801        let cli = parse_cli(["tsift", "--tabular", "communities"]);
30802        assert!(cli.tabular);
30803        assert!(matches!(cli.command, Some(Commands::Communities { .. })));
30804    }
30805
30806    #[test]
30807    fn cli_tabular_with_explain() {
30808        let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
30809        assert!(cli.tabular);
30810        assert!(matches!(cli.command, Some(Commands::Explain { .. })));
30811    }
30812
30813    #[test]
30814    fn cli_traverse_accepts_path_target_and_html_format() {
30815        let cli = parse_cli([
30816            "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
30817        ]);
30818        match cli.command {
30819            Some(Commands::Traverse {
30820                node,
30821                to,
30822                path,
30823                format,
30824                ..
30825            }) => {
30826                assert_eq!(node.as_deref(), Some("#kgnv"));
30827                assert_eq!(to.as_deref(), Some("main"));
30828                assert_eq!(path, PathBuf::from("."));
30829                assert_eq!(format, TraverseFormat::Html);
30830            }
30831            _ => panic!("expected Traverse command"),
30832        }
30833    }
30834
30835    #[test]
30836    fn cli_parses_semantic_related_command() {
30837        let cli = parse_cli([
30838            "tsift",
30839            "semantic",
30840            "graph navigation",
30841            "--path",
30842            ".",
30843            "--kind",
30844            "all",
30845            "--limit",
30846            "3",
30847            "--json",
30848        ]);
30849        match cli.command {
30850            Some(Commands::Semantic {
30851                query,
30852                path,
30853                kind,
30854                limit,
30855                json,
30856                ..
30857            }) => {
30858                assert_eq!(query, "graph navigation");
30859                assert_eq!(path, PathBuf::from("."));
30860                assert_eq!(kind, SemanticRelatedKind::All);
30861                assert_eq!(limit, 3);
30862                assert!(json);
30863            }
30864            _ => panic!("expected Semantic command"),
30865        }
30866    }
30867
30868    #[test]
30869    fn cli_parses_convex_sync_command() {
30870        let cli = parse_cli([
30871            "tsift",
30872            "convex-sync",
30873            ".",
30874            "--snapshot",
30875            "rows.json",
30876            "--chunk-size",
30877            "25",
30878            "--json",
30879        ]);
30880        match cli.command {
30881            Some(Commands::ConvexSync {
30882                path,
30883                snapshot,
30884                chunk_size,
30885                json,
30886                ..
30887            }) => {
30888                assert_eq!(path, PathBuf::from("."));
30889                assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
30890                assert_eq!(chunk_size, 25);
30891                assert!(json);
30892            }
30893            _ => panic!("expected ConvexSync command"),
30894        }
30895    }
30896
30897    #[test]
30898    fn cli_parses_convex_sync_live_flags() {
30899        let cli = parse_cli([
30900            "tsift",
30901            "convex-sync",
30902            ".",
30903            "--remote-snapshot",
30904            "--apply",
30905            "--endpoint",
30906            "https://example.test/convex-graph",
30907            "--auth-token-env",
30908            "TSIFT_TEST_TOKEN",
30909        ]);
30910        match cli.command {
30911            Some(Commands::ConvexSync {
30912                remote_snapshot,
30913                apply,
30914                endpoint,
30915                auth_token_env,
30916                ..
30917            }) => {
30918                assert!(remote_snapshot);
30919                assert!(apply);
30920                assert_eq!(
30921                    endpoint.as_deref(),
30922                    Some("https://example.test/convex-graph")
30923                );
30924                assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
30925            }
30926            _ => panic!("expected ConvexSync command"),
30927        }
30928    }
30929
30930    #[test]
30931    fn cli_parses_graph_db_query() {
30932        let cli = parse_cli([
30933            "tsift",
30934            "graph-db",
30935            "--backend",
30936            "convex-snapshot",
30937            "--convex-snapshot",
30938            "rows.json",
30939            "--json",
30940            "neighborhood",
30941            "gbak-kgnv",
30942            "--depth",
30943            "2",
30944            "--edge-kind",
30945            "mentions",
30946            "--property",
30947            "path=tasks/software/tsift.md",
30948            "--cursor",
30949            "gbak-old",
30950            "--limit",
30951            "10",
30952        ]);
30953        match cli.command {
30954            Some(Commands::GraphDb {
30955                backend,
30956                convex_snapshot,
30957                json,
30958                query,
30959                ..
30960            }) => {
30961                assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
30962                assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
30963                assert!(json);
30964                match query {
30965                    GraphDbQuery::Neighborhood {
30966                        id,
30967                        depth,
30968                        edge_kind,
30969                        cursor,
30970                        limit,
30971                        property_filters,
30972                    } => {
30973                        assert_eq!(id, "gbak-kgnv");
30974                        assert_eq!(depth, 2);
30975                        assert_eq!(edge_kind.as_deref(), Some("mentions"));
30976                        assert_eq!(cursor.as_deref(), Some("gbak-old"));
30977                        assert_eq!(limit, Some(10));
30978                        assert_eq!(
30979                            property_filters,
30980                            vec!["path=tasks/software/tsift.md".to_string()]
30981                        );
30982                    }
30983                    _ => panic!("expected graph-db neighborhood query"),
30984                }
30985            }
30986            _ => panic!("expected GraphDb command"),
30987        }
30988    }
30989
30990    #[test]
30991    fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
30992        let cli = parse_cli([
30993            "tsift",
30994            "graph-db",
30995            "--json",
30996            "backend-eval",
30997            "--candidate",
30998            "surrealdb",
30999            "--target",
31000            "gval",
31001            "--full-projection",
31002        ]);
31003        match cli.command {
31004            Some(Commands::GraphDb { json, query, .. }) => {
31005                assert!(json);
31006                match query {
31007                    GraphDbQuery::BackendEval {
31008                        candidates,
31009                        targets,
31010                        full_projection,
31011                    } => {
31012                        assert_eq!(candidates, vec!["surrealdb".to_string()]);
31013                        assert_eq!(targets, vec!["gval".to_string()]);
31014                        assert!(full_projection);
31015                    }
31016                    _ => panic!("expected graph-db backend-eval query"),
31017                }
31018            }
31019            _ => panic!("expected GraphDb command"),
31020        }
31021    }
31022
31023    #[test]
31024    fn cli_parses_graph_db_tokensave_backend() {
31025        let cli = parse_cli([
31026            "tsift",
31027            "graph-db",
31028            "--backend",
31029            "tokensave",
31030            "--json",
31031            "node",
31032            "fn:main",
31033        ]);
31034        match cli.command {
31035            Some(Commands::GraphDb {
31036                backend,
31037                json,
31038                query,
31039                ..
31040            }) => {
31041                assert_eq!(backend, GraphDbBackend::Tokensave);
31042                assert!(json);
31043                match query {
31044                    GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
31045                    _ => panic!("expected graph-db node query"),
31046                }
31047            }
31048            _ => panic!("expected GraphDb command"),
31049        }
31050    }
31051
31052    #[test]
31053    fn cli_parses_analyze_command() {
31054        let cli = parse_cli([
31055            "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
31056            "--limit", "7", "--json",
31057        ]);
31058        match cli.command {
31059            Some(Commands::Analyze {
31060                path,
31061                scope,
31062                entry_points,
31063                limit,
31064                json,
31065            }) => {
31066                assert_eq!(path, PathBuf::from("."));
31067                assert_eq!(scope.as_deref(), Some("core"));
31068                assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
31069                assert_eq!(limit, 7);
31070                assert!(json);
31071            }
31072            _ => panic!("expected Analyze command"),
31073        }
31074    }
31075
31076    #[test]
31077    fn cli_parses_graph_db_related_query() {
31078        let cli = parse_cli([
31079            "tsift",
31080            "graph-db",
31081            "--json",
31082            "related",
31083            "voice avatar memory retrieval",
31084            "--kind",
31085            "all",
31086            "--depth",
31087            "3",
31088            "--seed-limit",
31089            "4",
31090            "--limit",
31091            "12",
31092        ]);
31093        match cli.command {
31094            Some(Commands::GraphDb { json, query, .. }) => {
31095                assert!(json);
31096                match query {
31097                    GraphDbQuery::Related {
31098                        query,
31099                        kind,
31100                        depth,
31101                        seed_limit,
31102                        limit,
31103                    } => {
31104                        assert_eq!(query, "voice avatar memory retrieval");
31105                        assert_eq!(kind, SemanticRelatedKind::All);
31106                        assert_eq!(depth, 3);
31107                        assert_eq!(seed_limit, 4);
31108                        assert_eq!(limit, 12);
31109                    }
31110                    _ => panic!("expected graph-db related query"),
31111                }
31112            }
31113            _ => panic!("expected GraphDb command"),
31114        }
31115    }
31116
31117    #[test]
31118    fn cli_parses_graph_db_compact_query() {
31119        let cli = parse_cli([
31120            "tsift",
31121            "graph-db",
31122            "--path",
31123            ".",
31124            "compact",
31125            "--apply",
31126            "--prune-tombstones",
31127            "--confirmed-convex-reconciled",
31128        ]);
31129        match cli.command {
31130            Some(Commands::GraphDb { query, .. }) => match query {
31131                GraphDbQuery::Compact {
31132                    apply,
31133                    prune_tombstones,
31134                    confirmed_convex_reconciled,
31135                } => {
31136                    assert!(apply);
31137                    assert!(prune_tombstones);
31138                    assert!(confirmed_convex_reconciled);
31139                }
31140                _ => panic!("expected graph-db compact query"),
31141            },
31142            _ => panic!("expected GraphDb command"),
31143        }
31144    }
31145
31146    #[test]
31147    fn cli_parses_impact_command() {
31148        let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
31149        match cli.command {
31150            Some(Commands::Impact {
31151                path,
31152                cached,
31153                limit,
31154                ..
31155            }) => {
31156                assert_eq!(path, PathBuf::from("."));
31157                assert!(cached);
31158                assert_eq!(limit, 5);
31159            }
31160            _ => panic!("expected Impact command"),
31161        }
31162    }
31163
31164    #[test]
31165    fn cli_parses_conflict_matrix_command() {
31166        let cli = parse_cli([
31167            "tsift",
31168            "conflict-matrix",
31169            "--path",
31170            "tasks/software/tsift.md",
31171            "--depth",
31172            "4",
31173            "--limit",
31174            "12",
31175            "--impact-limit",
31176            "6",
31177            "--json",
31178            "pwcm",
31179            "#g6kf",
31180        ]);
31181        match cli.command {
31182            Some(Commands::ConflictMatrix {
31183                targets,
31184                path,
31185                depth,
31186                limit,
31187                impact_limit,
31188                json,
31189                ..
31190            }) => {
31191                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31192                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31193                assert_eq!(depth, 4);
31194                assert_eq!(limit, 12);
31195                assert_eq!(impact_limit, 6);
31196                assert!(json);
31197            }
31198            _ => panic!("expected ConflictMatrix command"),
31199        }
31200    }
31201
31202    #[test]
31203    fn cli_parses_dispatch_trace_command() {
31204        let cli = parse_cli([
31205            "tsift",
31206            "dispatch-trace",
31207            "--path",
31208            "tasks/software/tsift.md",
31209            "--format",
31210            "html",
31211            "--depth",
31212            "4",
31213            "pwcm",
31214            "#g6kf",
31215        ]);
31216        match cli.command {
31217            Some(Commands::DispatchTrace {
31218                targets,
31219                path,
31220                format,
31221                depth,
31222                ..
31223            }) => {
31224                assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
31225                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31226                assert_eq!(format, DispatchTraceFormat::Html);
31227                assert_eq!(depth, 4);
31228            }
31229            _ => panic!("expected DispatchTrace command"),
31230        }
31231    }
31232
31233    #[test]
31234    fn cli_parses_dependency_dag_command() {
31235        let cli = parse_cli([
31236            "tsift",
31237            "dependency-dag",
31238            "--path",
31239            "tasks/software/tsift.md",
31240            "--depth",
31241            "5",
31242            "--limit",
31243            "20",
31244            "--json",
31245            "alpha",
31246            "#beta",
31247        ]);
31248        match cli.command {
31249            Some(Commands::DependencyDag {
31250                targets,
31251                path,
31252                depth,
31253                limit,
31254                json,
31255                ..
31256            }) => {
31257                assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
31258                assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
31259                assert_eq!(depth, 5);
31260                assert_eq!(limit, 20);
31261                assert!(json);
31262            }
31263            _ => panic!("expected DependencyDag command"),
31264        }
31265    }
31266
31267    #[test]
31268    fn relativize_strips_root_prefix() {
31269        let root = std::path::Path::new("/home/user/project");
31270        assert_eq!(
31271            relativize("/home/user/project/src/main.rs", root),
31272            "src/main.rs"
31273        );
31274    }
31275
31276    #[test]
31277    fn relativize_leaves_non_matching_path() {
31278        let root = std::path::Path::new("/home/user/project");
31279        assert_eq!(
31280            relativize("/other/path/file.rs", root),
31281            "/other/path/file.rs"
31282        );
31283    }
31284
31285    #[test]
31286    fn relativize_leaves_already_relative() {
31287        let root = std::path::Path::new("/home/user/project");
31288        assert_eq!(relativize("src/main.rs", root), "src/main.rs");
31289    }
31290
31291    #[test]
31292    fn relativize_pathbuf_strips_prefix() {
31293        let root = std::path::Path::new("/home/user/project");
31294        let path = std::path::Path::new("/home/user/project/src/lib.rs");
31295        assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
31296    }
31297
31298    #[test]
31299    fn relativize_edges_strips_caller_file() {
31300        let root = std::path::Path::new("/tmp/proj");
31301        let mut edges = vec![index::StoredEdge {
31302            caller_file: "/tmp/proj/src/main.rs".to_string(),
31303            caller_name: "main".to_string(),
31304            caller_line: 1,
31305            callee_name: "helper".to_string(),
31306            call_site_line: 5,
31307            tagpath_handle: None,
31308        }];
31309        relativize_edges(&mut edges, root);
31310        assert_eq!(edges[0].caller_file, "src/main.rs");
31311    }
31312
31313    #[test]
31314    fn relativize_json_paths_strips_known_keys() {
31315        let root = std::path::Path::new("/tmp/proj");
31316        let mut val = serde_json::json!({
31317            "file": "/tmp/proj/src/main.rs",
31318            "path": "/tmp/proj/test.rs",
31319            "name": "/tmp/proj/not-a-path",
31320            "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
31321        });
31322        relativize_json_paths(&mut val, root);
31323        assert_eq!(val["file"], "src/main.rs");
31324        assert_eq!(val["path"], "test.rs");
31325        assert_eq!(val["name"], "/tmp/proj/not-a-path");
31326        assert_eq!(val["hits"][0]["path"], "nested.rs");
31327    }
31328
31329    // --- limit caps ---
31330
31331    #[test]
31332    fn cli_graph_accepts_limit_flag() {
31333        let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
31334        match cli.command {
31335            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
31336            _ => panic!("expected Graph command"),
31337        }
31338    }
31339
31340    #[test]
31341    fn cli_graph_default_limit_is_20() {
31342        let cli = parse_cli(["tsift", "graph", "main"]);
31343        match cli.command {
31344            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
31345            _ => panic!("expected Graph command"),
31346        }
31347    }
31348
31349    #[test]
31350    fn cli_communities_accepts_limit_flag() {
31351        let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
31352        match cli.command {
31353            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
31354            _ => panic!("expected Communities command"),
31355        }
31356    }
31357
31358    #[test]
31359    fn cli_communities_default_limit_is_10() {
31360        let cli = parse_cli(["tsift", "communities"]);
31361        match cli.command {
31362            Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
31363            _ => panic!("expected Communities command"),
31364        }
31365    }
31366
31367    #[test]
31368    fn cli_explain_accepts_limit_flag() {
31369        let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
31370        match cli.command {
31371            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
31372            _ => panic!("expected Explain command"),
31373        }
31374    }
31375
31376    #[test]
31377    fn cli_explain_default_limit_is_15() {
31378        let cli = parse_cli(["tsift", "explain", "main"]);
31379        match cli.command {
31380            Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
31381            _ => panic!("expected Explain command"),
31382        }
31383    }
31384
31385    #[test]
31386    fn cli_limit_zero_means_unlimited() {
31387        let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
31388        match cli.command {
31389            Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
31390            _ => panic!("expected Graph command"),
31391        }
31392    }
31393
31394    #[test]
31395    fn graph_cmd_limit_runs_ok() {
31396        let dir = setup_graph_index();
31397        let result = cmd_graph(
31398            "main",
31399            dir.path(),
31400            false,
31401            false,
31402            None,
31403            1,
31404            false,
31405            false,
31406            false,
31407            false,
31408            false,
31409            false,
31410            false,
31411            TagpathSearchOpts::default(),
31412        );
31413        assert!(result.is_ok());
31414    }
31415
31416    #[test]
31417    fn graph_cmd_unlimited_runs_ok() {
31418        let dir = setup_graph_index();
31419        let result = cmd_graph(
31420            "main",
31421            dir.path(),
31422            false,
31423            false,
31424            None,
31425            0,
31426            false,
31427            false,
31428            false,
31429            false,
31430            false,
31431            false,
31432            false,
31433            TagpathSearchOpts::default(),
31434        );
31435        assert!(result.is_ok());
31436    }
31437
31438    #[test]
31439    fn graph_cmd_tabular_runs_ok() {
31440        let dir = setup_graph_index();
31441        let result = cmd_graph(
31442            "main",
31443            dir.path(),
31444            false,
31445            false,
31446            None,
31447            20,
31448            false,
31449            false,
31450            false,
31451            false,
31452            false,
31453            true,
31454            false,
31455            TagpathSearchOpts::default(),
31456        );
31457        assert!(result.is_ok());
31458    }
31459
31460    #[test]
31461    fn communities_cmd_tabular_runs_ok() {
31462        let dir = setup_graph_index();
31463        let result = cmd_communities(
31464            dir.path(),
31465            None,
31466            1,
31467            10,
31468            false,
31469            false,
31470            false,
31471            false,
31472            true,
31473            false,
31474            TagpathSearchOpts::default(),
31475        );
31476        assert!(result.is_ok());
31477    }
31478
31479    #[test]
31480    fn explain_cmd_tabular_runs_ok() {
31481        let dir = setup_graph_index();
31482        let result = cmd_explain(
31483            "main",
31484            dir.path(),
31485            None,
31486            15,
31487            false,
31488            false,
31489            false,
31490            false,
31491            false,
31492            true,
31493            false,
31494            false,
31495        );
31496        assert!(result.is_ok());
31497    }
31498
31499    #[test]
31500    fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
31501        // #gdbcacheprove: .agent-doc runtime markdown (snapshots, baselines, archives,
31502        // session docs, runtime logs) must not contribute to the source watermark, or
31503        // every agent-doc cycle would invalidate the graph-db backend-eval cache and
31504        // force a full rebuild on the next run.
31505        let cases = [
31506            ".agent-doc",
31507            ".agent-doc/snapshots/abc.md",
31508            ".agent-doc/baselines/abc.md",
31509            ".agent-doc/archives/2026.md",
31510            ".agent-doc/runtime/run.jsonl",
31511            "src/foo/.agent-doc",
31512            "src/foo/.agent-doc/snapshots/x.md",
31513            "./.agent-doc/snapshots/x.md",
31514        ];
31515        for path in cases {
31516            assert!(
31517                traversal_relative_path_is_generated_artifact(path),
31518                "expected `{path}` to be excluded from source watermark"
31519            );
31520        }
31521        // Real source paths must NOT be excluded.
31522        for path in [
31523            "src/main.rs",
31524            "tests/perf_gate.rs",
31525            "fixtures/x.json",
31526            "agent-doc/src/lib.rs", // sibling dir without the leading dot
31527            "src/.agent-doc-helper.rs",
31528        ] {
31529            assert!(
31530                !traversal_relative_path_is_generated_artifact(path),
31531                "expected `{path}` to be included in source watermark"
31532            );
31533        }
31534    }
31535
31536    #[test]
31537    fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
31538        // #cachelookupshift: the conflict-matrix preparation cache key hashes
31539        // file_state snapshot rows + every markdown file under the root. Any
31540        // .tsift/, target/, or .agent-doc/ path slipping past the filter would
31541        // shift the watermark every run because those directories mutate as a
31542        // side effect of running tsift itself. This test locks the artifact
31543        // filter against regressions for each prefix variant
31544        // (bare, root-anchored, nested, and './' leading).
31545        let cases = [
31546            ".tsift",
31547            ".tsift/index.db",
31548            ".tsift/indexes/foo/index.db",
31549            ".tsift/conflict-matrix-cache/inputs/abc.json",
31550            ".tsift/summaries.db",
31551            "src/foo/.tsift",
31552            "src/foo/.tsift/graph.db",
31553            "./.tsift/index.db",
31554            "target",
31555            "target/debug/build/x",
31556            "target/release/tsift",
31557            "src/foo/target/debug/x",
31558            "./target/release/x",
31559        ];
31560        for path in cases {
31561            assert!(
31562                traversal_relative_path_is_generated_artifact(path),
31563                "expected `{path}` to be excluded from source watermark"
31564            );
31565        }
31566        // Look-alike paths must NOT be excluded — only true artifact dirs.
31567        for path in [
31568            "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
31569            "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
31570            "src/tsift-extras/lib.rs",
31571            "tsift/README.md",
31572            "src/targeting.rs",
31573            "src/.tsiftrc",
31574            "src/agent-doc-helper.rs",
31575        ] {
31576            assert!(
31577                !traversal_relative_path_is_generated_artifact(path),
31578                "expected `{path}` to be included in source watermark"
31579            );
31580        }
31581    }
31582
31583    #[test]
31584    fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
31585        // #cachelookupshift: the conflict-matrix preparation cache only hits
31586        // when traversal_source_watermark returns the same hash for two
31587        // consecutive calls on identical source state. Lock that invariant so
31588        // a future change that folds wall-clock time, a directory mtime, or
31589        // any other non-content input into the hash trips this test before
31590        // regressing the preparation_cache_lookup hit rate. We exercise the
31591        // session_only=true path with a hinted markdown file so the test does
31592        // not need a full index DB to drive the index-snapshot branch.
31593        let dir = tempfile::tempdir().unwrap();
31594        let root = dir.path();
31595        std::fs::create_dir_all(root.join("src")).unwrap();
31596        std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
31597        let hint = root.join("README.md");
31598        std::fs::write(&hint, "# stable\n").unwrap();
31599        // Add a generated-artifact directory that must NOT affect the watermark.
31600        std::fs::create_dir_all(root.join(".tsift")).unwrap();
31601        std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
31602        std::fs::create_dir_all(root.join("target/debug")).unwrap();
31603        std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
31604
31605        let first = traversal_source_watermark(root, &hint, None, true)
31606            .expect("first watermark call must succeed")
31607            .expect("first watermark must produce a hash for hinted markdown");
31608        let second = traversal_source_watermark(root, &hint, None, true)
31609            .expect("second watermark call must succeed")
31610            .expect("second watermark must produce a hash for hinted markdown");
31611        assert_eq!(
31612            first, second,
31613            "watermark must be identical across back-to-back invocations on a quiescent root"
31614        );
31615
31616        // Mutating a generated-artifact file must NOT shift the hash.
31617        std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
31618        std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
31619        let third = traversal_source_watermark(root, &hint, None, true)
31620            .expect("third watermark call must succeed")
31621            .expect("third watermark must produce a hash for hinted markdown");
31622        assert_eq!(
31623            first, third,
31624            "watermark must ignore mutations under .tsift/ and target/"
31625        );
31626
31627        // Mutating the hinted markdown file MUST shift the hash so the
31628        // preparation cache invalidates correctly when user state changes.
31629        // Sleep briefly to push the file mtime past the original even on
31630        // coarse-resolution filesystems.
31631        std::thread::sleep(std::time::Duration::from_millis(20));
31632        std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
31633        let fourth = traversal_source_watermark(root, &hint, None, true)
31634            .expect("fourth watermark call must succeed")
31635            .expect("fourth watermark must produce a hash for hinted markdown");
31636        assert_ne!(
31637            first, fourth,
31638            "watermark must invalidate when the hinted markdown file changes"
31639        );
31640    }
31641
31642    #[test]
31643    fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
31644        // #gcachemiss: full-projection cache keys must not miss just because the
31645        // SQLite summary cache file header or mtime churned. Only the semantic rows
31646        // that feed traversal projection should participate in the source watermark.
31647        let dir = tempfile::tempdir().unwrap();
31648        let root = dir.path();
31649        std::fs::write(root.join("README.md"), "# stable\n").unwrap();
31650        let summaries_db_path = root.join(".tsift/summaries.db");
31651        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31652        let mut summary = summarize::Summary {
31653            id: 0,
31654            symbol_name: "main".to_string(),
31655            file_path: "src/main.rs".to_string(),
31656            content_hash: "hash-main".to_string(),
31657            summary: "main wires the CLI".to_string(),
31658            entities: Some(vec![summarize::Entity {
31659                name: "Cli".to_string(),
31660                kind: "type".to_string(),
31661                description: "Command-line interface".to_string(),
31662            }]),
31663            relationships: None,
31664            concept_labels: Some(vec!["cli".to_string()]),
31665            extracted_at: "1700000000".to_string(),
31666            model: "test-model".to_string(),
31667            tokens_input: Some(10),
31668            tokens_output: Some(5),
31669        };
31670        summary_db.insert(&summary).unwrap();
31671        drop(summary_db);
31672
31673        let hint = root.join("README.md");
31674        let first = traversal_source_watermark(root, &hint, None, true)
31675            .expect("first watermark call must succeed")
31676            .expect("first watermark must produce a hash");
31677
31678        std::thread::sleep(std::time::Duration::from_millis(20));
31679        let conn = Connection::open(&summaries_db_path).unwrap();
31680        conn.pragma_update(None, "user_version", 1).unwrap();
31681        conn.pragma_update(None, "user_version", 0).unwrap();
31682        drop(conn);
31683
31684        let second = traversal_source_watermark(root, &hint, None, true)
31685            .expect("second watermark call must succeed")
31686            .expect("second watermark must produce a hash");
31687        assert_eq!(
31688            first, second,
31689            "metadata-only summaries.db churn must not invalidate the source watermark"
31690        );
31691
31692        summary.entities = Some(vec![summarize::Entity {
31693            name: "GraphCache".to_string(),
31694            kind: "type".to_string(),
31695            description: "Stable full-projection cache input".to_string(),
31696        }]);
31697        let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
31698        summary_db.delete_by_file("src/main.rs").unwrap();
31699        summary_db.insert(&summary).unwrap();
31700        drop(summary_db);
31701
31702        let third = traversal_source_watermark(root, &hint, None, true)
31703            .expect("third watermark call must succeed")
31704            .expect("third watermark must produce a hash");
31705        assert_ne!(
31706            first, third,
31707            "semantic summary row changes must invalidate the source watermark"
31708        );
31709    }
31710
31711    #[test]
31712    fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
31713        // #gfullhot: backend-eval full-projection cache keys should be based on
31714        // the indexed graph inputs, not file_state mtimes. Touching a source file
31715        // without changing extracted symbols/call edges must still hit the cache.
31716        let dir = tempfile::tempdir().unwrap();
31717        let root = dir.path();
31718        std::fs::create_dir_all(root.join("src")).unwrap();
31719        std::fs::create_dir_all(root.join(".tsift")).unwrap();
31720        let source = root.join("src/lib.rs");
31721        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
31722        std::fs::write(&source, source_body).unwrap();
31723        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31724        db.rebuild(root).unwrap();
31725        drop(db);
31726
31727        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31728            .unwrap()
31729            .value;
31730        std::thread::sleep(std::time::Duration::from_millis(20));
31731        std::fs::write(&source, source_body).unwrap();
31732        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31733        db.apply_changes(root).unwrap();
31734        drop(db);
31735
31736        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31737            .unwrap()
31738            .value;
31739        assert_eq!(
31740            first, second,
31741            "mtime-only source index churn must not invalidate the full-projection cache"
31742        );
31743    }
31744
31745    #[test]
31746    fn full_projection_source_watermark_ignores_session_markdown_churn() {
31747        // #gfullhot: the full-projection performance cache isolates code graph
31748        // and semantic-summary inputs. Current session evidence is measured by
31749        // the bounded real dataset, so unrelated task-doc edits must not force a
31750        // million-row full-projection rebuild.
31751        let dir = tempfile::tempdir().unwrap();
31752        let root = dir.path();
31753        std::fs::create_dir_all(root.join("src")).unwrap();
31754        std::fs::create_dir_all(root.join("tasks/software")).unwrap();
31755        std::fs::create_dir_all(root.join(".tsift")).unwrap();
31756        std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
31757        let task_doc = root.join("tasks/software/tsift.md");
31758        std::fs::write(
31759            &task_doc,
31760            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
31761        )
31762        .unwrap();
31763        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31764        db.rebuild(root).unwrap();
31765        drop(db);
31766
31767        let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
31768            .unwrap()
31769            .value;
31770        std::fs::write(
31771            &task_doc,
31772            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
31773        )
31774        .unwrap();
31775        let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
31776            .unwrap()
31777            .value;
31778        assert_eq!(
31779            first, second,
31780            "session markdown churn must not invalidate the full-projection code/summary cache"
31781        );
31782    }
31783
31784    #[test]
31785    fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
31786        // #gfullhot: once a full-project projection is cached, repeated samples
31787        // with unchanged graph inputs must report zero source_graph_build and
31788        // projection_rows work even if indexed file mtimes changed.
31789        let dir = tempfile::tempdir().unwrap();
31790        let root = dir.path();
31791        std::fs::create_dir_all(root.join("src")).unwrap();
31792        std::fs::create_dir_all(root.join(".tsift")).unwrap();
31793        let source = root.join("src/lib.rs");
31794        let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
31795        std::fs::write(&source, source_body).unwrap();
31796        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31797        db.rebuild(root).unwrap();
31798        drop(db);
31799
31800        let (_projection, _warnings, _phases, first_stats) =
31801            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
31802        assert!(
31803            !first_stats.hit,
31804            "the first full-projection run should populate the cache"
31805        );
31806
31807        std::thread::sleep(std::time::Duration::from_millis(20));
31808        std::fs::write(&source, source_body).unwrap();
31809        let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
31810        db.apply_changes(root).unwrap();
31811        drop(db);
31812
31813        let (_projection, _warnings, phases, second_stats) =
31814            graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
31815        assert!(second_stats.hit, "mtime-only churn should still cache-hit");
31816        let source_graph_build = phases
31817            .iter()
31818            .find(|phase| phase.name == "full_projection.source_graph_build")
31819            .expect("cache hit must report source_graph_build");
31820        let projection_rows = phases
31821            .iter()
31822            .find(|phase| phase.name == "full_projection.projection_rows")
31823            .expect("cache hit must report projection_rows");
31824        assert_eq!(source_graph_build.duration_micros, 0);
31825        assert_eq!(projection_rows.duration_micros, 0);
31826    }
31827
31828    #[test]
31829    fn build_token_capped_preview_within_cap() {
31830        let lines: Vec<&str> = vec!["fn foo() {", "    1 + 2", "}"];
31831        let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
31832        assert!(!capped.was_capped);
31833        assert_eq!(capped.preview.len(), 3);
31834        assert_eq!(capped.capped_end, 3);
31835    }
31836
31837    #[test]
31838    fn build_token_capped_preview_truncates_long_body() {
31839        let owned: Vec<String> = (0..200).map(|i| format!("    let line_{i} = {i};")).collect();
31840        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
31841        let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
31842        assert!(capped.was_capped);
31843        assert!(capped.preview.len() < 200);
31844        assert!(capped.capped_end < 200);
31845        assert!(!capped.preview.is_empty());
31846    }
31847
31848    #[test]
31849    fn build_token_capped_preview_respects_start_offset() {
31850        let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
31851        let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
31852        let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
31853        assert!(capped.was_capped);
31854        assert!(capped.capped_end >= 50);
31855        assert!(capped.capped_end < 100);
31856        assert_eq!(capped.preview[0].line, 50);
31857    }
31858
31859    #[test]
31860    fn response_budget_body_token_cap_defaults() {
31861        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
31862        assert_eq!(budget.body_token_cap(), 1500);
31863
31864        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
31865        assert_eq!(budget.body_token_cap(), 500);
31866
31867        let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
31868        assert_eq!(budget.body_token_cap(), 3000);
31869    }
31870
31871    #[test]
31872    fn build_token_capped_preview_empty_input() {
31873        let lines: Vec<&str> = vec![];
31874        let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
31875        assert!(!capped.was_capped);
31876        assert!(capped.preview.is_empty());
31877    }
31878
31879    #[test]
31880    fn build_token_capped_preview_single_long_line_fits() {
31881        let lines: Vec<&str> = vec!["short"];
31882        let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
31883        assert!(!capped.was_capped);
31884        assert_eq!(capped.preview.len(), 1);
31885        assert_eq!(capped.capped_end, 1);
31886    }
31887
31888    #[test]
31889    fn edge_index_replaces_from_id_to_id_with_positions() {
31890        let input = serde_json::json!({
31891            "nodes": [
31892                {"id": "symbol:src/lib.rs:foo"},
31893                {"id": "symbol:src/lib.rs:bar"},
31894                {"id": "symbol:src/lib.rs:baz"}
31895            ],
31896            "edges": [
31897                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
31898                {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
31899            ]
31900        });
31901        let result = edge_index_transform(input);
31902        let edges = result.get("edges").unwrap().as_array().unwrap();
31903        assert_eq!(edges.len(), 2);
31904        assert_eq!(edges[0]["from"], 0);
31905        assert_eq!(edges[0]["to"], 1);
31906        assert_eq!(edges[1]["from"], 1);
31907        assert_eq!(edges[1]["to"], 2);
31908        assert!(edges[0].get("from_id").is_none());
31909        assert!(edges[0].get("to_id").is_none());
31910    }
31911
31912    #[test]
31913    fn edge_index_preserves_unresolved_ids_as_strings() {
31914        let input = serde_json::json!({
31915            "nodes": [{"id": "symbol:src/lib.rs:foo"}],
31916            "edges": [
31917                {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
31918            ]
31919        });
31920        let result = edge_index_transform(input);
31921        let edge = &result["edges"][0];
31922        assert_eq!(edge["from"], 0);
31923        assert_eq!(edge["to_id"], "symbol:other.rs:missing");
31924    }
31925
31926    #[test]
31927    fn edge_index_noop_without_nodes_and_edges() {
31928        let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
31929        let result = edge_index_transform(input);
31930        assert_eq!(result["report"]["entries"][0]["from_id"], "a");
31931    }
31932}
31933
31934// --- SQL introspection ---
31935
31936#[derive(Serialize)]
31937struct TableInfo {
31938    name: String,
31939    columns: Vec<ColumnInfo>,
31940    row_count: i64,
31941}
31942
31943#[derive(Serialize)]
31944struct ColumnInfo {
31945    name: String,
31946    #[serde(rename = "type")]
31947    col_type: String,
31948    notnull: bool,
31949    pk: bool,
31950    #[serde(skip_serializing_if = "Option::is_none")]
31951    default_value: Option<String>,
31952}
31953
31954/// Open a SQLite connection (read-only).
31955pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
31956    let conn = Connection::open_with_flags(
31957        path,
31958        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
31959    )
31960    .with_context(|| format!("opening database: {}", path.display()))?;
31961    Ok(conn)
31962}
31963
31964/// List all user tables with column metadata and row counts.
31965pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
31966    let mut stmt = conn.prepare(
31967        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
31968    )?;
31969    let table_names: Vec<String> = stmt
31970        .query_map([], |row| row.get(0))?
31971        .collect::<std::result::Result<Vec<_>, _>>()?;
31972
31973    let mut tables = Vec::new();
31974    for tbl in table_names {
31975        let columns = table_columns(conn, &tbl)?;
31976        let row_count: i64 =
31977            conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
31978                row.get(0)
31979            })?;
31980        tables.push(TableInfo {
31981            name: tbl,
31982            columns,
31983            row_count,
31984        });
31985    }
31986    Ok(tables)
31987}
31988
31989/// Get column metadata for a single table.
31990pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
31991    let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
31992    let cols = stmt
31993        .query_map([], |row| {
31994            Ok(ColumnInfo {
31995                name: row.get(1)?,
31996                col_type: row.get::<_, String>(2).unwrap_or_default(),
31997                notnull: row.get::<_, bool>(3).unwrap_or(false),
31998                pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
31999                default_value: row.get(4)?,
32000            })
32001        })?
32002        .collect::<std::result::Result<Vec<_>, _>>()?;
32003    Ok(cols)
32004}
32005
32006/// Execute an arbitrary SQL query and return rows as JSON values.
32007pub(crate) fn execute_query(
32008    conn: &Connection,
32009    sql: &str,
32010) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
32011    let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
32012    let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
32013    let col_count = col_names.len();
32014
32015    let mut rows = Vec::new();
32016    let mut query_rows = stmt.query([])?;
32017    while let Some(row) = query_rows.next()? {
32018        let mut vals = Vec::with_capacity(col_count);
32019        for i in 0..col_count {
32020            let val = match row.get_ref(i)? {
32021                rusqlite::types::ValueRef::Null => serde_json::Value::Null,
32022                rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
32023                rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
32024                rusqlite::types::ValueRef::Text(s) => {
32025                    serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
32026                }
32027                rusqlite::types::ValueRef::Blob(b) => {
32028                    serde_json::Value::String(format!("<blob {} bytes>", b.len()))
32029                }
32030            };
32031            vals.push(val);
32032        }
32033        rows.push(vals);
32034    }
32035    Ok((col_names, rows))
32036}
32037
32038
32039#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32040enum DigestRunnerKind {
32041    Test,
32042    Log,
32043}
32044
32045impl DigestRunnerKind {
32046    fn parse(raw: &str) -> Result<Self> {
32047        match raw.trim().to_ascii_lowercase().as_str() {
32048            "test" => Ok(Self::Test),
32049            "log" => Ok(Self::Log),
32050            other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
32051        }
32052    }
32053
32054    fn as_str(self) -> &'static str {
32055        match self {
32056            Self::Test => "test",
32057            Self::Log => "log",
32058        }
32059    }
32060}
32061
32062/// Simple shell word splitting (handles single and double quotes).
32063pub(crate) fn shell_split(s: &str) -> Vec<&str> {
32064    let mut parts = Vec::new();
32065    let mut i = 0;
32066    let bytes = s.as_bytes();
32067    while i < bytes.len() {
32068        // Skip whitespace
32069        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
32070            i += 1;
32071        }
32072        if i >= bytes.len() {
32073            break;
32074        }
32075        let start = i;
32076        if bytes[i] == b'"' || bytes[i] == b'\'' {
32077            let quote = bytes[i];
32078            i += 1;
32079            while i < bytes.len() && bytes[i] != quote {
32080                i += 1;
32081            }
32082            if i < bytes.len() {
32083                i += 1; // closing quote
32084            }
32085        } else {
32086            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
32087                i += 1;
32088            }
32089        }
32090        parts.push(&s[start..i]);
32091    }
32092    parts
32093}
32094
32095/// Quote a string for shell if it contains special characters.
32096pub(crate) fn shell_quote(s: &str) -> String {
32097    // Strip existing quotes
32098    let unquoted =
32099        if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
32100            &s[1..s.len() - 1]
32101        } else {
32102            s
32103        };
32104
32105    if unquoted
32106        .chars()
32107        .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
32108    {
32109        format!("\"{}\"", unquoted)
32110    } else {
32111        format!(
32112            "\"{}\"",
32113            unquoted.replace('\\', "\\\\").replace('"', "\\\"")
32114        )
32115    }
32116}
32117
32118fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
32119    sift::SearchCoverageSnapshot {
32120        mode: sift::SearchCoverageMode::Sealed,
32121        total_sector_count: 0,
32122        mounted_sector_count: 0,
32123        reused_sector_count: 0,
32124        dirty_sector_count: 0,
32125        completed_dirty_sector_count: 0,
32126        rebuilding_sector_count: 0,
32127        resumed_sector_count: 0,
32128        active_rebuild: None,
32129    }
32130}
32131
32132fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
32133    let total_sector_count = responses
32134        .iter()
32135        .map(|response| response.coverage.total_sector_count)
32136        .sum();
32137    let mounted_sector_count = responses
32138        .iter()
32139        .map(|response| response.coverage.mounted_sector_count)
32140        .sum();
32141    let reused_sector_count = responses
32142        .iter()
32143        .map(|response| response.coverage.reused_sector_count)
32144        .sum();
32145    let dirty_sector_count = responses
32146        .iter()
32147        .map(|response| response.coverage.dirty_sector_count)
32148        .sum();
32149    let completed_dirty_sector_count = responses
32150        .iter()
32151        .map(|response| response.coverage.completed_dirty_sector_count)
32152        .sum();
32153    let rebuilding_sector_count = responses
32154        .iter()
32155        .map(|response| response.coverage.rebuilding_sector_count)
32156        .sum();
32157    let resumed_sector_count = responses
32158        .iter()
32159        .map(|response| response.coverage.resumed_sector_count)
32160        .sum();
32161
32162    let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
32163        sift::SearchCoverageMode::Sealed
32164    } else if completed_dirty_sector_count > 0
32165        || rebuilding_sector_count > 0
32166        || resumed_sector_count > 0
32167    {
32168        sift::SearchCoverageMode::Converging
32169    } else {
32170        sift::SearchCoverageMode::Frontier
32171    };
32172
32173    sift::SearchCoverageSnapshot {
32174        mode,
32175        total_sector_count,
32176        mounted_sector_count,
32177        reused_sector_count,
32178        dirty_sector_count,
32179        completed_dirty_sector_count,
32180        rebuilding_sector_count,
32181        resumed_sector_count,
32182        active_rebuild: responses
32183            .iter()
32184            .find_map(|response| response.coverage.active_rebuild.clone()),
32185    }
32186}
32187
32188fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
32189    sift::SearchResponse {
32190        strategy: strategy.to_string(),
32191        root: root.display().to_string(),
32192        indexed_artifacts: 0,
32193        skipped_artifacts: 0,
32194        coverage: empty_search_coverage(),
32195        hits: Vec::new(),
32196    }
32197}
32198
32199fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
32200    for hit in &mut response.hits {
32201        let path = Path::new(&hit.path);
32202        if path.is_relative() {
32203            hit.path = search_root.join(path).display().to_string();
32204        }
32205    }
32206}
32207
32208fn merge_search_responses(
32209    root: &Path,
32210    strategy: &str,
32211    limit: usize,
32212    responses: Vec<sift::SearchResponse>,
32213) -> sift::SearchResponse {
32214    let indexed_artifacts = responses
32215        .iter()
32216        .map(|response| response.indexed_artifacts)
32217        .sum();
32218    let skipped_artifacts = responses
32219        .iter()
32220        .map(|response| response.skipped_artifacts)
32221        .sum();
32222    let coverage = if responses.is_empty() {
32223        empty_search_coverage()
32224    } else {
32225        aggregate_search_coverage(&responses)
32226    };
32227    let mut hits: Vec<sift::SearchHit> = responses
32228        .into_iter()
32229        .flat_map(|response| response.hits)
32230        .collect();
32231    hits.sort_by(|left, right| {
32232        right
32233            .score
32234            .partial_cmp(&left.score)
32235            .unwrap_or(Ordering::Equal)
32236            .then_with(|| left.path.cmp(&right.path))
32237            .then_with(|| left.location.cmp(&right.location))
32238    });
32239    hits.truncate(limit);
32240    for (rank, hit) in hits.iter_mut().enumerate() {
32241        hit.rank = rank + 1;
32242    }
32243
32244    sift::SearchResponse {
32245        strategy: strategy.to_string(),
32246        root: root.display().to_string(),
32247        indexed_artifacts,
32248        skipped_artifacts,
32249        coverage,
32250        hits,
32251    }
32252}
32253
32254pub(crate) fn federated_sift_search(
32255    root: &Path,
32256    cache_dir: &Path,
32257    query: &str,
32258    limit: usize,
32259    timeout_secs: u64,
32260    strategy: &str,
32261) -> Result<sift::SearchResponse> {
32262    let targets = resolve_search_index_targets(root, root, None, true)?;
32263    if targets.is_empty() {
32264        if config::Config::submodule_dirs(root)?.is_empty() {
32265            return run_search_with_timeout(
32266                root,
32267                cache_dir,
32268                query,
32269                limit,
32270                timeout_secs,
32271                strategy,
32272                &[],
32273            );
32274        }
32275        return Ok(empty_search_response(root, strategy));
32276    }
32277
32278    let mut responses = Vec::with_capacity(targets.len());
32279    for target in &targets {
32280        let mut response = run_search_with_timeout(
32281            &target.source_root,
32282            cache_dir,
32283            query,
32284            limit,
32285            timeout_secs,
32286            strategy,
32287            std::slice::from_ref(target),
32288        )?;
32289        absolutize_search_hit_paths(&mut response, &target.source_root);
32290        response.root = root.display().to_string();
32291        responses.push(response);
32292    }
32293
32294    Ok(merge_search_responses(root, strategy, limit, responses))
32295}
32296
32297/// Federated symbol search across every scoped `.tsift/indexes/<scope>/index.db`
32298/// in the workspace. Per-scope tagpath annotation runs inside the per-scope
32299/// loop so each scope's adapter resolves against its own `.naming.toml` /
32300/// `.naming/index.json` (the workspace root usually has no tagpath of its
32301/// own). The merged `TagpathAnnotationDiagnostic` reports `loaded=true` when
32302/// at least one scope loaded, and `stale=true` with the first stale reason
32303/// when any scope was stale.
32304pub(crate) fn federated_symbol_search(
32305    root: &std::path::Path,
32306    query: &str,
32307    limit: usize,
32308    tagpath_opts: &TagpathSearchOpts,
32309) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
32310    let cfg = config::Config::load(root)?;
32311    let submodules = config::Config::submodule_dirs(root)?;
32312    let mut all_hits: Vec<index::SymbolHit> = Vec::new();
32313    let mut combined = TagpathAnnotationDiagnostic::default();
32314    for scope in &submodules {
32315        if !cfg.federation_for_scope(scope) {
32316            continue;
32317        }
32318        let db_path = cfg.db_path_for(root, &scope.id);
32319        if !db_path.exists() {
32320            continue;
32321        }
32322        let db = index::IndexDb::open_read_only(&db_path)?;
32323        let mut hits = db.symbol_search(query, limit)?;
32324        let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
32325        combined.loaded |= diag.loaded;
32326        if diag.stale && !combined.stale {
32327            combined.stale = true;
32328            combined.reason = diag.reason;
32329        }
32330        all_hits.append(&mut hits);
32331    }
32332    all_hits.sort_by(|a, b| {
32333        b.score
32334            .partial_cmp(&a.score)
32335            .unwrap_or(std::cmp::Ordering::Equal)
32336    });
32337    all_hits.truncate(limit);
32338    Ok((all_hits, combined))
32339}
32340
32341#[derive(Debug, Deserialize)]
32342#[serde(tag = "type", rename_all = "lowercase")]
32343enum RipgrepJsonEvent {
32344    Match {
32345        data: RipgrepMatchData,
32346    },
32347    #[serde(other)]
32348    Other,
32349}
32350
32351#[derive(Debug, Deserialize)]
32352struct RipgrepMatchData {
32353    path: RipgrepTextField,
32354    lines: RipgrepTextField,
32355    line_number: Option<usize>,
32356}
32357
32358#[derive(Debug, Deserialize)]
32359struct RipgrepTextField {
32360    text: Option<String>,
32361}
32362
32363pub(crate) fn federated_exact_search(
32364    root: &Path,
32365    query: &str,
32366    limit: usize,
32367    timeout_secs: u64,
32368) -> Result<sift::SearchResponse> {
32369    let cfg = config::Config::load(root)?;
32370    let mut responses = Vec::new();
32371    for scope in config::Config::submodule_dirs(root)? {
32372        if !cfg.federation_for_scope(&scope) {
32373            continue;
32374        }
32375        let mut response =
32376            run_exact_search_with_timeout(&scope.source_root, query, limit, timeout_secs)?;
32377        absolutize_search_hit_paths(&mut response, &scope.source_root);
32378        response.root = root.display().to_string();
32379        responses.push(response);
32380    }
32381
32382    Ok(merge_search_responses(root, "exact", limit, responses))
32383}
32384
32385pub(crate) fn run_sift_search(
32386    search_path: &Path,
32387    cache_dir: &Path,
32388    query: &str,
32389    limit: usize,
32390    strategy: &str,
32391) -> Result<sift::SearchResponse> {
32392    let engine = Sift::builder().with_cache_dir(cache_dir).build();
32393    let options = SearchOptions::default()
32394        .with_limit(limit)
32395        .with_strategy(strategy.to_string());
32396    let input = SearchInput::new(search_path, query).with_options(options);
32397    engine.search(input).context("sift search failed")
32398}
32399
32400fn exact_search_timeout_message(timeout_secs: u64) -> String {
32401    format!(
32402        "tsift search timed out after {}s (strategy: exact). \
32403         Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
32404        timeout_secs
32405    )
32406}
32407
32408fn exact_search_command(search_path: &Path, query: &str) -> Command {
32409    let mut command = Command::new("rg");
32410    command
32411        .arg("--json")
32412        .arg("--fixed-strings")
32413        .arg("--line-number")
32414        .arg("--hidden")
32415        .arg("--")
32416        .arg(query)
32417        .arg(search_path);
32418    command
32419}
32420
32421fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
32422    let observed_unix_secs = SystemTime::now()
32423        .duration_since(UNIX_EPOCH)
32424        .unwrap_or_default()
32425        .as_secs() as i64;
32426    let modified_unix_secs = fs::metadata(path)
32427        .ok()
32428        .and_then(|metadata| metadata.modified().ok())
32429        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
32430        .map(|duration| duration.as_secs() as i64);
32431    sift::ArtifactFreshness {
32432        observed_unix_secs,
32433        modified_unix_secs,
32434    }
32435}
32436
32437fn parse_exact_search_output(
32438    search_path: &Path,
32439    limit: usize,
32440    raw: &str,
32441) -> Result<sift::SearchResponse> {
32442    if limit == 0 {
32443        return Ok(sift::SearchResponse {
32444            strategy: "exact".to_string(),
32445            root: search_path.display().to_string(),
32446            indexed_artifacts: 0,
32447            skipped_artifacts: 0,
32448            coverage: empty_search_coverage(),
32449            hits: Vec::new(),
32450        });
32451    }
32452
32453    let mut hits = Vec::new();
32454    for line in raw.lines() {
32455        let event: RipgrepJsonEvent =
32456            serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
32457        let RipgrepJsonEvent::Match { data } = event else {
32458            continue;
32459        };
32460        let Some(path_text) = data.path.text else {
32461            continue;
32462        };
32463        let Some(lines_text) = data.lines.text else {
32464            continue;
32465        };
32466        let path = PathBuf::from(path_text);
32467        let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
32468        let rank = hits.len() + 1;
32469        hits.push(sift::SearchHit {
32470            artifact_id: format!(
32471                "exact:{}:{}:{}",
32472                path.display(),
32473                data.line_number.unwrap_or(0),
32474                rank
32475            ),
32476            artifact_kind: sift::ContextArtifactKind::File,
32477            path: path.display().to_string(),
32478            rank,
32479            score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
32480            confidence: sift::ScoreConfidence::High,
32481            location: data.line_number.map(|line| format!("line {}", line)),
32482            snippet: snippet.clone(),
32483            provenance: sift::ArtifactProvenance {
32484                adapter: sift::AcquisitionAdapterKind::FileSystem,
32485                source: "ripgrep -F".to_string(),
32486                synthetic: false,
32487            },
32488            freshness: exact_search_file_timestamp(&path),
32489            budget: sift::ArtifactBudget::from_text(&snippet, 1),
32490        });
32491        if hits.len() >= limit {
32492            break;
32493        }
32494    }
32495
32496    Ok(sift::SearchResponse {
32497        strategy: "exact".to_string(),
32498        root: search_path.display().to_string(),
32499        indexed_artifacts: hits.len(),
32500        skipped_artifacts: 0,
32501        coverage: empty_search_coverage(),
32502        hits,
32503    })
32504}
32505
32506fn exact_search_response_from_process(
32507    search_path: &Path,
32508    limit: usize,
32509    status: std::process::ExitStatus,
32510    stdout: &[u8],
32511    stderr: &[u8],
32512) -> Result<sift::SearchResponse> {
32513    if !status.success() && status.code() != Some(1) {
32514        let message = String::from_utf8_lossy(stderr);
32515        let trimmed = message.trim();
32516        if trimmed.is_empty() {
32517            bail!("ripgrep exact search exited with status {}", status);
32518        }
32519        bail!("{}", trimmed);
32520    }
32521
32522    let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
32523    parse_exact_search_output(search_path, limit, &raw)
32524}
32525
32526fn run_exact_search(search_path: &Path, query: &str, limit: usize) -> Result<sift::SearchResponse> {
32527    let output = exact_search_command(search_path, query)
32528        .output()
32529        .context("running exact search with ripgrep")?;
32530    exact_search_response_from_process(
32531        search_path,
32532        limit,
32533        output.status,
32534        &output.stdout,
32535        &output.stderr,
32536    )
32537}
32538
32539pub(crate) fn run_exact_search_with_timeout(
32540    search_path: &Path,
32541    query: &str,
32542    limit: usize,
32543    timeout_secs: u64,
32544) -> Result<sift::SearchResponse> {
32545    if timeout_secs == 0 {
32546        return run_exact_search(search_path, query, limit);
32547    }
32548
32549    let mut child = exact_search_command(search_path, query)
32550        .stdin(Stdio::null())
32551        .stdout(Stdio::piped())
32552        .stderr(Stdio::piped())
32553        .spawn()
32554        .context("spawning timed exact search worker")?;
32555
32556    let timeout = Duration::from_secs(timeout_secs);
32557    let status = wait_for_child_exit(&mut child, timeout)
32558        .context("waiting for timed exact search worker")?;
32559    if status.is_none() {
32560        let _ = child.kill();
32561        let _ = child.wait();
32562        bail!("{}", exact_search_timeout_message(timeout_secs));
32563    }
32564
32565    let status = status.unwrap();
32566    let stdout = read_child_stdout(&mut child)?;
32567    let stderr = read_child_stderr(&mut child)?;
32568    exact_search_response_from_process(
32569        search_path,
32570        limit,
32571        status,
32572        stdout.as_bytes(),
32573        stderr.as_bytes(),
32574    )
32575}
32576
32577pub(crate) fn run_search_with_timeout(
32578    search_path: &Path,
32579    cache_dir: &Path,
32580    query: &str,
32581    limit: usize,
32582    timeout_secs: u64,
32583    strategy: &str,
32584    search_targets: &[SearchIndexTarget],
32585) -> Result<sift::SearchResponse> {
32586    if timeout_secs == 0 {
32587        return run_sift_search(search_path, cache_dir, query, limit, strategy);
32588    }
32589
32590    let output_path = next_search_worker_output_path();
32591    let mut child = Command::new(
32592        std::env::current_exe().context("resolving tsift executable for timed search")?,
32593    )
32594    .arg("__search-worker")
32595    .arg("--path")
32596    .arg(search_path)
32597    .arg("--cache-dir")
32598    .arg(cache_dir)
32599    .arg("--query")
32600    .arg(query)
32601    .arg("--limit")
32602    .arg(limit.to_string())
32603    .arg("--strategy")
32604    .arg(strategy)
32605    .arg("--output")
32606    .arg(&output_path)
32607    .stdin(Stdio::null())
32608    .stdout(Stdio::null())
32609    .stderr(Stdio::piped())
32610    .spawn()
32611    .context("spawning timed sift search worker")?;
32612
32613    let timeout = Duration::from_secs(timeout_secs);
32614    let status =
32615        wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
32616    if status.is_none() {
32617        let _ = child.kill();
32618        let _ = child.wait();
32619        let _ = fs::remove_file(&output_path);
32620        bail!(
32621            "{}",
32622            search_timeout_message(timeout_secs, strategy, search_targets)?
32623        );
32624    }
32625
32626    let status = status.unwrap();
32627    let stderr = read_child_stderr(&mut child)?;
32628    if !status.success() {
32629        let _ = fs::remove_file(&output_path);
32630        let message = stderr.trim();
32631        if message.is_empty() {
32632            bail!("sift search worker exited with status {}", status);
32633        }
32634        bail!("{}", message);
32635    }
32636
32637    let raw = fs::read_to_string(&output_path)
32638        .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
32639    let _ = fs::remove_file(&output_path);
32640    serde_json::from_str(&raw).context("parsing search worker output")
32641}
32642
32643fn next_search_worker_output_path() -> PathBuf {
32644    let stamp = SystemTime::now()
32645        .duration_since(UNIX_EPOCH)
32646        .unwrap_or_default()
32647        .as_nanos();
32648    std::env::temp_dir().join(format!(
32649        "tsift-search-{}-{}.json",
32650        std::process::id(),
32651        stamp
32652    ))
32653}
32654
32655fn wait_for_child_exit(
32656    child: &mut std::process::Child,
32657    timeout: Duration,
32658) -> Result<Option<std::process::ExitStatus>> {
32659    let started = Instant::now();
32660    loop {
32661        if let Some(status) = child.try_wait()? {
32662            return Ok(Some(status));
32663        }
32664        if started.elapsed() >= timeout {
32665            return Ok(None);
32666        }
32667        let remaining = timeout.saturating_sub(started.elapsed());
32668        std::thread::sleep(remaining.min(Duration::from_millis(10)));
32669    }
32670}
32671
32672fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
32673    let mut stderr = String::new();
32674    if let Some(mut pipe) = child.stderr.take() {
32675        pipe.read_to_string(&mut stderr)
32676            .context("reading search worker stderr")?;
32677    }
32678    Ok(stderr)
32679}
32680
32681fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
32682    let mut stdout = String::new();
32683    if let Some(mut pipe) = child.stdout.take() {
32684        pipe.read_to_string(&mut stdout)
32685            .context("reading search worker stdout")?;
32686    }
32687    Ok(stdout)
32688}
32689
32690pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
32691    if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
32692        fs::write(&path, std::process::id().to_string())
32693            .with_context(|| format!("writing search worker pid file: {path}"))?;
32694    }
32695    if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
32696        let delay_ms = ms
32697            .parse::<u64>()
32698            .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
32699        std::thread::sleep(Duration::from_millis(delay_ms));
32700    }
32701    Ok(())
32702}
32703
32704#[cfg(test)]
32705thread_local! {
32706    static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
32707}
32708
32709#[cfg(test)]
32710enum SearchPostPrecheckLockMode {
32711    RollbackJournal,
32712    Wal,
32713}
32714
32715#[cfg(test)]
32716struct SearchPostPrecheckLockHook {
32717    db_path: PathBuf,
32718    mode: SearchPostPrecheckLockMode,
32719}
32720
32721#[cfg(test)]
32722struct SearchPostPrecheckLockGuard;
32723
32724#[cfg(test)]
32725impl Drop for SearchPostPrecheckLockGuard {
32726    fn drop(&mut self) {
32727        SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32728            hook.borrow_mut().take();
32729        });
32730    }
32731}
32732
32733#[cfg(test)]
32734fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32735    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
32736}
32737
32738#[cfg(test)]
32739fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
32740    install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
32741}
32742
32743#[cfg(test)]
32744fn install_search_post_precheck_lock_hook(
32745    db_path: PathBuf,
32746    mode: SearchPostPrecheckLockMode,
32747) -> SearchPostPrecheckLockGuard {
32748    SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
32749        assert!(
32750            hook.borrow().is_none(),
32751            "search post-precheck lock hook already installed"
32752        );
32753        *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
32754    });
32755    SearchPostPrecheckLockGuard
32756}
32757
32758#[cfg(test)]
32759pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
32760    let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
32761        return Ok(());
32762    };
32763    let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
32764    std::thread::spawn(move || {
32765        let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
32766        match hook.mode {
32767            SearchPostPrecheckLockMode::RollbackJournal => {
32768                conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
32769                    .expect("acquiring rollback-journal hook lock");
32770                fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
32771                    .expect("writing rollback journal marker");
32772            }
32773            SearchPostPrecheckLockMode::Wal => {
32774                conn.execute_batch(
32775                    "PRAGMA journal_mode=WAL;
32776                     PRAGMA wal_autocheckpoint=0;
32777                     CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
32778                     INSERT INTO search_wal_lock_probe DEFAULT VALUES;
32779                     PRAGMA locking_mode=EXCLUSIVE;
32780                     BEGIN EXCLUSIVE;",
32781                )
32782                .expect("acquiring WAL hook lock");
32783                assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
32784            }
32785        }
32786        ready_tx.send(()).expect("signaling search lock hook");
32787        std::thread::sleep(Duration::from_millis(200));
32788        drop(conn);
32789        let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
32790    });
32791    ready_rx
32792        .recv_timeout(Duration::from_secs(1))
32793        .context("waiting for search post-precheck lock hook")?;
32794    Ok(())
32795}
32796
32797#[cfg(not(test))]
32798pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
32799    Ok(())
32800}