1mod cli;
2mod commands;
3mod community_detection;
4mod conflict_matrix;
5mod context_pack;
6mod output;
7mod rewrite;
8mod search_budget;
9mod semantic_edit;
10mod session_review_budget;
11mod token_savings;
12mod workflow;
13
14pub(crate) use community_detection::{
15 CommunityDetectionReport, annotate_community_members_with_context,
16 community_tagpath_cache_part, community_tagpath_cache_part_for_loaded,
17 detect_communities_cached, file_communities_from_callers, graph_effectiveness_blocked,
18 graph_effectiveness_ready, resolve_tagpath_handle_for_callee_edge,
19 update_community_annotation_diagnostics,
20};
21#[allow(unused_imports)]
22pub(crate) use conflict_matrix::{
23 ConflictMatrixCandidate, ConflictMatrixGraphPreparedInputs, ConflictMatrixPreparedInputs,
24 ConflictMatrixReport, ConflictMatrixSemanticRef, ConflictMatrixSharedPreparationSummary,
25 ConflictMatrixWorkerFeedback, ConflictMatrixWorkerPromptPacket, build_conflict_matrix_report,
26 build_conflict_matrix_report_from_prepared_graph, cmd_conflict_matrix,
27 collect_conflict_matrix_evidence_packets, conflict_matrix_candidate_from_evidence,
28 conflict_matrix_graph_index, conflict_matrix_semantic_ref,
29 conflict_matrix_shared_preparation_summary, conflict_matrix_source_handle,
30 conflict_matrix_target_scoped_graph_snapshot, conflict_matrix_worker_feedback,
31 conflict_risk_label, extract_conflict_target_refs, hash_bytes_hex, is_planner_config_path,
32 normalize_conflict_target, prepare_conflict_matrix_graph_orchestration,
33 prepare_conflict_matrix_inputs, resolve_conflict_matrix_targets, sorted_intersection,
34 sorted_set,
35};
36#[allow(unused_imports)]
37pub(crate) use context_pack::{
38 ContextPackReport, ContextPackSummaryRefPreview, build_context_pack_diff_preview,
39 build_context_pack_log_preview, build_context_pack_report,
40 build_context_pack_report_with_profile, build_context_pack_test_preview,
41 context_pack_status_reminders, exploration_ref_id, materialize_context_pack_exploration_packet,
42 print_context_pack_human,
43};
44pub use rewrite::rewrite_command;
45pub(crate) use rewrite::{
46 apply_rewrite_output_format, execute_rewritten_command, no_rewrite_message,
47};
48#[cfg(test)]
49use search_budget::{SearchBudgetReport, search_facet_filters_summary};
50pub(crate) use search_budget::{
51 SearchBudgetReportInput, apply_search_facet_filters, build_search_budget_follow_up,
52 build_search_budget_report, print_search_budget_human,
53};
54pub(crate) use semantic_edit::{
55 AstSpanPreview, EditBatch, EditResult, EditStatus, MarkdownEmbeddedSymbol,
56 MarkdownSpanMetadata, MetricDigestOptions, SemanticEditVerifyOptions,
57 apply_edit_plan_atomically, build_edit_plan, cmd_edit_intents,
58};
59#[allow(unused_imports)]
60pub(crate) use session_review_budget::{
61 SessionReviewBudgetFailurePreview, SessionReviewBudgetReport,
62 SessionReviewNextContextBudgetReport, SessionReviewNextTokenAction,
63 build_session_review_budget_report, build_session_review_next_context_budget_report,
64 print_session_review_budget_human, print_session_review_next_context_budget_human,
65};
66
67#[cfg(test)]
68use rewrite::{
69 OutputCap, apply_output_cap, effective_rewrite_run_command, resolve_digest_context_path,
70 rewrite_output_cap,
71};
72#[cfg(test)]
73use std::io::{BufRead as _, BufReader};
74#[cfg(test)]
75use token_savings::{
76 TokenSavingsFamily, TokenSavingsFixture, TokenSavingsFixtureCase,
77 TokenSavingsMarkdownProjectionInput, TokenSavingsMarkdownProjectionInputs,
78 TokenSavingsRawSymbol, TokenSavingsSourceReadInput, TokenSavingsSourceReadInputs,
79 build_token_savings_report,
80};
81
82use anyhow::{Context, Result, bail};
83use clap::Parser;
84use cli::{
85 Cli, Commands, DispatchTraceFormat, GraphDbQuery, KgCommand, LeaseCommand, LocalModelCommand,
86 SemanticRelatedKind, SourceReadStyle,
87};
88#[cfg(test)]
89use cli::{GraphDbBackend, TraverseFormat};
90use commands::digests::{
91 cmd_context_pack, cmd_diff_digest, cmd_log_digest, cmd_metric_digest, cmd_session_cost,
92 cmd_session_digest, cmd_session_review_with_budget, cmd_test_digest,
93};
94#[cfg(test)]
95use commands::graph::cmd_explain;
96use commands::graph::{
97 cmd_analyze, cmd_communities, cmd_explain_with_budget, cmd_graph, cmd_path, cmd_traverse,
98};
99#[cfg(test)]
100use commands::index_search::cmd_search;
101use commands::index_search::{cmd_index, cmd_search_with_budget, cmd_search_worker};
102use commands::infra::{
103 StatusCommandOptions, cmd_convex_sync, cmd_edit, cmd_graph_db, cmd_init, cmd_locks,
104 cmd_rewrite, cmd_route, cmd_sql, cmd_status,
105};
106use commands::memory::cmd_memory;
107use commands::quality::{cmd_audit, cmd_audit_tagpath, cmd_lint};
108use commands::summarize::cmd_summarize;
109use flate2::{Compression, read::GzDecoder, write::GzEncoder};
110#[cfg(test)]
111use output::ResponseBudgetPreset;
112use output::tagpath::{
113 TagpathAnnotationDiagnostic, TagpathSearchOpts, annotate_communities_with_tagpath,
114 annotate_hits_with_tagpath, annotate_path_nodes_with_tagpath,
115 annotate_stored_edges_with_tagpath, annotate_stored_symbols_with_tagpath,
116};
117use output::{
118 OutputFormat, ResponseBudget, ToolEnvelope, ToolEnvelopeMetric, ToolEnvelopeSummary,
119 TranscriptArtifactRef,
120};
121use rusqlite::{Connection, OptionalExtension, Row};
122use serde::{Deserialize, Serialize};
123use sift::{SearchInput, SearchOptions, Sift};
124#[cfg(test)]
125use std::cell::RefCell;
126use std::cmp::Ordering;
127use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
128use std::env;
129use std::fs;
130use std::io::{Read as _, Write as _};
131use std::path::{Path, PathBuf};
132use std::process::{Command, Stdio};
133use std::sync::{Mutex, OnceLock};
134use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
135use substrate::{
136 ConvexEdgeRow, ConvexNodeRow, ConvexProjectionRows, GraphEdge as SubstrateGraphEdge,
137 GraphFreshness, GraphNode as SubstrateGraphNode, GraphProjection, GraphPropertyFilter,
138 GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphStore, SQLITE_GRAPH_SCHEMA_VERSION,
139 SqliteGraphStore, SqliteProjectionRefresh, TerseGraphEdge as SubstrateTerseGraphEdge,
140 TerseGraphNode as SubstrateTerseGraphNode,
141};
142use tagpath::{family as tagpath_family, ontology as tagpath_ontology};
143#[cfg(test)]
144use tsift_agent_doc::session_cost;
145use tsift_agent_doc::session_markdown::{self, AgentDocQueueItem, AgentDocSessionDocument};
146#[cfg(test)]
147use tsift_agent_doc::session_review;
148use tsift_cache::cycle_packet_cache;
149use tsift_core::{
150 NeighborhoodScoring, RankedNeighborhoodOptions, SemanticSeededNeighborhoodOptions,
151};
152use tsift_digest::{diff_digest, log_digest, metric_digest, test_digest};
153use tsift_graph as graph;
154use tsift_index::{config, index, init, multiplicity, walk};
155use tsift_memgraphrag::append_tsift_memory_graph_projection_rows;
156#[cfg(test)]
157use tsift_memory::MemoryEvent;
158use tsift_quality::{dci_benchmark, lint, perf_gate, token_gate};
159use tsift_resolution as resolution;
160use tsift_search::{impact, sift};
161use tsift_sqlite as substrate;
162use tsift_status::status;
163use tsift_summarize::summarize;
164#[cfg(feature = "backend-surrealdb")]
165use tsift_surrealdb::SurrealdbGraphStore;
166use tsift_tokensave::TokensaveDb;
167
168#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
169pub(crate) enum GraphDbExperimentalBackend {
170 DuckdbDuckpgq,
171 Falkordb,
172 Ladybug,
173 Kuzu,
174 Surrealdb,
175}
176
177#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
178pub(crate) struct SearchFacetFilters {
179 #[serde(skip_serializing_if = "Vec::is_empty", default)]
180 pub(crate) languages: Vec<String>,
181 #[serde(skip_serializing_if = "Vec::is_empty", default)]
182 pub(crate) kinds: Vec<String>,
183 #[serde(skip_serializing_if = "Vec::is_empty", default)]
184 pub(crate) node_kinds: Vec<String>,
185 #[serde(skip_serializing_if = "Vec::is_empty", default)]
186 pub(crate) sections: Vec<String>,
187 #[serde(skip_serializing_if = "Vec::is_empty", default)]
188 pub(crate) parents: Vec<String>,
189 #[serde(skip_serializing_if = "Vec::is_empty", default)]
190 pub(crate) children: Vec<String>,
191 #[serde(skip_serializing_if = "Vec::is_empty", default)]
192 pub(crate) fence_languages: Vec<String>,
193 #[serde(skip_serializing_if = "Vec::is_empty", default)]
194 pub(crate) list_depths: Vec<usize>,
195 #[serde(skip_serializing_if = "Vec::is_empty", default)]
196 pub(crate) heading_levels: Vec<usize>,
197}
198
199impl SearchFacetFilters {
200 pub(crate) fn is_empty(&self) -> bool {
201 self.languages.is_empty()
202 && self.kinds.is_empty()
203 && self.node_kinds.is_empty()
204 && self.sections.is_empty()
205 && self.parents.is_empty()
206 && self.children.is_empty()
207 && self.fence_languages.is_empty()
208 && self.list_depths.is_empty()
209 && self.heading_levels.is_empty()
210 }
211
212 fn needs_ast_context(&self) -> bool {
213 !self.sections.is_empty()
214 || !self.parents.is_empty()
215 || !self.children.is_empty()
216 || !self.fence_languages.is_empty()
217 || !self.list_depths.is_empty()
218 || !self.heading_levels.is_empty()
219 }
220}
221
222#[derive(Serialize)]
223struct GraphDbBackendPromotionGate {
224 status: String,
225 native_adapter_required: bool,
226 required_checks: Vec<String>,
227}
228
229impl GraphDbExperimentalBackend {
230 fn name(self) -> &'static str {
231 match self {
232 Self::DuckdbDuckpgq => "duckdb-duckpgq",
233 Self::Falkordb => "falkordb",
234 Self::Ladybug => "ladybug",
235 Self::Kuzu => "kuzu",
236 Self::Surrealdb => "surrealdb",
237 }
238 }
239
240 fn adapter_label(self) -> &'static str {
241 match self {
242 Self::DuckdbDuckpgq => "DuckDB/DuckPGQ read-only prototype",
243 Self::Falkordb => "FalkorDB read-only prototype",
244 Self::Ladybug => "Ladybug read-only prototype",
245 Self::Kuzu => "Kuzu (Vela-Engineering/kuzu) read-only prototype",
246 Self::Surrealdb => "SurrealDB read-only prototype",
247 }
248 }
249
250 fn projection_load(self) -> &'static str {
251 match self {
252 Self::Falkordb => {
253 "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"
254 }
255 Self::Kuzu => {
256 "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"
257 }
258 Self::Surrealdb => {
259 "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"
260 }
261 _ => {
262 "provider-neutral rows loaded into a dependency-free in-process read snapshot for parity and performance gates"
263 }
264 }
265 }
266
267 fn lock_behavior(self) -> &'static str {
268 match self {
269 Self::Falkordb => {
270 "read-only FalkorDB prototype snapshot; production promotion must prove multi-process writer behavior and local fallback semantics before replacing SQLite"
271 }
272 Self::Kuzu => {
273 "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"
274 }
275 Self::Surrealdb => {
276 "read-only SurrealDB prototype snapshot; production promotion must prove embedded/file-backed writer and read-only lock behavior before replacing SQLite"
277 }
278 _ => "read-only snapshot/row adapter; no writer lock is taken during query benchmarks",
279 }
280 }
281
282 fn install_portability(self) -> &'static str {
283 match self {
284 Self::Falkordb => {
285 "prototype is dependency-free in this binary; production FalkorDB promotion must keep install optional and preserve cargo build/install without a service"
286 }
287 Self::Kuzu => {
288 "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"
289 }
290 Self::Surrealdb => {
291 "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"
292 }
293 _ => {
294 "prototype is dependency-free in this binary; a production engine adapter must remain optional before promotion"
295 }
296 }
297 }
298
299 fn prototype_hold_reason(self) -> Option<&'static str> {
300 match self {
301 Self::DuckdbDuckpgq => Some(
302 "DuckDB/DuckPGQ remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
303 ),
304 Self::Falkordb => Some(
305 "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",
306 ),
307 Self::Ladybug => Some(
308 "Ladybug remains behind backend-eval until a native production adapter proves projection writes, freshness/parity, full_projection wins, install portability, and lock behavior",
309 ),
310 Self::Kuzu => Some(
311 "Kuzu remains behind backend-eval until a native optional adapter proves projection writes/load, SQLite parity, full_projection wins, install portability, and lock behavior",
312 ),
313 Self::Surrealdb => Some(
314 "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",
315 ),
316 }
317 }
318
319 fn promotion_gate(self) -> GraphDbBackendPromotionGate {
320 match self {
321 Self::DuckdbDuckpgq => GraphDbBackendPromotionGate {
322 status: "hold_native_adapter_required".to_string(),
323 native_adapter_required: true,
324 required_checks: vec![
325 "native_duckdb_duckpgq_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
326 .to_string(),
327 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
328 .to_string(),
329 "embedded_or_service_lock_behavior_match_or_beat_sqlite".to_string(),
330 "operator_install_cost_keeps_cargo_build_install_duckdb_extension_free_by_default"
331 .to_string(),
332 ],
333 },
334 Self::Falkordb => GraphDbBackendPromotionGate {
335 status: "hold_native_adapter_required".to_string(),
336 native_adapter_required: true,
337 required_checks: vec![
338 "native_falkordb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
339 .to_string(),
340 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
341 .to_string(),
342 "multi_process_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
343 .to_string(),
344 "operator_install_cost_keeps_cargo_build_install_service_free_by_default"
345 .to_string(),
346 ],
347 },
348 Self::Ladybug => GraphDbBackendPromotionGate {
349 status: "hold_native_adapter_required".to_string(),
350 native_adapter_required: true,
351 required_checks: vec![
352 "native_ladybug_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
353 .to_string(),
354 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
355 .to_string(),
356 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
357 .to_string(),
358 "operator_install_cost_keeps_cargo_build_install_ladybug_free_by_default"
359 .to_string(),
360 ],
361 },
362 Self::Kuzu => GraphDbBackendPromotionGate {
363 status: "hold_native_adapter_required".to_string(),
364 native_adapter_required: true,
365 required_checks: vec![
366 "native_kuzu_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
367 .to_string(),
368 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
369 .to_string(),
370 "concurrent_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
371 .to_string(),
372 "operator_install_cost_keeps_cargo_build_install_native_kuzu_free_by_default"
373 .to_string(),
374 ],
375 },
376 Self::Surrealdb => GraphDbBackendPromotionGate {
377 status: "hold_native_adapter_required".to_string(),
378 native_adapter_required: true,
379 required_checks: vec![
380 "native_surrealdb_projection_load_writes_provider_neutral_rows_without_sqlite_row_replay"
381 .to_string(),
382 "freshness_and_parity_match_sqlite_on_real_and_full_projection_datasets"
383 .to_string(),
384 "embedded_file_backed_writer_and_read_only_lock_behavior_match_or_beat_sqlite"
385 .to_string(),
386 "operator_install_cost_keeps_cargo_build_install_surrealdb_free_by_default"
387 .to_string(),
388 ],
389 },
390 }
391 }
392
393 fn parse(raw: &str) -> Result<Self> {
394 match raw {
395 "duckdb-duckpgq" | "duckdb" | "duckpgq" => Ok(Self::DuckdbDuckpgq),
396 "falkordb" | "falkor" => Ok(Self::Falkordb),
397 "ladybug" => Ok(Self::Ladybug),
398 "kuzu" | "vela-kuzu" => Ok(Self::Kuzu),
399 "surrealdb" | "surreal" | "surreal-db" => Ok(Self::Surrealdb),
400 _ => {
401 bail!(
402 "unknown backend-eval candidate {raw:?}; expected duckdb-duckpgq, falkordb, ladybug, kuzu, or surrealdb"
403 )
404 }
405 }
406 }
407}
408
409pub fn run() -> Result<()> {
410 let cli = Cli::parse();
411 let compact = cli.compact;
412 let pretty = cli.pretty;
413 let terse = cli.terse || cli.ultra_terse;
414 let ultra_terse = cli.ultra_terse;
415 let absolute = cli.absolute;
416 let tabular = cli.tabular;
417 let schema = cli.schema;
418 let envelope = cli.envelope;
419 match cli.command {
420 Some(Commands::Search {
421 query,
422 path,
423 limit,
424 strategy,
425 exact,
426 scope,
427 federated,
428 lang,
429 kind,
430 node_kind,
431 section,
432 parent,
433 child,
434 fence_language,
435 list_depth,
436 heading_level,
437 json,
438 autoindex,
439 no_autoindex,
440 timeout,
441 max_items,
442 max_bytes,
443 budget,
444 no_tagpath,
445 tagpath_strict,
446 }) => cmd_search_with_budget(
447 query,
448 path,
449 limit,
450 if exact {
451 Some("exact".to_string())
452 } else {
453 strategy
454 },
455 scope,
456 federated,
457 json || terse || schema || envelope,
458 autoindex || !no_autoindex,
459 timeout,
460 compact,
461 pretty,
462 terse,
463 ultra_terse,
464 absolute,
465 tabular,
466 schema,
467 envelope,
468 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
469 TagpathSearchOpts {
470 no_tagpath,
471 strict: tagpath_strict,
472 },
473 SearchFacetFilters {
474 languages: lang,
475 kinds: kind,
476 node_kinds: node_kind,
477 sections: section,
478 parents: parent,
479 children: child,
480 fence_languages: fence_language,
481 list_depths: list_depth,
482 heading_levels: heading_level,
483 },
484 ),
485 Some(Commands::SearchWorker {
486 path,
487 cache_dir,
488 query,
489 limit,
490 strategy,
491 output,
492 fts_index_fresh,
493 }) => cmd_search_worker(
494 &path,
495 &cache_dir,
496 &query,
497 limit,
498 &strategy,
499 &output,
500 fts_index_fresh,
501 ),
502 Some(Commands::DigestRunner {
503 kind,
504 path,
505 runner,
506 shell_command,
507 json,
508 }) => cmd_digest_runner(
509 &kind,
510 &path,
511 runner.as_deref(),
512 &shell_command,
513 OutputFormat {
514 json_output: json || terse || schema || envelope,
515 compact,
516 pretty,
517 terse,
518 ultra_terse,
519 schema,
520 envelope,
521 },
522 ),
523 Some(Commands::Edit { dry_run, file }) => {
524 cmd_edit(dry_run, file, compact, pretty, terse, schema)
525 }
526 Some(Commands::EditIntents {
527 path,
528 scope,
529 file,
530 json,
531 apply,
532 verify,
533 verify_command,
534 max_items,
535 max_bytes,
536 budget,
537 }) => cmd_edit_intents(
538 &path,
539 scope.as_deref(),
540 file,
541 apply,
542 SemanticEditVerifyOptions {
543 enabled: verify,
544 command: verify_command.as_deref(),
545 },
546 OutputFormat {
547 json_output: json || terse || schema || envelope,
548 compact,
549 pretty,
550 terse,
551 ultra_terse,
552 schema,
553 envelope,
554 },
555 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
556 ),
557 Some(Commands::Index {
558 path,
559 rebuild,
560 check,
561 exit_code,
562 prune,
563 quiet,
564 workspace,
565 submodule,
566 json,
567 }) => cmd_index(
568 &path,
569 rebuild,
570 check,
571 exit_code,
572 prune,
573 quiet,
574 workspace,
575 submodule.as_deref(),
576 json || terse || schema || envelope,
577 compact,
578 pretty,
579 terse,
580 absolute,
581 schema,
582 ),
583 Some(Commands::Rewrite { command, run }) => cmd_rewrite(
584 &command,
585 run,
586 OutputFormat {
587 json_output: terse || schema || envelope,
588 compact,
589 pretty,
590 terse,
591 ultra_terse,
592 schema,
593 envelope,
594 },
595 ),
596 Some(Commands::Route { task, id }) => cmd_route(&task, id),
597 Some(Commands::Memory { command }) => {
598 let json = command.json_output();
599 cmd_memory(
600 command,
601 OutputFormat {
602 json_output: json || terse || schema || envelope,
603 compact,
604 pretty,
605 terse,
606 ultra_terse,
607 schema,
608 envelope,
609 },
610 )
611 }
612 Some(Commands::LocalModel { command }) => {
613 let json = command.json_output();
614 cmd_local_model(
615 command,
616 OutputFormat {
617 json_output: json || terse || schema || envelope,
618 compact,
619 pretty,
620 terse,
621 ultra_terse,
622 schema,
623 envelope,
624 },
625 )
626 }
627 Some(Commands::Kg { command }) => match command {
628 KgCommand::Extract {
629 profile,
630 model,
631 host,
632 input,
633 source_ref,
634 graph_db,
635 no_lease,
636 idle_ttl_seconds,
637 keep_loaded,
638 lease_file,
639 no_context,
640 json,
641 } => commands::kg::cmd_kg_extract(commands::kg::KgExtractArgs {
642 profile,
643 model,
644 host,
645 input,
646 source_ref,
647 graph_db,
648 no_lease,
649 idle_ttl_seconds,
650 keep_loaded,
651 lease_file,
652 no_context,
653 json: json || terse || schema || envelope,
654 }),
655 KgCommand::Status { graph_db, json } => {
656 commands::kg::cmd_kg_status(graph_db, json || terse || schema || envelope)
657 }
658 KgCommand::Refresh {
659 graph_db,
660 apply,
661 profile,
662 model,
663 host,
664 no_lease,
665 idle_ttl_seconds,
666 keep_loaded,
667 lease_file,
668 no_context,
669 json,
670 } => commands::kg::cmd_kg_refresh(commands::kg::KgRefreshArgs {
671 graph_db,
672 json: json || terse || schema || envelope,
673 apply,
674 profile,
675 model,
676 host,
677 no_lease,
678 idle_ttl_seconds,
679 keep_loaded,
680 lease_file,
681 no_context,
682 }),
683 KgCommand::Evidence {
684 symbol,
685 kind,
686 limit,
687 graph_db,
688 json,
689 } => commands::kg::cmd_kg_evidence(
690 symbol,
691 kind,
692 limit,
693 graph_db,
694 json || terse || schema || envelope,
695 ),
696 KgCommand::Unload {
697 profile,
698 model,
699 host,
700 json,
701 } => commands::kg::cmd_kg_unload(
702 profile,
703 model,
704 host,
705 json || terse || schema || envelope,
706 ),
707 KgCommand::Smoke {
708 profile,
709 model,
710 host,
711 unload,
712 json,
713 } => commands::kg::cmd_kg_smoke(
714 profile,
715 model,
716 host,
717 unload,
718 json || terse || schema || envelope,
719 ),
720 },
721 Some(Commands::Finding { command }) => match command {
722 cli::FindingCommand::Add {
723 path,
724 kind,
725 title,
726 body,
727 about,
728 confidence,
729 status,
730 relates,
731 scope,
732 json,
733 } => commands::finding::cmd_finding_add(
734 &path,
735 &kind,
736 &title,
737 &body,
738 &about,
739 confidence,
740 &status,
741 relates.as_deref(),
742 scope.as_deref(),
743 json || terse || schema || envelope,
744 pretty,
745 ),
746 cli::FindingCommand::List {
747 path,
748 about,
749 kind,
750 status,
751 include_stale,
752 scope,
753 json,
754 } => commands::finding::cmd_finding_list(
755 &path,
756 about.as_deref(),
757 kind.as_deref(),
758 status.as_deref(),
759 include_stale,
760 scope.as_deref(),
761 json || terse || schema || envelope,
762 pretty,
763 ),
764 cli::FindingCommand::Harvest { path, scope, json } => {
765 commands::finding::cmd_finding_harvest(
766 &path,
767 scope.as_deref(),
768 json || terse || schema || envelope,
769 pretty,
770 )
771 }
772 cli::FindingCommand::Promote { id, path, json } => {
773 commands::finding::cmd_finding_promote(
774 &path,
775 &id,
776 json || terse || schema || envelope,
777 pretty,
778 )
779 }
780 },
781 Some(Commands::Graph {
782 symbol,
783 path,
784 callers,
785 callees,
786 scope,
787 limit,
788 json,
789 no_tagpath,
790 tagpath_strict,
791 }) => cmd_graph(
792 &symbol,
793 &path,
794 callers,
795 callees,
796 scope.as_deref(),
797 limit,
798 json || terse || schema || envelope,
799 compact,
800 pretty,
801 terse,
802 absolute,
803 tabular,
804 schema,
805 TagpathSearchOpts {
806 no_tagpath,
807 strict: tagpath_strict,
808 },
809 ),
810 Some(Commands::Sql {
811 db,
812 query,
813 table,
814 json,
815 }) => cmd_sql(
816 &db,
817 query,
818 table,
819 json || terse || schema || envelope,
820 compact,
821 pretty,
822 terse,
823 schema,
824 ),
825 Some(Commands::Communities {
826 path,
827 scope,
828 min_size,
829 limit,
830 json,
831 no_tagpath,
832 tagpath_strict,
833 }) => cmd_communities(
834 &path,
835 scope.as_deref(),
836 min_size,
837 limit,
838 json || terse || schema || envelope,
839 compact,
840 pretty,
841 terse,
842 tabular,
843 schema,
844 TagpathSearchOpts {
845 no_tagpath,
846 strict: tagpath_strict,
847 },
848 ),
849 Some(Commands::Analyze {
850 path,
851 scope,
852 entry_points,
853 limit,
854 json,
855 }) => cmd_analyze(
856 &path,
857 scope.as_deref(),
858 &entry_points,
859 limit,
860 OutputFormat {
861 json_output: json || terse || schema || envelope,
862 compact,
863 pretty,
864 terse,
865 ultra_terse,
866 schema,
867 envelope,
868 },
869 ),
870 Some(Commands::Path {
871 from,
872 to,
873 path,
874 scope,
875 json,
876 no_tagpath,
877 tagpath_strict,
878 }) => cmd_path(
879 &from,
880 &to,
881 &path,
882 scope.as_deref(),
883 json || terse || schema || envelope,
884 compact,
885 pretty,
886 terse,
887 schema,
888 TagpathSearchOpts {
889 no_tagpath,
890 strict: tagpath_strict,
891 },
892 ),
893 Some(Commands::Explain {
894 symbol,
895 path,
896 scope,
897 limit,
898 json,
899 max_items,
900 max_bytes,
901 budget,
902 no_tagpath,
903 tagpath_strict,
904 }) => cmd_explain_with_budget(
905 &symbol,
906 &path,
907 scope.as_deref(),
908 limit,
909 json || terse || schema || envelope,
910 compact,
911 pretty,
912 terse,
913 ultra_terse,
914 absolute,
915 tabular,
916 schema,
917 envelope,
918 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
919 TagpathSearchOpts {
920 no_tagpath,
921 strict: tagpath_strict,
922 },
923 ),
924 Some(Commands::Traverse {
925 node,
926 to,
927 path,
928 scope,
929 depth,
930 limit,
931 format,
932 convex_snapshot,
933 }) => cmd_traverse(
934 node.as_deref(),
935 to.as_deref(),
936 &path,
937 scope.as_deref(),
938 depth,
939 limit,
940 format,
941 pretty,
942 terse,
943 schema,
944 convex_snapshot.as_deref(),
945 ),
946 Some(Commands::ConvexSync {
947 path,
948 scope,
949 snapshot,
950 chunk_size,
951 remote_snapshot,
952 apply,
953 endpoint,
954 auth_token_env,
955 json,
956 }) => cmd_convex_sync(
957 ConvexSyncOptions {
958 path: &path,
959 scope: scope.as_deref(),
960 snapshot: snapshot.as_deref(),
961 chunk_size,
962 remote_snapshot,
963 apply,
964 endpoint: endpoint.as_deref(),
965 auth_token_env: &auth_token_env,
966 },
967 OutputFormat {
968 json_output: json || terse || schema || envelope,
969 compact,
970 pretty,
971 terse,
972 ultra_terse,
973 schema,
974 envelope,
975 },
976 ),
977 Some(Commands::GraphDb {
978 path,
979 scope,
980 backend,
981 convex_snapshot,
982 json,
983 query,
984 }) => cmd_graph_db(
985 &path,
986 scope.as_deref(),
987 backend,
988 convex_snapshot.as_deref(),
989 query,
990 OutputFormat {
991 json_output: json || terse || schema || envelope,
992 compact,
993 pretty,
994 terse,
995 ultra_terse,
996 schema,
997 envelope,
998 },
999 ),
1000 Some(Commands::SourceRead {
1001 file,
1002 path,
1003 style,
1004 start,
1005 lines,
1006 end,
1007 scope,
1008 json,
1009 max_items,
1010 max_bytes,
1011 budget,
1012 }) => cmd_source_read(
1013 &file,
1014 &path,
1015 style,
1016 start,
1017 lines,
1018 end,
1019 scope.as_deref(),
1020 OutputFormat {
1021 json_output: json || terse || schema || envelope,
1022 compact,
1023 pretty,
1024 terse,
1025 ultra_terse,
1026 schema,
1027 envelope,
1028 },
1029 absolute,
1030 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1031 ),
1032 Some(Commands::MarkdownAst {
1033 file,
1034 path,
1035 node,
1036 json,
1037 max_items,
1038 max_bytes,
1039 budget,
1040 }) => cmd_markdown_ast(
1041 &file,
1042 &path,
1043 node.as_deref(),
1044 OutputFormat {
1045 json_output: json || terse || schema || envelope,
1046 compact,
1047 pretty,
1048 terse,
1049 ultra_terse,
1050 schema,
1051 envelope,
1052 },
1053 absolute,
1054 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1055 ),
1056 Some(Commands::SymbolRead {
1057 symbol,
1058 file,
1059 path,
1060 scope,
1061 json,
1062 max_items,
1063 max_bytes,
1064 budget,
1065 }) => cmd_symbol_read(
1066 &symbol,
1067 file.as_deref(),
1068 &path,
1069 scope.as_deref(),
1070 OutputFormat {
1071 json_output: json || terse || schema || envelope,
1072 compact,
1073 pretty,
1074 terse,
1075 ultra_terse,
1076 schema,
1077 envelope,
1078 },
1079 absolute,
1080 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1081 ),
1082 Some(Commands::Audit {
1083 skills_dir,
1084 manifest,
1085 usage,
1086 cleanup,
1087 report,
1088 json,
1089 }) => cmd_audit(
1090 &skills_dir,
1091 manifest,
1092 usage,
1093 cleanup,
1094 report,
1095 json || terse || schema || envelope,
1096 compact,
1097 pretty,
1098 terse,
1099 schema,
1100 ),
1101 Some(Commands::AuditTagpath { path, scope, json }) => cmd_audit_tagpath(
1102 &path,
1103 scope.as_deref(),
1104 json || terse || schema || envelope,
1105 pretty,
1106 terse,
1107 schema,
1108 ),
1109 Some(Commands::Init {
1110 path,
1111 codex,
1112 opencode,
1113 workspace,
1114 }) => cmd_init(&path, codex, opencode, workspace),
1115 Some(Commands::Lint {
1116 file,
1117 index,
1118 entities_from,
1119 json,
1120 }) => cmd_lint(
1121 &file,
1122 index,
1123 entities_from,
1124 json || terse || schema || envelope,
1125 compact,
1126 pretty,
1127 terse,
1128 schema,
1129 ),
1130 Some(Commands::Summarize {
1131 symbol,
1132 file,
1133 extract,
1134 diff,
1135 stats,
1136 path,
1137 profile,
1138 json,
1139 }) => cmd_summarize(
1140 symbol,
1141 file,
1142 extract,
1143 diff,
1144 stats,
1145 &path,
1146 json || terse || schema || envelope,
1147 compact,
1148 pretty,
1149 terse,
1150 schema,
1151 profile,
1152 ),
1153 Some(Commands::Semantic {
1154 query,
1155 path,
1156 scope,
1157 limit,
1158 kind,
1159 profile,
1160 json,
1161 }) => cmd_semantic_related(
1162 &query,
1163 &path,
1164 scope.as_deref(),
1165 limit,
1166 kind,
1167 json || terse || schema || envelope,
1168 compact,
1169 pretty,
1170 terse,
1171 schema,
1172 profile,
1173 ),
1174 Some(Commands::DiffDigest {
1175 path,
1176 cached,
1177 revision,
1178 max_parsed_files,
1179 json,
1180 }) => cmd_diff_digest(
1181 &path,
1182 cached,
1183 revision.as_deref(),
1184 max_parsed_files,
1185 OutputFormat {
1186 json_output: json || terse || schema || envelope,
1187 compact,
1188 pretty,
1189 terse,
1190 ultra_terse,
1191 schema,
1192 envelope,
1193 },
1194 ),
1195 Some(Commands::Impact {
1196 path,
1197 cached,
1198 revision,
1199 scope,
1200 limit,
1201 json,
1202 }) => cmd_impact(
1203 &path,
1204 cached,
1205 revision.as_deref(),
1206 scope.as_deref(),
1207 limit,
1208 OutputFormat {
1209 json_output: json || terse || schema || envelope,
1210 compact,
1211 pretty,
1212 terse,
1213 ultra_terse,
1214 schema,
1215 envelope,
1216 },
1217 ),
1218 Some(Commands::TestDigest {
1219 path,
1220 input,
1221 runner,
1222 json,
1223 }) => cmd_test_digest(
1224 &path,
1225 input.as_deref(),
1226 runner.as_deref(),
1227 OutputFormat {
1228 json_output: json || terse || schema || envelope,
1229 compact,
1230 pretty,
1231 terse,
1232 ultra_terse,
1233 schema,
1234 envelope,
1235 },
1236 ),
1237 Some(Commands::LogDigest {
1238 path,
1239 input,
1240 fixture,
1241 fail_under,
1242 json,
1243 }) => cmd_log_digest(
1244 &path,
1245 input.as_deref(),
1246 fixture.as_deref(),
1247 fail_under,
1248 OutputFormat {
1249 json_output: json || terse || schema || envelope,
1250 compact,
1251 pretty,
1252 terse,
1253 ultra_terse,
1254 schema,
1255 envelope,
1256 },
1257 ),
1258 Some(Commands::ContextPack {
1259 path,
1260 test_input,
1261 runner,
1262 log_input,
1263 json,
1264 max_items,
1265 max_bytes,
1266 budget,
1267 convex_snapshot,
1268 }) => cmd_context_pack(
1269 &path,
1270 test_input.as_deref(),
1271 runner.as_deref(),
1272 log_input.as_deref(),
1273 OutputFormat {
1274 json_output: json || terse || schema || envelope,
1275 compact,
1276 pretty,
1277 terse,
1278 ultra_terse,
1279 schema,
1280 envelope,
1281 },
1282 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1283 convex_snapshot.as_deref(),
1284 ),
1285 Some(Commands::ConflictMatrix {
1286 targets,
1287 path,
1288 scope,
1289 depth,
1290 limit,
1291 impact_limit,
1292 json,
1293 }) => cmd_conflict_matrix(
1294 &path,
1295 scope.as_deref(),
1296 &targets,
1297 depth,
1298 limit,
1299 impact_limit,
1300 OutputFormat {
1301 json_output: json || terse || schema || envelope,
1302 compact,
1303 pretty,
1304 terse,
1305 ultra_terse,
1306 schema,
1307 envelope,
1308 },
1309 ),
1310 Some(Commands::DispatchTrace {
1311 targets,
1312 path,
1313 scope,
1314 depth,
1315 limit,
1316 impact_limit,
1317 format,
1318 json,
1319 }) => cmd_dispatch_trace(
1320 DispatchTraceOptions {
1321 path: &path,
1322 scope: scope.as_deref(),
1323 raw_targets: &targets,
1324 depth,
1325 limit,
1326 impact_limit,
1327 trace_format: if json {
1328 DispatchTraceFormat::Json
1329 } else {
1330 format
1331 },
1332 },
1333 OutputFormat {
1334 json_output: json || terse || schema || envelope,
1335 compact,
1336 pretty,
1337 terse,
1338 ultra_terse,
1339 schema,
1340 envelope,
1341 },
1342 ),
1343 Some(Commands::DependencyDag {
1344 targets,
1345 path,
1346 scope,
1347 depth,
1348 limit,
1349 json,
1350 }) => cmd_dependency_dag(
1351 &path,
1352 scope.as_deref(),
1353 &targets,
1354 depth,
1355 limit,
1356 OutputFormat {
1357 json_output: json || terse || schema || envelope,
1358 compact,
1359 pretty,
1360 terse,
1361 ultra_terse,
1362 schema,
1363 envelope,
1364 },
1365 ),
1366 Some(Commands::TokenSavings {
1367 fixture,
1368 fail_under,
1369 json,
1370 }) => token_savings::cmd_token_savings(
1371 &fixture,
1372 fail_under,
1373 OutputFormat {
1374 json_output: json || terse || schema || envelope,
1375 compact,
1376 pretty,
1377 terse,
1378 ultra_terse,
1379 schema,
1380 envelope,
1381 },
1382 ),
1383 Some(Commands::MetricDigest {
1384 input,
1385 baseline,
1386 metrics,
1387 lower_is_better,
1388 higher_is_better,
1389 history,
1390 top,
1391 json,
1392 }) => cmd_metric_digest(
1393 MetricDigestOptions {
1394 input_path: input.as_deref(),
1395 baseline_path: baseline.as_deref(),
1396 metrics: &metrics,
1397 lower_is_better: &lower_is_better,
1398 higher_is_better: &higher_is_better,
1399 history,
1400 top,
1401 },
1402 OutputFormat {
1403 json_output: json || terse || schema || envelope,
1404 compact,
1405 pretty,
1406 terse,
1407 ultra_terse,
1408 schema,
1409 envelope,
1410 },
1411 ),
1412 Some(Commands::DciBenchmark { fixture, json }) => cmd_dci_benchmark(
1413 &fixture,
1414 OutputFormat {
1415 json_output: json || terse || schema || envelope,
1416 compact,
1417 pretty,
1418 terse,
1419 ultra_terse,
1420 schema,
1421 envelope,
1422 },
1423 ),
1424 Some(Commands::TokenGate { command }) => {
1425 cmd_token_gate(
1426 command,
1427 OutputFormat {
1428 json_output: true,
1429 compact,
1430 pretty,
1431 terse,
1432 ultra_terse,
1433 schema,
1434 envelope,
1435 },
1436 )?;
1437 Ok(())
1438 }
1439 Some(Commands::Workflow { topic, json }) => workflow::cmd_workflow(
1440 &topic,
1441 OutputFormat {
1442 json_output: json || terse || schema || envelope,
1443 compact,
1444 pretty,
1445 terse,
1446 ultra_terse,
1447 schema,
1448 envelope,
1449 },
1450 ),
1451 Some(Commands::SessionDigest {
1452 path,
1453 input,
1454 source,
1455 json,
1456 }) => cmd_session_digest(
1457 &path,
1458 input.as_deref(),
1459 source.as_deref(),
1460 OutputFormat {
1461 json_output: json || terse || schema || envelope,
1462 compact,
1463 pretty,
1464 terse,
1465 ultra_terse,
1466 schema,
1467 envelope,
1468 },
1469 ),
1470 Some(Commands::SessionCost {
1471 input,
1472 fixture,
1473 fail_under,
1474 source,
1475 json,
1476 }) => cmd_session_cost(
1477 input.as_deref(),
1478 fixture.as_deref(),
1479 fail_under,
1480 source.as_deref(),
1481 OutputFormat {
1482 json_output: json || terse || schema || envelope,
1483 compact,
1484 pretty,
1485 terse,
1486 ultra_terse,
1487 schema,
1488 envelope,
1489 },
1490 ),
1491 Some(Commands::SessionReview {
1492 path,
1493 next_context,
1494 json,
1495 max_items,
1496 max_bytes,
1497 budget,
1498 }) => cmd_session_review_with_budget(
1499 &path,
1500 next_context,
1501 OutputFormat {
1502 json_output: json || terse || schema || envelope,
1503 compact,
1504 pretty,
1505 terse,
1506 ultra_terse,
1507 schema,
1508 envelope,
1509 },
1510 ResponseBudget::from_cli(max_items, max_bytes, budget, envelope),
1511 ),
1512 Some(Commands::Status {
1513 path,
1514 fix,
1515 no_fix,
1516 json,
1517 }) => cmd_status(
1518 &path,
1519 StatusCommandOptions {
1520 fix,
1521 no_fix,
1522 json_output: json || terse || schema || envelope,
1523 compact,
1524 pretty,
1525 terse,
1526 schema,
1527 },
1528 ),
1529 Some(Commands::Locks { path, scope, json }) => cmd_locks(
1530 &path,
1531 scope.as_deref(),
1532 json || terse || schema || envelope,
1533 compact,
1534 pretty,
1535 terse,
1536 schema,
1537 ),
1538 None => {
1539 println!("tsift v{}", env!("CARGO_PKG_VERSION"));
1540 println!("Run `tsift --help` for usage.");
1541 Ok(())
1542 }
1543 }
1544}
1545
1546fn cmd_local_model(command: LocalModelCommand, output: OutputFormat) -> Result<()> {
1547 match command {
1548 LocalModelCommand::Status { no_probe, .. } => {
1549 let report = tsift_local_model::build_status_report(!no_probe);
1550 if output.json_output {
1551 if output.pretty {
1552 println!("{}", serde_json::to_string_pretty(&report)?);
1553 } else {
1554 println!("{}", serde_json::to_string(&report)?);
1555 }
1556 } else {
1557 print!("{}", tsift_local_model::format_status_human(&report));
1558 }
1559 Ok(())
1560 }
1561 LocalModelCommand::Unload {
1562 profile,
1563 provider_endpoint,
1564 provider_pid,
1565 idle_ttl_seconds,
1566 no_probe,
1567 pre_used_mib,
1568 post_used_mib,
1569 tolerance_mib,
1570 strict,
1571 ..
1572 } => {
1573 let profile = tsift_local_model::profile_by_id(&profile)
1574 .with_context(|| format!("unknown local model profile {profile:?}"))?;
1575 let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1576 let post_probe =
1577 lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1578 let report = tsift_local_model::build_lifecycle_report(
1579 profile,
1580 pre_probe,
1581 post_probe,
1582 provider_endpoint,
1583 provider_pid,
1584 idle_ttl_seconds,
1585 tolerance_mib,
1586 );
1587 if output.json_output {
1588 if output.pretty {
1589 println!("{}", serde_json::to_string_pretty(&report)?);
1590 } else {
1591 println!("{}", serde_json::to_string(&report)?);
1592 }
1593 } else {
1594 print!("{}", tsift_local_model::format_lifecycle_human(&report));
1595 }
1596 if strict && !report.cleanup.cleanup_proven {
1597 bail!(
1598 "local model VRAM cleanup was not proven: {}",
1599 report.cleanup.reason
1600 );
1601 }
1602 Ok(())
1603 }
1604 LocalModelCommand::Lease { command } => cmd_local_model_lease(command, output),
1605 LocalModelCommand::Resolve {
1606 profile,
1607 role,
1608 no_probe,
1609 ..
1610 } => {
1611 let preference_value = profile.as_deref();
1612 let preference = tsift_local_model::ProfilePreference::from_cli(preference_value);
1613 let probe = if no_probe {
1614 tsift_local_model::GpuProbe::unavailable("gpu probe skipped")
1615 } else {
1616 tsift_local_model::probe_nvidia_smi()
1617 };
1618 let resolution = tsift_local_model::resolve_profile_preference(
1619 &preference,
1620 role.to_model_role(),
1621 &probe,
1622 );
1623 if output.json_output {
1624 if output.pretty {
1625 println!("{}", serde_json::to_string_pretty(&resolution)?);
1626 } else {
1627 println!("{}", serde_json::to_string(&resolution)?);
1628 }
1629 } else {
1630 println!(
1631 "preference: {} | role: {:?}",
1632 preference.describe(),
1633 role.to_model_role()
1634 );
1635 println!(
1636 "selected: {} ({})",
1637 resolution.profile.id, resolution.profile.label
1638 );
1639 println!("selectable: {}", resolution.selectable);
1640 println!("source: {:?}", resolution.source);
1641 println!("reason: {}", resolution.reason);
1642 }
1643 Ok(())
1644 }
1645 LocalModelCommand::Swap {
1646 from,
1647 to,
1648 provider_endpoint,
1649 provider_pid,
1650 idle_ttl_seconds,
1651 no_probe,
1652 pre_used_mib,
1653 post_used_mib,
1654 tolerance_mib,
1655 strict,
1656 ..
1657 } => {
1658 let from_profile = tsift_local_model::profile_by_id(&from)
1659 .with_context(|| format!("unknown source local model profile {from:?}"))?;
1660 let to_profile = tsift_local_model::profile_by_id(&to)
1661 .with_context(|| format!("unknown target local model profile {to:?}"))?;
1662 let pre_probe = lifecycle_probe(no_probe, pre_used_mib, "pre-load GPU probe skipped");
1663 let post_probe =
1664 lifecycle_probe(no_probe, post_used_mib, "post-unload GPU probe skipped");
1665 let report = tsift_local_model::build_swap_report(
1666 from_profile,
1667 to_profile,
1668 pre_probe,
1669 post_probe,
1670 provider_endpoint,
1671 provider_pid,
1672 idle_ttl_seconds,
1673 tolerance_mib,
1674 );
1675 if output.json_output {
1676 if output.pretty {
1677 println!("{}", serde_json::to_string_pretty(&report)?);
1678 } else {
1679 println!("{}", serde_json::to_string(&report)?);
1680 }
1681 } else {
1682 println!(
1683 "swap: {} -> {} | status: {:?}",
1684 report.from_profile_id, report.to_profile_id, report.swap_status
1685 );
1686 println!(
1687 "unload cleanup: {:?} ({})",
1688 report.unload.cleanup.status, report.unload.cleanup.reason
1689 );
1690 println!(
1691 "target resolution: {:?} -> {} (selectable: {})",
1692 report.target_resolution.source,
1693 report.target_resolution.profile.id,
1694 report.target_resolution.selectable
1695 );
1696 for note in &report.notes {
1697 println!("note: {note}");
1698 }
1699 }
1700 if strict {
1701 match report.swap_status {
1702 tsift_local_model::SwapStatus::UnloadNotProven => {
1703 bail!(
1704 "swap blocked: source unload cleanup was not proven ({})",
1705 report.unload.cleanup.reason
1706 );
1707 }
1708 tsift_local_model::SwapStatus::UnloadProvenTargetUnselectable => {
1709 bail!(
1710 "swap blocked: target {} is not selectable on the post-unload probe",
1711 report.to_profile_id
1712 );
1713 }
1714 _ => {}
1715 }
1716 }
1717 Ok(())
1718 }
1719 }
1720}
1721
1722fn unload_profile_model(
1729 profile_id: &str,
1730 host: Option<&str>,
1731) -> tsift_local_model::UnloadActionResult {
1732 let endpoint = tsift_local_model::resolve_provider_endpoint(
1733 &tsift_local_model::UnloadStrategy::OllamaKeepAliveZero,
1734 host,
1735 );
1736 match tsift_local_model::profile_by_id(profile_id) {
1737 Some(profile) => tsift_local_model::unload_model_at(&endpoint, profile.model_ref),
1738 None => tsift_local_model::unload_model_at(&endpoint, profile_id),
1739 }
1740}
1741
1742fn cmd_local_model_lease(command: LeaseCommand, output: OutputFormat) -> Result<()> {
1743 use tsift_local_model::{
1744 acquire_lease, current_unix_seconds, format_lease_show_human, lease_mode_for_profile,
1745 profile_by_id, reap_leases, release_lease, renew_lease, resolve_lease_file, show_registry,
1746 };
1747 let now = current_unix_seconds();
1748 match command {
1749 LeaseCommand::Acquire {
1750 profile,
1751 holder_pid,
1752 holder_command,
1753 idle_ttl_seconds,
1754 vram_baseline_mib,
1755 no_probe,
1756 lease_file,
1757 strict,
1758 ..
1759 } => {
1760 let profile_lookup = profile_by_id(&profile)
1761 .with_context(|| format!("unknown local model profile {profile:?}"))?;
1762 let _ = lease_mode_for_profile(&profile_lookup);
1764 let pid = holder_pid.unwrap_or_else(std::process::id);
1765 let baseline = match vram_baseline_mib {
1766 Some(value) => value,
1767 None => {
1768 if no_probe {
1769 0
1770 } else {
1771 let probe = tsift_local_model::probe_nvidia_smi();
1772 probe.used_vram_mib.unwrap_or(0)
1773 }
1774 }
1775 };
1776 let path = resolve_lease_file(lease_file.as_deref());
1777 let acquisition = acquire_lease(
1778 &profile,
1779 pid,
1780 &holder_command,
1781 baseline,
1782 idle_ttl_seconds,
1783 now,
1784 &path,
1785 )?;
1786 if output.json_output {
1787 if output.pretty {
1788 println!("{}", serde_json::to_string_pretty(&acquisition)?);
1789 } else {
1790 println!("{}", serde_json::to_string(&acquisition)?);
1791 }
1792 } else {
1793 println!(
1794 "lease {} for {} (pid={}): {:?}",
1795 match acquisition.status {
1796 tsift_local_model::GpuLeaseAcquisitionStatus::Acquired => "acquired",
1797 tsift_local_model::GpuLeaseAcquisitionStatus::Refreshed => "refreshed",
1798 tsift_local_model::GpuLeaseAcquisitionStatus::ReclaimedStale => {
1799 "reclaimed-stale"
1800 }
1801 tsift_local_model::GpuLeaseAcquisitionStatus::CpuOrHashBypass => {
1802 "bypass-cpu-or-hash"
1803 }
1804 tsift_local_model::GpuLeaseAcquisitionStatus::Conflict => "conflicted",
1805 },
1806 acquisition.profile_id,
1807 acquisition.holder_pid,
1808 acquisition.status
1809 );
1810 if let Some(conflict) = &acquisition.conflict {
1811 println!(
1812 "held by pid={} cmd={} acquired {}s ago",
1813 conflict.holder_pid,
1814 conflict.holder_command,
1815 now.saturating_sub(conflict.acquired_at_unix_seconds)
1816 );
1817 }
1818 println!("registry: {}", path.display());
1819 }
1820 if strict
1821 && acquisition.status == tsift_local_model::GpuLeaseAcquisitionStatus::Conflict
1822 {
1823 bail!(
1824 "gpu lease for {profile:?} is held by pid={}",
1825 acquisition
1826 .conflict
1827 .map(|conflict| conflict.holder_pid.to_string())
1828 .unwrap_or_else(|| "unknown".to_string())
1829 );
1830 }
1831 Ok(())
1832 }
1833 LeaseCommand::Release {
1834 profile,
1835 holder_pid,
1836 lease_file,
1837 unload_on_last_release,
1838 host,
1839 ..
1840 } => {
1841 let pid = holder_pid.unwrap_or_else(std::process::id);
1842 let path = resolve_lease_file(lease_file.as_deref());
1843 let release = release_lease(&profile, pid, now, &path)?;
1844 let unloaded = if unload_on_last_release
1847 && release.outcome == tsift_local_model::GpuLeaseReleaseOutcome::Released
1848 && release.remaining_holders == 0
1849 {
1850 Some(unload_profile_model(&profile, host.as_deref()))
1851 } else {
1852 None
1853 };
1854 if output.json_output {
1855 let payload = serde_json::json!({
1856 "release": release,
1857 "unloaded": unloaded,
1858 });
1859 if output.pretty {
1860 println!("{}", serde_json::to_string_pretty(&payload)?);
1861 } else {
1862 println!("{}", serde_json::to_string(&payload)?);
1863 }
1864 } else {
1865 println!(
1866 "release {} for {} (pid={}): {:?} (remaining holders: {})",
1867 match release.outcome {
1868 tsift_local_model::GpuLeaseReleaseOutcome::Released => "ok",
1869 tsift_local_model::GpuLeaseReleaseOutcome::NotHeld => "not-held",
1870 tsift_local_model::GpuLeaseReleaseOutcome::ProfileAbsent => "absent",
1871 },
1872 release.profile_id,
1873 release.holder_pid,
1874 release.outcome,
1875 release.remaining_holders
1876 );
1877 if let Some(result) = &unloaded {
1878 println!("unloaded {} (last reference released): {}", profile, result.outcome);
1879 }
1880 println!("registry: {}", path.display());
1881 }
1882 Ok(())
1883 }
1884 LeaseCommand::Renew {
1885 profile,
1886 holder_pid,
1887 lease_file,
1888 ..
1889 } => {
1890 let pid = holder_pid.unwrap_or_else(std::process::id);
1891 let path = resolve_lease_file(lease_file.as_deref());
1892 let renew = renew_lease(&profile, pid, now, &path)?;
1893 if output.json_output {
1894 if output.pretty {
1895 println!("{}", serde_json::to_string_pretty(&renew)?);
1896 } else {
1897 println!("{}", serde_json::to_string(&renew)?);
1898 }
1899 } else {
1900 println!(
1901 "renew {} (pid={}): {:?}",
1902 renew.profile_id, renew.holder_pid, renew.outcome
1903 );
1904 println!("registry: {}", path.display());
1905 }
1906 Ok(())
1907 }
1908 LeaseCommand::Reap {
1909 lease_file,
1910 unload_empty,
1911 host,
1912 ..
1913 } => {
1914 let path = resolve_lease_file(lease_file.as_deref());
1915 let reap = reap_leases(now, &path)?;
1916 let unloaded: Vec<_> = if unload_empty {
1919 reap.emptied_profiles
1920 .iter()
1921 .filter_map(|profile_id| {
1922 profile_by_id(profile_id).map(|profile| {
1923 serde_json::json!({
1924 "profile": profile_id,
1925 "outcome": unload_profile_model(profile_id, host.as_deref()).outcome,
1926 "model": profile.model_ref,
1927 })
1928 })
1929 })
1930 .collect()
1931 } else {
1932 Vec::new()
1933 };
1934 if output.json_output {
1935 let payload = serde_json::json!({
1936 "reap": reap,
1937 "unloaded": unloaded,
1938 });
1939 if output.pretty {
1940 println!("{}", serde_json::to_string_pretty(&payload)?);
1941 } else {
1942 println!("{}", serde_json::to_string(&payload)?);
1943 }
1944 } else {
1945 println!(
1946 "reaped {} stale holder(s); {} profile(s) dropped to zero references",
1947 reap.reclaimed.len(),
1948 reap.emptied_profiles.len()
1949 );
1950 for profile_id in &reap.emptied_profiles {
1951 println!(" emptied: {profile_id}");
1952 }
1953 if !unloaded.is_empty() {
1954 println!("unloaded {} unreferenced model(s)", unloaded.len());
1955 }
1956 println!("registry: {}", path.display());
1957 }
1958 Ok(())
1959 }
1960 LeaseCommand::Show {
1961 lease_file,
1962 include_stale,
1963 ..
1964 } => {
1965 let path = resolve_lease_file(lease_file.as_deref());
1966 let registry = show_registry(&path, now, include_stale)?;
1967 if output.json_output {
1968 if output.pretty {
1969 println!("{}", serde_json::to_string_pretty(®istry)?);
1970 } else {
1971 println!("{}", serde_json::to_string(®istry)?);
1972 }
1973 } else {
1974 print!("{}", format_lease_show_human(®istry, now));
1975 }
1976 println!("registry: {}", path.display());
1977 Ok(())
1978 }
1979 }
1980}
1981
1982fn lifecycle_probe(
1983 no_probe: bool,
1984 synthetic_used_mib: Option<u64>,
1985 skipped_reason: &str,
1986) -> tsift_local_model::GpuProbe {
1987 if let Some(used_mib) = synthetic_used_mib {
1988 return tsift_local_model::GpuProbe::synthetic_vram(used_mib);
1989 }
1990 if no_probe {
1991 return tsift_local_model::GpuProbe::unavailable(skipped_reason);
1992 }
1993 tsift_local_model::probe_nvidia_smi()
1994}
1995
1996pub fn classify_task(task: &str) -> (&'static str, &'static str) {
1999 let lower = task.to_lowercase();
2000 for signal in &[
2002 "architect",
2003 "architecture",
2004 "design",
2005 "plan",
2006 "strateg",
2007 "analy",
2008 "review",
2009 "evaluate",
2010 "assess",
2011 ] {
2012 if lower.contains(signal) {
2013 return ("opus", "claude-opus-4-6");
2014 }
2015 }
2016 for signal in &[
2018 "edit",
2019 "write",
2020 "fix",
2021 "change",
2022 "update",
2023 "create",
2024 "add ",
2025 "remove",
2026 "delete",
2027 "modify",
2028 "refactor",
2029 "implement",
2030 "build",
2031 ] {
2032 if lower.contains(signal) {
2033 return ("sonnet", "claude-sonnet-4-6");
2034 }
2035 }
2036 ("haiku", "claude-haiku-4-5-20251001")
2038}
2039
2040#[cfg(test)]
2041fn to_json<T: serde::Serialize>(val: &T, pretty: bool, terse: bool) -> anyhow::Result<String> {
2042 to_json_schema(val, pretty, terse, false, false)
2043}
2044
2045pub(crate) fn inject_tagpath_stale_into_json(
2052 value: &mut serde_json::Value,
2053 stale: bool,
2054 reason: Option<&str>,
2055) {
2056 if !stale {
2057 return;
2058 }
2059 if let Some(obj) = value.as_object_mut() {
2060 obj.insert(
2061 "tagpath_index_stale".to_string(),
2062 serde_json::Value::Bool(true),
2063 );
2064 if let Some(reason) = reason {
2065 obj.insert(
2066 "tagpath_stale_reason".to_string(),
2067 serde_json::Value::String(reason.to_string()),
2068 );
2069 }
2070 }
2071}
2072
2073pub(crate) fn to_json_schema<T: serde::Serialize>(
2074 val: &T,
2075 pretty: bool,
2076 terse: bool,
2077 ultra_terse: bool,
2078 schema: bool,
2079) -> anyhow::Result<String> {
2080 if terse || schema {
2081 let value = serde_json::to_value(val)?;
2082 let mut transformed = if terse { terse_transform(value) } else { value };
2083 if ultra_terse {
2084 transformed = ultra_terse_transform(transformed);
2085 transformed = edge_index_transform(transformed);
2086 }
2087 if schema {
2088 transformed = schema_transform(transformed);
2089 }
2090 if terse {
2091 let terse_schema = terse_schema_for(&transformed);
2092 let wrapped = serde_json::json!({"_s": terse_schema, "d": transformed});
2093 if pretty {
2094 Ok(serde_json::to_string_pretty(&wrapped)?)
2095 } else {
2096 Ok(serde_json::to_string(&wrapped)?)
2097 }
2098 } else if pretty {
2099 Ok(serde_json::to_string_pretty(&transformed)?)
2100 } else {
2101 Ok(serde_json::to_string(&transformed)?)
2102 }
2103 } else if pretty {
2104 Ok(serde_json::to_string_pretty(val)?)
2105 } else {
2106 Ok(serde_json::to_string(val)?)
2107 }
2108}
2109
2110pub(crate) fn envelope_metric(label: &str, value: impl ToString) -> ToolEnvelopeMetric {
2111 ToolEnvelopeMetric {
2112 label: label.to_string(),
2113 value: value.to_string(),
2114 }
2115}
2116
2117pub(crate) fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
2118 let mut seen = HashSet::new();
2119 let mut deduped = Vec::new();
2120 for value in values {
2121 if seen.insert(value.clone()) {
2122 deduped.push(value);
2123 }
2124 }
2125 deduped
2126}
2127
2128pub(crate) fn print_json_or_envelope<T: Serialize>(
2129 report: &T,
2130 format: &OutputFormat,
2131 tool: &str,
2132 view: &str,
2133 summary: ToolEnvelopeSummary,
2134 truncated: bool,
2135 follow_up: Vec<String>,
2136) -> Result<()> {
2137 if format.envelope {
2138 let schema = format.schema || tool == "source-read";
2139 let envelope = ToolEnvelope {
2140 tool,
2141 view,
2142 summary,
2143 truncated,
2144 follow_up: dedupe_preserve_order(follow_up),
2145 report,
2146 };
2147 println!(
2148 "{}",
2149 to_json_schema(
2150 &envelope,
2151 format.pretty,
2152 format.terse,
2153 format.ultra_terse,
2154 schema
2155 )?
2156 );
2157 } else {
2158 println!(
2159 "{}",
2160 to_json_schema(
2161 report,
2162 format.pretty,
2163 format.terse,
2164 format.ultra_terse,
2165 format.schema
2166 )?
2167 );
2168 }
2169 Ok(())
2170}
2171
2172pub(crate) fn estimated_tokens_from_bytes(bytes: usize) -> usize {
2173 bytes.div_ceil(4)
2174}
2175
2176fn cmd_token_gate(command: cli::TokenGateCommand, format: OutputFormat) -> Result<()> {
2177 match command {
2178 cli::TokenGateCommand::Sample {
2179 surface,
2180 path,
2181 scope,
2182 target,
2183 depth,
2184 sample_index,
2185 json: _,
2186 } => cmd_token_gate_sample(
2187 &surface,
2188 &path,
2189 scope.as_deref(),
2190 target.as_deref(),
2191 depth,
2192 sample_index,
2193 ),
2194 cli::TokenGateCommand::Evaluate {
2195 history,
2196 allowed_regression_percent,
2197 json: _,
2198 } => cmd_token_gate_evaluate(history.as_deref(), allowed_regression_percent, &format),
2199 }
2200}
2201
2202fn cmd_token_gate_sample(
2203 surface: &str,
2204 path: &Path,
2205 scope: Option<&str>,
2206 target: Option<&str>,
2207 depth: usize,
2208 sample_index: usize,
2209) -> Result<()> {
2210 if !token_gate::TOKEN_GATE_SURFACES.contains(&surface) {
2211 bail!(
2212 "unknown surface `{}`; expected one of: {}",
2213 surface,
2214 token_gate::TOKEN_GATE_SURFACES.join(", ")
2215 );
2216 }
2217
2218 let path_str = path.to_string_lossy().to_string();
2219 let tsift_bin = std::env::current_exe()?;
2220
2221 let args: Vec<String> = match surface {
2222 "context_pack" => vec!["context-pack".to_string(), "--json".to_string(), path_str],
2223 "session_review_next_context" => vec![
2224 "session-review".to_string(),
2225 "--json".to_string(),
2226 "--next-context".to_string(),
2227 path_str,
2228 ],
2229 "graph_db_evidence" => {
2230 let tgt = target.unwrap_or("default").to_string();
2231 vec![
2232 "graph-db".to_string(),
2233 "--json".to_string(),
2234 "--path".to_string(),
2235 path_str,
2236 "evidence".to_string(),
2237 tgt,
2238 "--depth".to_string(),
2239 depth.to_string(),
2240 ]
2241 }
2242 "conflict_matrix" => {
2243 let tgt = target.unwrap_or("default").to_string();
2244 let mut a = vec![
2245 "conflict-matrix".to_string(),
2246 "--json".to_string(),
2247 "--path".to_string(),
2248 path_str,
2249 "--depth".to_string(),
2250 depth.to_string(),
2251 ];
2252 if let Some(s) = scope {
2253 a.push("--scope".to_string());
2254 a.push(s.to_string());
2255 }
2256 a.push(tgt);
2257 a
2258 }
2259 "dispatch_trace" => {
2260 let tgt = target.unwrap_or("default").to_string();
2261 vec![
2262 "dispatch-trace".to_string(),
2263 "--json".to_string(),
2264 "--path".to_string(),
2265 path_str,
2266 tgt,
2267 ]
2268 }
2269 _ => bail!("unhandled surface: {}", surface),
2270 };
2271
2272 let start = Instant::now();
2273 let child = Command::new(&tsift_bin)
2274 .args(&args)
2275 .stdout(Stdio::piped())
2276 .stderr(Stdio::piped())
2277 .env("TSIFT_QUIET", "1")
2278 .spawn();
2279 let output = match child {
2280 Ok(c) => c.wait_with_output()?,
2281 Err(e) => bail!("failed to spawn tsift for surface {}: {}", surface, e),
2282 };
2283 let runtime_micros = start.elapsed().as_micros() as f64;
2284
2285 let stdout = String::from_utf8_lossy(&output.stdout);
2286 let envelope_bytes = stdout.trim().len() as f64;
2287 let prompt_tokens = estimated_tokens_from_bytes(stdout.trim().len()) as f64;
2288
2289 let cache_hit_rate_percent = 0.0;
2290 let raw_read_avoidance = 0.0;
2291 let useful_hit_density = if prompt_tokens > 0.0 { 0.5 } else { 0.0 };
2292
2293 let timestamp = iso_timestamp_now();
2294 let id = format!(
2295 "{surface}-baseline-{}-sample-{sample_index}",
2296 ×tamp[..10]
2297 );
2298 let label = format!(
2299 "token-gate baseline {surface} sample {sample_index} for {}",
2300 path.display()
2301 );
2302
2303 let mut metrics = BTreeMap::new();
2304 metrics.insert("prompt_tokens".to_string(), prompt_tokens);
2305 metrics.insert("envelope_bytes".to_string(), envelope_bytes);
2306 metrics.insert("runtime_micros".to_string(), runtime_micros);
2307 metrics.insert("cache_hit_rate_percent".to_string(), cache_hit_rate_percent);
2308 metrics.insert("raw_read_avoidance".to_string(), raw_read_avoidance);
2309 metrics.insert("useful_hit_density".to_string(), useful_hit_density);
2310
2311 let sample = token_gate::TokenGateSample {
2312 label,
2313 id,
2314 timestamp: Some(timestamp),
2315 surface: surface.to_string(),
2316 metrics,
2317 };
2318
2319 println!("{}", serde_json::to_string_pretty(&sample)?);
2320 Ok(())
2321}
2322
2323fn cmd_token_gate_evaluate(
2324 history_path: Option<&Path>,
2325 allowed_regression_percent: f64,
2326 format: &OutputFormat,
2327) -> Result<()> {
2328 let history_path = history_path.map(PathBuf::from).unwrap_or_else(|| {
2329 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2330 p.push("../../fixtures/token-gate-history.json");
2331 p
2332 });
2333
2334 let raw = std::fs::read_to_string(&history_path).with_context(|| {
2335 format!(
2336 "failed to read token gate history: {}",
2337 history_path.display()
2338 )
2339 })?;
2340 let samples = token_gate::parse_token_history(&raw)?;
2341 let report = token_gate::evaluate_token_gate(&samples, allowed_regression_percent);
2342
2343 if format.json_output {
2344 println!(
2345 "{}",
2346 to_json_schema(&report, format.pretty, format.terse, false, format.schema)?
2347 );
2348 } else {
2349 println!("Token Gate Report");
2350 println!(" min_samples: {}", report.min_samples);
2351 println!(
2352 " allowed_regression: {:.1}%",
2353 report.allowed_regression_percent
2354 );
2355 println!(" decision: {:?}", report.decision);
2356 for eval in &report.surface_evaluations {
2357 println!(
2358 " {} ({} samples): {:?}",
2359 eval.display_name, eval.sample_count, eval.verdict
2360 );
2361 for me in &eval.metric_evaluations {
2362 println!(" {} ({:?}): {}", me.metric, me.direction, me.diagnostic);
2363 }
2364 }
2365 for d in &report.diagnostics {
2366 println!(" ! {}", d);
2367 }
2368 }
2369 Ok(())
2370}
2371
2372fn iso_timestamp_now() -> String {
2373 let dur = SystemTime::now()
2374 .duration_since(UNIX_EPOCH)
2375 .unwrap_or_default();
2376 let total_secs = dur.as_secs();
2377 let days_since_epoch = total_secs / 86400;
2378 let (year, month, day) = days_to_ymd(days_since_epoch);
2379 let time_of_day = total_secs % 86400;
2380 let hour = (time_of_day / 3600) as u8;
2381 let minute = ((time_of_day % 3600) / 60) as u8;
2382 let second = (time_of_day % 60) as u8;
2383 format!(
2384 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
2385 year, month, day, hour, minute, second
2386 )
2387}
2388
2389fn days_to_ymd(mut days: u64) -> (u64, u8, u8) {
2390 let mut year = 1970u64;
2391 loop {
2392 let days_in_year = if is_leap(year) { 366 } else { 365 };
2393 if days < days_in_year {
2394 break;
2395 }
2396 days -= days_in_year;
2397 year += 1;
2398 }
2399 let leap = is_leap(year);
2400 let month_days: [u8; 12] = if leap {
2401 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2402 } else {
2403 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
2404 };
2405 let mut month: u8 = 1;
2406 for &md in &month_days {
2407 if days < md as u64 {
2408 break;
2409 }
2410 days -= md as u64;
2411 month += 1;
2412 }
2413 let day = days as u8 + 1;
2414 (year, month, day)
2415}
2416
2417fn is_leap(year: u64) -> bool {
2418 year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
2419}
2420
2421fn persist_transcript_artifact(
2422 root: &Path,
2423 prefix: &str,
2424 suffix: &str,
2425 key: &str,
2426 body: &str,
2427 expand: String,
2428) -> Result<TranscriptArtifactRef> {
2429 let handle = stable_handle(prefix, key);
2430 let artifacts_dir = root.join(".tsift/artifacts");
2431 fs::create_dir_all(&artifacts_dir).with_context(|| {
2432 format!(
2433 "creating transcript artifacts dir: {}",
2434 artifacts_dir.display()
2435 )
2436 })?;
2437 let file_name = format!("{handle}.{suffix}");
2438 let artifact_path = artifacts_dir.join(file_name);
2439 fs::write(&artifact_path, body)
2440 .with_context(|| format!("writing transcript artifact: {}", artifact_path.display()))?;
2441 let rel_path = relativize_pathbuf(&artifact_path, root);
2442 Ok(TranscriptArtifactRef {
2443 handle,
2444 path: rel_path.display().to_string(),
2445 bytes: body.len(),
2446 lines: body.lines().count(),
2447 expand,
2448 })
2449}
2450
2451fn terse_key(key: &str) -> &str {
2452 match key {
2453 "name" => "n",
2454 "kind" => "k",
2455 "file" => "f",
2456 "line" => "l",
2457 "path" => "p",
2458 "from" => "fr",
2459 "type" => "ty",
2460 "text" => "tx",
2461 "new" => "nw",
2462 "run" => "r",
2463 "use" => "u",
2464 "score" => "sc",
2465 "language" => "la",
2466 "status" => "st",
2467 "state" => "stt",
2468 "error" => "err",
2469 "errors" => "ers",
2470 "hops" => "hp",
2471 "tags" => "tg",
2472 "model" => "ml",
2473 "skill" => "sk",
2474 "count" => "ct",
2475 "total" => "tot",
2476 "column" => "col",
2477 "description" => "dsc",
2478 "end_line" => "el",
2479 "signature" => "sig",
2480 "parent_module" => "pm",
2481 "visibility" => "vis",
2482 "match_type" => "mt",
2483 "caller_file" => "cf",
2484 "caller_name" => "cn",
2485 "caller_line" => "cl",
2486 "callee_name" => "en",
2487 "call_site_line" => "csl",
2488 "members" => "m",
2489 "refs" => "refs",
2490 "role" => "rl",
2491 "peer" => "pr",
2492 "modularity" => "q",
2493 "modularity_contribution" => "mc",
2494 "iterations" => "it",
2495 "node_count" => "nc",
2496 "edge_count" => "ec",
2497 "community_count" => "cc",
2498 "communities" => "cms",
2499 "community" => "cm",
2500 "community_diagnostics" => "cd",
2501 "cache_hit" => "cah",
2502 "tagpath_state" => "tps",
2503 "tagpath_stale_reason" => "tsr",
2504 "annotated_community_count" => "acc",
2505 "annotated_member_count" => "amc",
2506 "ambiguous_member_count" => "ambc",
2507 "ambiguous_members" => "amb",
2508 "candidate_count" => "cand",
2509 "tagpath_candidate_count" => "tcand",
2510 "evidence" => "ev",
2511 "chosen_file" => "chf",
2512 "symbol" => "s",
2513 "symbols" => "sy",
2514 "definitions" => "df",
2515 "callers" => "crs",
2516 "callees" => "ces",
2517 "total_tracked" => "tt",
2518 "modified" => "md",
2519 "deleted" => "dl",
2520 "unchanged" => "uc",
2521 "changes" => "ch",
2522 "prune_stats" => "ps",
2523 "hits" => "h",
2524 "rank" => "rk",
2525 "snippet" => "sn",
2526 "confidence" => "co",
2527 "index" => "ix",
2528 "summaries" => "sms",
2529 "recommendations" => "rec",
2530 "total_files" => "tf",
2531 "stale_files" => "sf",
2532 "last_indexed_secs_ago" => "age",
2533 "cached_files" => "caf",
2534 "total_indexed_files" => "tif",
2535 "coverage_pct" => "cov",
2536 "symbol_name" => "syn",
2537 "file_path" => "fp",
2538 "content_hash" => "hsh",
2539 "summary" => "sum",
2540 "tool" => "tl",
2541 "view" => "vw",
2542 "truncated" => "tr",
2543 "follow_up" => "fu",
2544 "report" => "rp",
2545 "metrics" => "ms",
2546 "label" => "lb",
2547 "value" => "v",
2548 "command" => "cmd",
2549 "exit_code" => "xc",
2550 "success" => "ok",
2551 "artifact" => "art",
2552 "digest" => "dg",
2553 "bytes" => "bt",
2554 "lines" => "lns",
2555 "expand" => "xp",
2556 "entities" => "ent",
2557 "relationships" => "rel",
2558 "concept_labels" => "cls",
2559 "extracted_at" => "at",
2560 "tokens_input" => "ti",
2561 "tokens_output" => "tout",
2562 "total_summaries" => "ts",
2563 "stale_count" => "stc",
2564 "total_tokens_input" => "tti",
2565 "total_tokens_output" => "tto",
2566 "estimated_tokens_saved" => "ets",
2567 "files_processed" => "fps",
2568 "symbols_extracted" => "se",
2569 "skills_dir" => "sd",
2570 "healthy" => "ok",
2571 "broken" => "brk",
2572 "skills" => "sks",
2573 "manifest_diffs" => "mdf",
2574 "similar_pairs" => "sim",
2575 "usage" => "usg",
2576 "cleanup" => "cln",
2577 "has_skill_md" => "hsm",
2578 "is_symlink" => "isl",
2579 "issues" => "iss",
2580 "invocation_count" => "inv",
2581 "reasons" => "rsn",
2582 "token_estimate" => "te",
2583 "skill_a" => "sa",
2584 "skill_b" => "sb",
2585 "desc_a" => "da",
2586 "desc_b" => "db",
2587 "annotations" => "ann",
2588 "entity" => "ety",
2589 "suggestion" => "sug",
2590 "columns" => "cols",
2591 "row_count" => "rc",
2592 "notnull" => "nn",
2593 "default_value" => "dv",
2594 "replace_all" => "ra",
2595 other => other,
2596 }
2597}
2598
2599fn terse_transform(val: serde_json::Value) -> serde_json::Value {
2600 match val {
2601 serde_json::Value::Object(map) => {
2602 let mut new_map = serde_json::Map::new();
2603 for (k, v) in map {
2604 new_map.insert(terse_key(&k).to_string(), terse_transform(v));
2605 }
2606 serde_json::Value::Object(new_map)
2607 }
2608 serde_json::Value::Array(arr) => {
2609 serde_json::Value::Array(arr.into_iter().map(terse_transform).collect())
2610 }
2611 other => other,
2612 }
2613}
2614
2615fn ultra_terse_transform(val: serde_json::Value) -> serde_json::Value {
2616 match val {
2617 serde_json::Value::Object(mut map) => {
2618 let is_graph_node =
2619 map.contains_key("id") && map.contains_key("k") && map.contains_key("n");
2620 let is_graph_edge =
2621 map.contains_key("from_id") && map.contains_key("to_id") && map.contains_key("k");
2622 if is_graph_node || is_graph_edge {
2623 map.remove("properties");
2624 map.remove("provenance");
2625 map.remove("freshness");
2626 }
2627 if is_graph_edge && let Some(serde_json::Value::String(s)) = map.get_mut("k") {
2628 *s = abbreviate_edge_kind(s).to_string();
2629 }
2630 let is_coverage = map.contains_key("mode")
2631 && (map.contains_key("total_sector_count")
2632 || map.contains_key("dirty_sector_count"));
2633 if is_coverage {
2634 map.remove("active_rebuild");
2635 map.remove("completed_dirty_sector_count");
2636 map.remove("mounted_sector_count");
2637 map.remove("rebuilding_sector_count");
2638 map.remove("resumed_sector_count");
2639 map.remove("reused_sector_count");
2640 }
2641 if let Some(serde_json::Value::String(s)) = map.get_mut("sn") {
2642 *s = truncate_for_ultra_terse(s, 80);
2643 }
2644 if let Some(serde_json::Value::String(s)) = map.get_mut("snippet") {
2645 *s = truncate_for_ultra_terse(s, 80);
2646 }
2647 let new_map: serde_json::Map<String, serde_json::Value> = map
2648 .into_iter()
2649 .map(|(k, v)| (k, ultra_terse_transform(v)))
2650 .collect();
2651 serde_json::Value::Object(new_map)
2652 }
2653 serde_json::Value::Array(arr) => {
2654 serde_json::Value::Array(arr.into_iter().map(ultra_terse_transform).collect())
2655 }
2656 other => other,
2657 }
2658}
2659
2660fn edge_index_transform(val: serde_json::Value) -> serde_json::Value {
2661 match val {
2662 serde_json::Value::Object(mut map) => {
2663 let node_ids: Option<Vec<String>> = map.get("nodes").and_then(|nodes| {
2664 nodes.as_array().map(|arr| {
2665 arr.iter()
2666 .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(String::from))
2667 .collect()
2668 })
2669 });
2670 if let Some(ref ids) = node_ids {
2671 let id_map: std::collections::HashMap<&str, usize> = ids
2672 .iter()
2673 .enumerate()
2674 .map(|(i, id)| (id.as_str(), i))
2675 .collect();
2676 if let Some(serde_json::Value::Array(edges)) = map.get_mut("edges") {
2677 for edge in edges.iter_mut() {
2678 if let serde_json::Value::Object(edge_map) = edge {
2679 if let Some(serde_json::Value::String(fid)) = edge_map.remove("from_id")
2680 {
2681 if let Some(&idx) = id_map.get(fid.as_str()) {
2682 edge_map.insert(
2683 "from".to_string(),
2684 serde_json::Value::Number(idx.into()),
2685 );
2686 } else {
2687 edge_map.insert(
2688 "from_id".to_string(),
2689 serde_json::Value::String(fid),
2690 );
2691 }
2692 }
2693 if let Some(serde_json::Value::String(tid)) = edge_map.remove("to_id") {
2694 if let Some(&idx) = id_map.get(tid.as_str()) {
2695 edge_map.insert(
2696 "to".to_string(),
2697 serde_json::Value::Number(idx.into()),
2698 );
2699 } else {
2700 edge_map.insert(
2701 "to_id".to_string(),
2702 serde_json::Value::String(tid),
2703 );
2704 }
2705 }
2706 }
2707 }
2708 }
2709 }
2710 let new_map: serde_json::Map<String, serde_json::Value> = map
2711 .into_iter()
2712 .map(|(k, v)| (k, edge_index_transform(v)))
2713 .collect();
2714 serde_json::Value::Object(new_map)
2715 }
2716 serde_json::Value::Array(arr) => {
2717 serde_json::Value::Array(arr.into_iter().map(edge_index_transform).collect())
2718 }
2719 other => other,
2720 }
2721}
2722
2723fn truncate_for_ultra_terse(s: &str, max_len: usize) -> String {
2724 if s.len() <= max_len {
2725 s.to_string()
2726 } else {
2727 let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2728 format!("{truncated}...")
2729 }
2730}
2731
2732fn terse_schema_for(val: &serde_json::Value) -> serde_json::Value {
2733 let mut keys = HashSet::new();
2734 collect_terse_keys(val, &mut keys);
2735 let mut schema = serde_json::Map::new();
2736 for (long, short) in TERSE_PAIRS {
2737 if keys.contains(*short) {
2738 schema.insert(
2739 short.to_string(),
2740 serde_json::Value::String(long.to_string()),
2741 );
2742 }
2743 }
2744 serde_json::Value::Object(schema)
2745}
2746
2747fn collect_terse_keys(val: &serde_json::Value, keys: &mut HashSet<String>) {
2748 match val {
2749 serde_json::Value::Object(map) => {
2750 for (k, v) in map {
2751 keys.insert(k.clone());
2752 collect_terse_keys(v, keys);
2753 }
2754 }
2755 serde_json::Value::Array(arr) => {
2756 for v in arr {
2757 collect_terse_keys(v, keys);
2758 }
2759 }
2760 _ => {}
2761 }
2762}
2763
2764fn schema_transform(val: serde_json::Value) -> serde_json::Value {
2765 match val {
2766 serde_json::Value::Array(arr) if arr.len() >= 2 => {
2767 if let Some(cols) = homogeneous_keys(&arr) {
2768 let rows: Vec<serde_json::Value> = arr
2769 .into_iter()
2770 .map(|item| {
2771 if let serde_json::Value::Object(map) = item {
2772 let vals: Vec<serde_json::Value> = cols
2773 .iter()
2774 .map(|c| map.get(c).cloned().unwrap_or(serde_json::Value::Null))
2775 .collect();
2776 serde_json::Value::Array(vals)
2777 } else {
2778 item
2779 }
2780 })
2781 .collect();
2782 let col_vals: Vec<serde_json::Value> =
2783 cols.into_iter().map(serde_json::Value::String).collect();
2784 serde_json::json!({"_c": col_vals, "_r": rows})
2785 } else {
2786 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2787 }
2788 }
2789 serde_json::Value::Array(arr) => {
2790 serde_json::Value::Array(arr.into_iter().map(schema_transform).collect())
2791 }
2792 serde_json::Value::Object(map) => {
2793 let new_map: serde_json::Map<String, serde_json::Value> = map
2794 .into_iter()
2795 .map(|(k, v)| (k, schema_transform(v)))
2796 .collect();
2797 serde_json::Value::Object(new_map)
2798 }
2799 other => other,
2800 }
2801}
2802
2803fn homogeneous_keys(arr: &[serde_json::Value]) -> Option<Vec<String>> {
2804 let first = arr.first()?.as_object()?;
2805 let keys: Vec<String> = first.keys().cloned().collect();
2806 for item in &arr[1..] {
2807 let obj = item.as_object()?;
2808 if obj.len() != keys.len() {
2809 return None;
2810 }
2811 for k in &keys {
2812 if !obj.contains_key(k) {
2813 return None;
2814 }
2815 }
2816 }
2817 Some(keys)
2818}
2819
2820const TERSE_PAIRS: &[(&str, &str)] = &[
2821 ("name", "n"),
2822 ("kind", "k"),
2823 ("file", "f"),
2824 ("line", "l"),
2825 ("path", "p"),
2826 ("from", "fr"),
2827 ("type", "ty"),
2828 ("text", "tx"),
2829 ("new", "nw"),
2830 ("run", "r"),
2831 ("use", "u"),
2832 ("score", "sc"),
2833 ("language", "la"),
2834 ("status", "st"),
2835 ("state", "stt"),
2836 ("error", "err"),
2837 ("errors", "ers"),
2838 ("hops", "hp"),
2839 ("tags", "tg"),
2840 ("model", "ml"),
2841 ("skill", "sk"),
2842 ("count", "ct"),
2843 ("total", "tot"),
2844 ("column", "col"),
2845 ("description", "dsc"),
2846 ("end_line", "el"),
2847 ("signature", "sig"),
2848 ("parent_module", "pm"),
2849 ("visibility", "vis"),
2850 ("match_type", "mt"),
2851 ("caller_file", "cf"),
2852 ("caller_name", "cn"),
2853 ("caller_line", "cl"),
2854 ("callee_name", "en"),
2855 ("call_site_line", "csl"),
2856 ("members", "m"),
2857 ("refs", "refs"),
2858 ("role", "rl"),
2859 ("peer", "pr"),
2860 ("modularity", "q"),
2861 ("modularity_contribution", "mc"),
2862 ("iterations", "it"),
2863 ("node_count", "nc"),
2864 ("edge_count", "ec"),
2865 ("community_count", "cc"),
2866 ("communities", "cms"),
2867 ("community", "cm"),
2868 ("community_diagnostics", "cd"),
2869 ("cache_hit", "cah"),
2870 ("tagpath_state", "tps"),
2871 ("tagpath_stale_reason", "tsr"),
2872 ("annotated_community_count", "acc"),
2873 ("annotated_member_count", "amc"),
2874 ("ambiguous_member_count", "ambc"),
2875 ("ambiguous_members", "amb"),
2876 ("candidate_count", "cand"),
2877 ("tagpath_candidate_count", "tcand"),
2878 ("evidence", "ev"),
2879 ("chosen_file", "chf"),
2880 ("symbol", "s"),
2881 ("symbols", "sy"),
2882 ("definitions", "df"),
2883 ("callers", "crs"),
2884 ("callees", "ces"),
2885 ("total_tracked", "tt"),
2886 ("modified", "md"),
2887 ("deleted", "dl"),
2888 ("unchanged", "uc"),
2889 ("changes", "ch"),
2890 ("prune_stats", "ps"),
2891 ("hits", "h"),
2892 ("rank", "rk"),
2893 ("snippet", "sn"),
2894 ("confidence", "co"),
2895 ("index", "ix"),
2896 ("summaries", "sms"),
2897 ("recommendations", "rec"),
2898 ("total_files", "tf"),
2899 ("stale_files", "sf"),
2900 ("last_indexed_secs_ago", "age"),
2901 ("cached_files", "caf"),
2902 ("total_indexed_files", "tif"),
2903 ("coverage_pct", "cov"),
2904 ("symbol_name", "syn"),
2905 ("file_path", "fp"),
2906 ("content_hash", "hsh"),
2907 ("summary", "sum"),
2908 ("tool", "tl"),
2909 ("view", "vw"),
2910 ("truncated", "tr"),
2911 ("follow_up", "fu"),
2912 ("report", "rp"),
2913 ("metrics", "ms"),
2914 ("label", "lb"),
2915 ("value", "v"),
2916 ("command", "cmd"),
2917 ("exit_code", "xc"),
2918 ("success", "ok"),
2919 ("artifact", "art"),
2920 ("digest", "dg"),
2921 ("bytes", "bt"),
2922 ("lines", "lns"),
2923 ("expand", "xp"),
2924 ("entities", "ent"),
2925 ("relationships", "rel"),
2926 ("concept_labels", "cls"),
2927 ("extracted_at", "at"),
2928 ("tokens_input", "ti"),
2929 ("tokens_output", "tout"),
2930 ("total_summaries", "ts"),
2931 ("stale_count", "stc"),
2932 ("total_tokens_input", "tti"),
2933 ("total_tokens_output", "tto"),
2934 ("estimated_tokens_saved", "ets"),
2935 ("files_processed", "fps"),
2936 ("symbols_extracted", "se"),
2937 ("skills_dir", "sd"),
2938 ("healthy", "ok"),
2939 ("broken", "brk"),
2940 ("skills", "sks"),
2941 ("manifest_diffs", "mdf"),
2942 ("similar_pairs", "sim"),
2943 ("usage", "usg"),
2944 ("cleanup", "cln"),
2945 ("has_skill_md", "hsm"),
2946 ("is_symlink", "isl"),
2947 ("issues", "iss"),
2948 ("invocation_count", "inv"),
2949 ("reasons", "rsn"),
2950 ("token_estimate", "te"),
2951 ("skill_a", "sa"),
2952 ("skill_b", "sb"),
2953 ("desc_a", "da"),
2954 ("desc_b", "db"),
2955 ("annotations", "ann"),
2956 ("entity", "ety"),
2957 ("suggestion", "sug"),
2958 ("columns", "cols"),
2959 ("row_count", "rc"),
2960 ("notnull", "nn"),
2961 ("default_value", "dv"),
2962 ("replace_all", "ra"),
2963];
2964
2965pub(crate) fn relativize(path: &str, root: &std::path::Path) -> String {
2966 let root_str = root.to_string_lossy();
2967 let prefix = format!("{}/", root_str.trim_end_matches('/'));
2968 path.strip_prefix(&prefix).unwrap_or(path).to_string()
2969}
2970
2971fn transcript_artifact_root(path: &Path) -> Result<PathBuf> {
2972 let canonical = path
2973 .canonicalize()
2974 .with_context(|| format!("canonicalizing {}", path.display()))?;
2975 let start = if canonical.is_dir() {
2976 canonical.clone()
2977 } else {
2978 canonical
2979 .parent()
2980 .map(Path::to_path_buf)
2981 .unwrap_or_else(|| canonical.clone())
2982 };
2983
2984 for ancestor in start.ancestors() {
2985 if ancestor.join(".git").exists() || ancestor.join(".gitmodules").is_file() {
2986 return Ok(ancestor.to_path_buf());
2987 }
2988 }
2989
2990 Ok(start)
2991}
2992
2993pub(crate) fn relativize_pathbuf(path: &std::path::Path, root: &std::path::Path) -> PathBuf {
2994 path.strip_prefix(root)
2995 .map(|p| p.to_path_buf())
2996 .unwrap_or_else(|_| path.to_path_buf())
2997}
2998
2999pub(crate) fn relativize_edges(edges: &mut [index::StoredEdge], root: &std::path::Path) {
3000 for edge in edges {
3001 edge.caller_file = relativize(&edge.caller_file, root);
3002 }
3003}
3004
3005pub(crate) fn relativize_symbols(symbols: &mut [index::StoredSymbol], root: &std::path::Path) {
3006 for sym in symbols {
3007 sym.file = relativize(&sym.file, root);
3008 }
3009}
3010
3011pub(crate) fn relativize_symbol_hits(hits: &mut [index::SymbolHit], root: &std::path::Path) {
3012 for hit in hits {
3013 hit.file = relativize(&hit.file, root);
3014 }
3015}
3016
3017#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3020pub enum EdgeSide {
3021 Caller,
3022 Callee,
3023}
3024
3025const JSON_PATH_KEYS: &[&str] = &["file", "path", "caller_file", "file_path"];
3026
3027pub(crate) fn relativize_json_paths(val: &mut serde_json::Value, root: &std::path::Path) {
3028 let root_str = root.to_string_lossy();
3029 let prefix = format!("{}/", root_str.trim_end_matches('/'));
3030 relativize_json_inner(val, &prefix);
3031}
3032
3033fn relativize_json_inner(val: &mut serde_json::Value, prefix: &str) {
3034 match val {
3035 serde_json::Value::Array(arr) => {
3036 for v in arr {
3037 relativize_json_inner(v, prefix);
3038 }
3039 }
3040 serde_json::Value::Object(map) => {
3041 for (k, v) in map.iter_mut() {
3042 if JSON_PATH_KEYS.contains(&k.as_str())
3043 && let serde_json::Value::String(s) = v
3044 && let Some(rest) = s.strip_prefix(prefix)
3045 {
3046 *s = rest.to_string();
3047 }
3048 relativize_json_inner(v, prefix);
3049 }
3050 }
3051 _ => {}
3052 }
3053}
3054
3055pub(crate) fn format_score(score: f64, compact: bool) -> String {
3056 if compact {
3057 format!("{score:.2}")
3058 } else {
3059 format!("{score:.4}")
3060 }
3061}
3062
3063pub(crate) fn truncate_for_compact(input: &str, max_chars: usize) -> String {
3064 let trimmed = input.trim();
3065 let count = trimmed.chars().count();
3066 if count <= max_chars {
3067 return trimmed.to_string();
3068 }
3069 let prefix: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect();
3070 format!("{prefix}...")
3071}
3072
3073pub(crate) fn compact_snippet(snippet: &str) -> Option<String> {
3074 snippet
3075 .lines()
3076 .find(|line| !line.trim().is_empty())
3077 .map(|line| truncate_for_compact(line, 100))
3078}
3079
3080pub(crate) fn compact_members(members: &[graph::CommunityMember], limit: usize) -> String {
3081 let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
3082 if names.len() <= limit {
3083 return names.join(", ");
3084 }
3085 format!(
3086 "{} (+{} more)",
3087 names[..limit].join(", "),
3088 names.len() - limit
3089 )
3090}
3091
3092pub(crate) fn stable_handle(prefix: &str, key: &str) -> String {
3093 let mut hasher = blake3::Hasher::new();
3094 hasher.update(prefix.as_bytes());
3095 hasher.update(&[0]);
3096 hasher.update(key.as_bytes());
3097 let hex = hasher.finalize().to_hex();
3098 format!("{prefix}-{}", &hex[..10])
3099}
3100
3101#[derive(Clone, Debug, PartialEq, Eq)]
3102struct CanonicalTagFamily {
3103 canonical: String,
3104 tag_alias: String,
3105}
3106
3107fn canonical_family_from_tagpath_family(
3108 family: tagpath_family::TagFamily,
3109) -> Option<CanonicalTagFamily> {
3110 let tag_alias = if family.dimensions.is_empty() {
3111 family.tags.join("/")
3112 } else {
3113 family
3114 .dimensions
3115 .iter()
3116 .filter(|dimension| !dimension.tags.is_empty())
3117 .map(|dimension| dimension.tags.join("."))
3118 .collect::<Vec<_>>()
3119 .join("/")
3120 };
3121
3122 if tag_alias.is_empty() {
3123 None
3124 } else {
3125 Some(CanonicalTagFamily {
3126 canonical: family.canonical,
3127 tag_alias,
3128 })
3129 }
3130}
3131
3132fn canonical_tag_family_from_name(name: &str) -> Option<CanonicalTagFamily> {
3133 let trimmed = name.trim();
3134 if trimmed.is_empty() {
3135 return None;
3136 }
3137
3138 canonical_family_from_tagpath_family(tagpath_family::generate_family(trimmed))
3139}
3140
3141fn canonical_tag_family_from_tags(tags: &str) -> Option<CanonicalTagFamily> {
3142 let canonical = tags
3143 .split(',')
3144 .map(str::trim)
3145 .filter(|tag| !tag.is_empty())
3146 .collect::<Vec<_>>()
3147 .join("_");
3148 if canonical.is_empty() {
3149 None
3150 } else {
3151 canonical_family_from_tagpath_family(tagpath_family::generate_family(&canonical))
3152 }
3153}
3154
3155pub(crate) fn canonical_tag_family_from_symbol(
3156 name: &str,
3157 tags: Option<&str>,
3158) -> Option<CanonicalTagFamily> {
3159 tags.and_then(canonical_tag_family_from_tags)
3160 .or_else(|| canonical_tag_family_from_name(name))
3161}
3162
3163fn tag_alias_from_name(name: &str) -> Option<String> {
3164 canonical_tag_family_from_name(name).map(|family| family.tag_alias)
3165}
3166
3167fn tag_alias_from_tags(name: &str, tags: Option<&str>) -> Option<String> {
3168 canonical_tag_family_from_symbol(name, tags).map(|family| family.tag_alias)
3169}
3170
3171pub(crate) fn family_query_from_tag_alias(tag_alias: &str) -> Option<String> {
3172 let query = tag_alias
3173 .split(['/', '.'])
3174 .map(str::trim)
3175 .filter(|part| !part.is_empty())
3176 .collect::<Vec<_>>()
3177 .join(" ");
3178 if query.is_empty() { None } else { Some(query) }
3179}
3180
3181#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3182struct CompactOntologyRefPreview {
3183 handle: String,
3184 tag: String,
3185 path: String,
3186 #[serde(skip_serializing_if = "Option::is_none")]
3187 title: Option<String>,
3188 #[serde(skip_serializing_if = "Option::is_none")]
3189 domain: Option<String>,
3190}
3191
3192#[derive(Clone, Debug)]
3193struct TagOntologyPreviewContext {
3194 project_root: PathBuf,
3195 tags: BTreeMap<String, tagpath_ontology::OntologyTag>,
3196}
3197
3198#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
3199struct CompactSymbolRefPreview {
3200 handle: String,
3201 name: String,
3202 #[serde(skip_serializing_if = "Option::is_none")]
3203 tag_alias: Option<String>,
3204 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3205 ontology_refs: Vec<CompactOntologyRefPreview>,
3206}
3207
3208fn build_compact_symbol_ref(
3209 prefix: &str,
3210 key: &str,
3211 name: &str,
3212 tags: Option<&str>,
3213 max_bytes: usize,
3214) -> CompactSymbolRefPreview {
3215 build_compact_symbol_ref_with_ontology(prefix, key, name, tags, max_bytes, None)
3216}
3217
3218fn build_compact_symbol_ref_with_ontology(
3219 prefix: &str,
3220 key: &str,
3221 name: &str,
3222 tags: Option<&str>,
3223 max_bytes: usize,
3224 ontology: Option<&TagOntologyPreviewContext>,
3225) -> CompactSymbolRefPreview {
3226 let tag_alias = tag_alias_from_tags(name, tags);
3227 let ontology_refs = tag_alias
3228 .as_deref()
3229 .map(|alias| ontology_refs_for_alias(ontology, alias))
3230 .unwrap_or_default();
3231 CompactSymbolRefPreview {
3232 handle: stable_handle(prefix, key),
3233 name: truncate_for_budget(name, max_bytes),
3234 tag_alias: tag_alias.map(|alias| truncate_for_budget(&alias, max_bytes)),
3235 ontology_refs,
3236 }
3237}
3238
3239fn load_tag_ontology_preview_context(root: &Path) -> Option<TagOntologyPreviewContext> {
3240 let report = tagpath_ontology::load_project(root).ok()?;
3241 if report.tags.is_empty() {
3242 return None;
3243 }
3244 Some(TagOntologyPreviewContext {
3245 project_root: report.project_path,
3246 tags: report
3247 .tags
3248 .into_iter()
3249 .map(|tag| (tag.tag.clone(), tag))
3250 .collect(),
3251 })
3252}
3253
3254fn ontology_refs_for_alias(
3255 ontology: Option<&TagOntologyPreviewContext>,
3256 alias: &str,
3257) -> Vec<CompactOntologyRefPreview> {
3258 let Some(ontology) = ontology else {
3259 return Vec::new();
3260 };
3261 let mut seen = BTreeSet::new();
3262 alias
3263 .split('/')
3264 .flat_map(|part| part.split('.'))
3265 .map(str::trim)
3266 .filter(|tag| !tag.is_empty())
3267 .filter_map(|tag| {
3268 let key = tag.to_ascii_lowercase();
3269 if !seen.insert(key.clone()) {
3270 return None;
3271 }
3272 let ontology_tag = ontology.tags.get(&key)?;
3273 let path = relativize_ontology_path(&ontology_tag.path, &ontology.project_root);
3274 Some(CompactOntologyRefPreview {
3275 handle: stable_handle("tont", &format!("{}:{path}", ontology_tag.tag)),
3276 tag: ontology_tag.tag.clone(),
3277 path,
3278 title: ontology_tag.title.clone(),
3279 domain: ontology_tag.domain.clone(),
3280 })
3281 })
3282 .collect()
3283}
3284
3285fn relativize_ontology_path(path: &Path, root: &Path) -> String {
3286 path.strip_prefix(root)
3287 .unwrap_or(path)
3288 .to_string_lossy()
3289 .replace('\\', "/")
3290}
3291
3292fn format_symbol_preview_line(handle: &str, name: &str, tag_alias: Option<&str>) -> String {
3293 match tag_alias {
3294 Some(alias) => format!("{handle} {name} tag:{alias}"),
3295 None => format!("{handle} {name}"),
3296 }
3297}
3298
3299fn format_summary_ref_line(summary: &ContextPackSummaryRefPreview) -> String {
3300 match summary.tag_alias.as_deref() {
3301 Some(alias) => format!(
3302 "{} {} tag:{} expand:{}",
3303 summary.handle, summary.symbol, alias, summary.expand
3304 ),
3305 None => format!(
3306 "{} {} expand:{}",
3307 summary.handle, summary.symbol, summary.expand
3308 ),
3309 }
3310}
3311
3312fn compact_symbol_ref_token(symbol: &CompactSymbolRefPreview) -> String {
3313 match symbol.tag_alias.as_deref() {
3314 Some(alias) => format!("{}@{}", symbol.handle, alias),
3315 None => format!("{}@{}", symbol.handle, symbol.name),
3316 }
3317}
3318
3319pub(crate) fn truncate_for_budget(input: &str, max_bytes: usize) -> String {
3320 let trimmed = input.trim();
3321 if trimmed.len() <= max_bytes {
3322 return trimmed.to_string();
3323 }
3324 if max_bytes <= 3 {
3325 return ".".repeat(max_bytes);
3326 }
3327
3328 let mut end = 0usize;
3329 for (idx, ch) in trimmed.char_indices() {
3330 let next = idx + ch.len_utf8();
3331 if next > max_bytes.saturating_sub(3) {
3332 break;
3333 }
3334 end = next;
3335 }
3336
3337 if end == 0 {
3338 "...".to_string()
3339 } else {
3340 format!("{}...", &trimmed[..end])
3341 }
3342}
3343
3344struct TokenCappedPreview {
3345 preview: Vec<SourceLinePreview>,
3346 capped_end: usize,
3347 was_capped: bool,
3348}
3349
3350fn build_token_capped_preview(
3351 all_lines: &[&str],
3352 start: usize,
3353 end: usize,
3354 max_bytes: usize,
3355 token_cap: usize,
3356) -> TokenCappedPreview {
3357 let mut preview = Vec::new();
3358 let mut accumulated_tokens = 0usize;
3359 let mut capped_end = end;
3360 let mut was_capped = false;
3361
3362 for (idx, line) in all_lines[(start - 1)..end].iter().enumerate() {
3363 let truncated = truncate_for_budget(line, max_bytes);
3364 let line_tokens = estimated_tokens_from_bytes(truncated.len());
3365 if accumulated_tokens + line_tokens > token_cap && !preview.is_empty() {
3366 capped_end = start + idx - 1;
3367 was_capped = true;
3368 break;
3369 }
3370 accumulated_tokens += line_tokens;
3371 preview.push(SourceLinePreview {
3372 line: start + idx,
3373 text: truncated,
3374 });
3375 }
3376
3377 TokenCappedPreview {
3378 preview,
3379 capped_end,
3380 was_capped,
3381 }
3382}
3383
3384pub(crate) fn abbreviate_kind(kind: &str) -> &str {
3385 match kind {
3386 "function" => "fn",
3387 "method" => "meth",
3388 "module" | "mod" => "mod",
3389 "struct" => "struct",
3390 "trait" => "trait",
3391 "impl" => "impl",
3392 "class" => "cls",
3393 "interface" => "iface",
3394 "type_alias" => "type",
3395 "data_class" => "data_cls",
3396 "sealed_class" => "sealed_cls",
3397 "enum_class" => "enum_cls",
3398 "companion_object" => "comp_obj",
3399 "object" => "obj",
3400 "heading" => "h",
3401 "code_block" => "code",
3402 "alias" => "alias",
3403 other => other,
3404 }
3405}
3406
3407pub(crate) fn abbreviate_edge_kind(kind: &str) -> &str {
3408 match kind {
3409 "calls" => "c",
3410 "defines" => "d",
3411 "contains" => "ct",
3412 "imports" => "i",
3413 "mentions" => "m",
3414 "mentions_concept" => "mc",
3415 "mentions_entity" => "me",
3416 "semantic_relation" => "sr",
3417 "belongs_to" => "bt",
3418 "scopes_context" => "sctx",
3419 "scopes_source" => "ssrc",
3420 "requests_context" => "rctx",
3421 "explains_result" => "er",
3422 "tagged_concept" => "tc",
3423 "tagged_entity" => "te",
3424 "related_concept" => "relc",
3425 "handled_by" => "hb",
3426 "defines_route" => "dr",
3427 "handles_route" => "hr",
3428 "targets" => "tgt",
3429 "has_vector_handle" => "hv",
3430 "parent" => "p",
3431 "child" => "ch",
3432 "uses" => "u",
3433 "projects_source" => "psrc",
3434 "records_memory_source" => "rms",
3435 "records_memory_event" => "rme",
3436 "has_ast_span" => "ha",
3437 "represents_symbol" => "rs",
3438 "contains_embedded_symbol" => "ces",
3439 "embedded_in_fence" => "ef",
3440 "contains_markdown_block" => "cmb",
3441 "contains_embedded_code" => "cec",
3442 "enclosing_module" => "em",
3443 "enclosing_section" => "es",
3444 "previous_sibling" => "psib",
3445 "next_sibling" => "nsib",
3446 "explicit_depends_on" => "edo",
3447 "worker_result_follow_up" => "wrf",
3448 "shared_resource" => "shr",
3449 "community_member" => "cm",
3450 other => other,
3451 }
3452}
3453
3454pub(crate) fn abbreviate_match_type(mt: &str) -> &str {
3455 match mt {
3456 "exact_name" => "exact",
3457 "all_tags" => "all_tags",
3458 "partial_tags" => "partial",
3459 other => other,
3460 }
3461}
3462
3463pub(crate) fn symbol_path_summary(path: &[graph::PathNode]) -> String {
3464 path.iter()
3465 .map(|n| n.name.as_str())
3466 .collect::<Vec<_>>()
3467 .join(" -> ")
3468}
3469
3470const SEARCH_GROUP_SAMPLE_LIMIT: usize = 2;
3471
3472struct SearchHitGroup {
3473 path: String,
3474 first_rank: usize,
3475 top_score: f64,
3476 confidence: String,
3477 hits: usize,
3478 samples: Vec<String>,
3479}
3480
3481fn format_search_sample(hit: &sift::SearchHit) -> Option<String> {
3482 let snippet = compact_snippet(&hit.snippet)?;
3483 Some(match hit.location.as_deref() {
3484 Some(location) => format!("{location}: {snippet}"),
3485 None => snippet,
3486 })
3487}
3488
3489pub(crate) fn group_search_hits(
3490 hits: &[sift::SearchHit],
3491 root: &Path,
3492 absolute: bool,
3493) -> Vec<SearchHitGroup> {
3494 let mut positions = BTreeMap::new();
3495 let mut groups = Vec::new();
3496 for hit in hits {
3497 let path = if absolute {
3498 hit.path.clone()
3499 } else {
3500 relativize(&hit.path, root)
3501 };
3502 let entry = positions.entry(path.clone()).or_insert_with(|| {
3503 groups.push(SearchHitGroup {
3504 path: path.clone(),
3505 first_rank: hit.rank,
3506 top_score: hit.score,
3507 confidence: format!("{:?}", hit.confidence),
3508 hits: 0,
3509 samples: Vec::new(),
3510 });
3511 groups.len() - 1
3512 });
3513 let group = &mut groups[*entry];
3514 group.hits += 1;
3515 if hit.rank < group.first_rank {
3516 group.first_rank = hit.rank;
3517 }
3518 if hit.score > group.top_score {
3519 group.top_score = hit.score;
3520 }
3521 if let Some(sample) = format_search_sample(hit)
3522 && group.samples.len() < SEARCH_GROUP_SAMPLE_LIMIT
3523 && !group.samples.contains(&sample)
3524 {
3525 group.samples.push(sample);
3526 }
3527 }
3528 groups.sort_by_key(|group| group.first_rank);
3529 groups
3530}
3531
3532pub(crate) fn should_collapse_search_hits(
3533 hits: &[sift::SearchHit],
3534 root: &Path,
3535 absolute: bool,
3536) -> bool {
3537 let groups = group_search_hits(hits, root, absolute);
3538 let max_hits_per_file = groups.iter().map(|group| group.hits).max().unwrap_or(0);
3539 max_hits_per_file >= 3 || (hits.len() >= 6 && groups.len() < hits.len())
3540}
3541
3542pub(crate) fn format_edge_groups(edges: &[index::StoredEdge], use_callers: bool) -> Vec<String> {
3543 let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
3544 for edge in edges {
3545 let key = edge.caller_file.as_str();
3546 let name = if use_callers {
3547 edge.caller_name.as_str()
3548 } else {
3549 edge.callee_name.as_str()
3550 };
3551 let names = grouped.entry(key).or_default();
3552 if !names.contains(&name) {
3553 names.push(name);
3554 }
3555 }
3556
3557 grouped
3558 .into_iter()
3559 .map(|(file, names)| format!(" {} ({}): {}", file, names.len(), names.join(", ")))
3560 .collect()
3561}
3562
3563pub(crate) fn should_collapse_edge_groups(edges: &[index::StoredEdge]) -> bool {
3564 let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
3565 for edge in edges {
3566 *grouped.entry(edge.caller_file.as_str()).or_default() += 1;
3567 }
3568 let max_hits_per_file = grouped.values().copied().max().unwrap_or(0);
3569 max_hits_per_file >= 3 || (edges.len() >= 6 && grouped.len() < edges.len())
3570}
3571
3572fn resolve_query_index_target(
3573 root: &Path,
3574 path_hint: &Path,
3575 scope: Option<&str>,
3576) -> Result<SearchIndexTarget> {
3577 let cfg = config::Config::load(root)?;
3578 if let Some(scope_name) = scope {
3579 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3580 return Ok(SearchIndexTarget {
3581 label: format!("submodule `{}` index", scope.id),
3582 db_path: cfg.db_path_for(root, &scope.id),
3583 source_root: scope.source_root.clone(),
3584 scope_name: Some(scope.id.clone()),
3585 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3586 });
3587 }
3588 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3589 return Ok(cargo_package_index_target(root, package));
3590 }
3591 config::Config::resolve_submodule(root, scope_name)?;
3592 }
3593
3594 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3595 return Ok(SearchIndexTarget {
3596 label: format!("submodule `{}` index", scope.id),
3597 db_path: cfg.db_path_for(root, &scope.id),
3598 source_root: scope.source_root.clone(),
3599 scope_name: Some(scope.id.clone()),
3600 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3601 });
3602 }
3603
3604 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3605 return Ok(cargo_package_index_target(root, package));
3606 }
3607
3608 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
3609 return Ok(SearchIndexTarget {
3610 label: format!("submodule `{}` index", scope.id),
3611 db_path: cfg.db_path_for(root, &scope.id),
3612 source_root: scope.source_root.clone(),
3613 scope_name: Some(scope.id.clone()),
3614 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
3615 });
3616 }
3617
3618 let db_path = root.join(".tsift/index.db");
3619 if db_path.exists() {
3620 return Ok(SearchIndexTarget {
3621 label: "index".to_string(),
3622 db_path,
3623 source_root: root.to_path_buf(),
3624 scope_name: None,
3625 reindex_cmd: format!("tsift index {}", root.display()),
3626 });
3627 }
3628
3629 let scopes = config::Config::submodule_dirs(root)?;
3630 if scopes.is_empty() {
3631 return Ok(SearchIndexTarget {
3632 label: "index".to_string(),
3633 db_path,
3634 source_root: root.to_path_buf(),
3635 scope_name: None,
3636 reindex_cmd: format!("tsift index {}", root.display()),
3637 });
3638 }
3639
3640 let available_scopes = scopes
3641 .iter()
3642 .map(|scope| scope.id.as_str())
3643 .collect::<Vec<_>>()
3644 .join(", ");
3645 let indexed_scopes = scopes
3646 .iter()
3647 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
3648 .map(|scope| scope.id.as_str())
3649 .collect::<Vec<_>>();
3650 let indexed_label = if indexed_scopes.is_empty() {
3651 "none".to_string()
3652 } else {
3653 indexed_scopes.join(", ")
3654 };
3655
3656 bail!(
3657 "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: {}.",
3658 root.display(),
3659 db_path.display(),
3660 available_scopes,
3661 indexed_label
3662 );
3663}
3664
3665pub(crate) fn resolve_query_db_path(
3666 root: &Path,
3667 path_hint: &Path,
3668 scope: Option<&str>,
3669) -> Result<PathBuf> {
3670 Ok(resolve_query_index_target(root, path_hint, scope)?.db_path)
3671}
3672
3673fn ensure_query_index_current(root: &Path, target: &SearchIndexTarget) -> Result<()> {
3674 let state = inspect_search_index(target)?;
3675 let Some(reason) = index_reason_for_state(state) else {
3676 return Ok(());
3677 };
3678
3679 match apply_search_index_update(root, target) {
3680 Ok(_) => {
3681 index::inspect_scope_invalidate_all();
3682 Ok(())
3683 }
3684 Err(err) if is_active_writer_lock_error(&err) && target.db_path.exists() => {
3685 eprintln!(
3686 "note: active tsift writer detected; skipping graph-query autoindex because {}. \
3687 Continuing with the current read-only index snapshot; graph results may lag. \
3688 Retry `{}` after the active writer finishes for fresh graph results.",
3689 index_reason_detail(target, reason),
3690 target.reindex_cmd
3691 );
3692 Ok(())
3693 }
3694 Err(err) => Err(err),
3695 }
3696}
3697
3698pub(crate) fn open_index_db(path: &std::path::Path, scope: Option<&str>) -> Result<index::IndexDb> {
3699 let root = lint::resolve_project_root_or_canonical_path(path)?;
3700 let target = resolve_query_index_target(&root, path, scope)?;
3701 ensure_query_index_current(&root, &target)?;
3702 let db_path = target.db_path;
3703 if !db_path.exists() {
3704 bail!(
3705 "no index found at {}. Run `tsift index` first.",
3706 db_path.display()
3707 );
3708 }
3709 index::IndexDb::open_read_only_resilient(&db_path)
3710}
3711
3712pub(crate) fn query_tagpath_root(
3713 root: &std::path::Path,
3714 path_hint: &std::path::Path,
3715 scope: Option<&str>,
3716) -> Result<PathBuf> {
3717 if let Some(scope_name) = scope {
3718 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
3719 return Ok(scope.source_root);
3720 }
3721 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
3722 return Ok(package.package_root);
3723 }
3724 config::Config::resolve_submodule(root, scope_name)?;
3725 }
3726 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
3727 return Ok(scope.source_root);
3728 }
3729 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
3730 return Ok(package.package_root);
3731 }
3732 Ok(root.to_path_buf())
3733}
3734
3735#[derive(Clone, Debug, Serialize, PartialEq)]
3736struct TraversalNode {
3737 handle: String,
3738 kind: String,
3739 label: String,
3740 #[serde(skip_serializing_if = "Option::is_none")]
3741 ref_id: Option<String>,
3742 #[serde(skip_serializing_if = "Option::is_none")]
3743 path: Option<String>,
3744 #[serde(skip_serializing_if = "Option::is_none")]
3745 line: Option<i64>,
3746 #[serde(skip_serializing_if = "Option::is_none")]
3747 detail: Option<String>,
3748 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3749 properties: BTreeMap<String, String>,
3750 expand: String,
3751}
3752
3753#[derive(Clone, Debug, Serialize, PartialEq)]
3754struct TraversalEdge {
3755 from: String,
3756 to: String,
3757 relation: String,
3758 #[serde(skip_serializing_if = "Option::is_none")]
3759 label: Option<String>,
3760 weight: usize,
3761}
3762
3763#[derive(Clone, Debug, Default)]
3764struct TraversalGraphBuild {
3765 nodes: BTreeMap<String, TraversalNode>,
3766 edges: Vec<TraversalEdge>,
3767 edge_keys: BTreeSet<(String, String, String)>,
3768 warnings: Vec<String>,
3769}
3770
3771pub(crate) const GRAPH_PROJECTION_VERSION: &str = "tsift-traversal-v1";
3772const GRAPH_DB_EVIDENCE_CONTRACT_VERSION: &str = "graph-db-evidence-v1";
3773const WORKER_PROMPT_PACKET_CONTRACT_VERSION: &str = "worker-prompt-packet-v1";
3774const CONFLICT_MATRIX_CONTRACT_VERSION: &str = "conflict-matrix-v1";
3775const CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION: &str =
3776 "context-pack-graph-orchestration-v1";
3777const SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION: &str = "session-review-follow-up-v1";
3778const DISPATCH_TRACE_CONTRACT_VERSION: &str = "dispatch-trace-v1";
3779const DEPENDENCY_DAG_CONTRACT_VERSION: &str = "dependency-dag-v1";
3780const GRAPH_PROJECTION_META_KIND: &str = "projection_meta";
3781const GRAPH_DB_RANKED_NEIGHBOR_CAP: usize = 12;
3782const GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP: usize = 16;
3783const GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP: usize = 64;
3784
3785#[derive(Debug, Serialize, PartialEq)]
3786struct TraversalTotals {
3787 nodes: usize,
3788 edges: usize,
3789}
3790
3791#[derive(Debug, Serialize, PartialEq)]
3792struct TraversalPathReport {
3793 from: TraversalNode,
3794 to: TraversalNode,
3795 hops: usize,
3796 nodes: Vec<TraversalNode>,
3797 edges: Vec<TraversalEdge>,
3798}
3799
3800#[derive(Debug, Serialize, PartialEq)]
3801struct TraversalRecommendation {
3802 handle: String,
3803 kind: String,
3804 label: String,
3805 reason: String,
3806 score: usize,
3807 expand: String,
3808}
3809
3810#[derive(Debug, Serialize, PartialEq)]
3811struct TraversalReport {
3812 root: String,
3813 #[serde(skip_serializing_if = "Option::is_none")]
3814 scope: Option<String>,
3815 mode: String,
3816 totals: TraversalTotals,
3817 #[serde(skip_serializing_if = "Option::is_none")]
3818 query: Option<String>,
3819 #[serde(skip_serializing_if = "Option::is_none")]
3820 target: Option<String>,
3821 nodes: Vec<TraversalNode>,
3822 edges: Vec<TraversalEdge>,
3823 #[serde(skip_serializing_if = "Option::is_none")]
3824 shortest_path: Option<TraversalPathReport>,
3825 recommendations: Vec<TraversalRecommendation>,
3826 exploration: ExplorationPacket,
3827 truncated: bool,
3828 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3829 warnings: Vec<String>,
3830}
3831
3832#[derive(Debug, Serialize, PartialEq)]
3833struct SemanticRelatedReport {
3834 root: String,
3835 #[serde(skip_serializing_if = "Option::is_none")]
3836 scope: Option<String>,
3837 query: String,
3838 embedding_model: String,
3839 count: usize,
3840 items: Vec<SemanticRelatedItem>,
3841 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3842 warnings: Vec<String>,
3843}
3844
3845#[derive(Clone, Debug, Serialize, PartialEq)]
3846struct SemanticRelatedItem {
3847 handle: String,
3848 kind: String,
3849 label: String,
3850 score: f64,
3851 #[serde(skip_serializing_if = "Option::is_none")]
3852 file_path: Option<String>,
3853 #[serde(skip_serializing_if = "Option::is_none")]
3854 source_symbol: Option<String>,
3855 #[serde(skip_serializing_if = "Option::is_none")]
3856 detail: Option<String>,
3857 expand: String,
3858}
3859
3860#[derive(Clone)]
3861struct TraversalSymbolIndexEntry {
3862 handle: String,
3863 node: TraversalNode,
3864 tokens: BTreeSet<String>,
3865}
3866
3867#[derive(Clone)]
3868struct TraversalFileIndexEntry {
3869 handle: String,
3870 node: TraversalNode,
3871 tokens: BTreeSet<String>,
3872}
3873
3874#[derive(Clone)]
3875struct TraversalRouteIndexEntry {
3876 handle: String,
3877 node: TraversalNode,
3878 tokens: BTreeSet<String>,
3879}
3880
3881#[derive(Clone)]
3882struct TraversalAstSpanIndexEntry {
3883 handle: String,
3884 symbol_handle: String,
3885 file_handle: Option<String>,
3886 file: String,
3887 name: String,
3888 kind: String,
3889 language: String,
3890 node_kind: String,
3891 start_byte: usize,
3892 end_byte: usize,
3893 parent_module: Option<String>,
3894 markdown: Option<MarkdownSpanMetadata>,
3895}
3896
3897#[derive(Clone)]
3898struct TraversalMultiplicityIndexEntry {
3899 handle: String,
3900 node: TraversalNode,
3901 tokens: BTreeSet<String>,
3902}
3903
3904struct TraversalCodeLookup<'a> {
3905 symbols: &'a [TraversalSymbolIndexEntry],
3906 files: &'a [TraversalFileIndexEntry],
3907 routes: &'a [TraversalRouteIndexEntry],
3908 multiplicities: &'a [TraversalMultiplicityIndexEntry],
3909 symbol_index: HashMap<String, Vec<usize>>,
3910 file_index: HashMap<String, Vec<usize>>,
3911 route_index: HashMap<String, Vec<usize>>,
3912 multiplicity_index: HashMap<String, Vec<usize>>,
3913 file_path_index: HashMap<String, String>,
3914}
3915
3916#[derive(Clone, Debug, Serialize, PartialEq)]
3917struct ExplorationBudget {
3918 project_size: String,
3919 max_source_windows: usize,
3920 lines_per_window: usize,
3921 relationship_limit: usize,
3922}
3923
3924#[derive(Clone, Debug, Serialize, PartialEq)]
3925struct ExplorationRelation {
3926 from: String,
3927 relation: String,
3928 to: String,
3929 #[serde(skip_serializing_if = "Option::is_none")]
3930 label: Option<String>,
3931}
3932
3933#[derive(Clone, Debug, Serialize, PartialEq)]
3934struct ExplorationSourceWindow {
3935 handle: String,
3936 file: String,
3937 start: usize,
3938 end: usize,
3939 reason: String,
3940 expand: String,
3941}
3942
3943#[derive(Clone, Debug, Serialize, PartialEq)]
3944struct ExplorationWorkerContext {
3945 handle: String,
3946 target: String,
3947 summary: String,
3948 expand: String,
3949}
3950
3951#[derive(Clone, Debug, Serialize, PartialEq)]
3952struct ExplorationPacket {
3953 budget: ExplorationBudget,
3954 relationship_map: Vec<ExplorationRelation>,
3955 source_windows: Vec<ExplorationSourceWindow>,
3956 #[serde(skip_serializing_if = "Vec::is_empty", default)]
3957 worker_context: Vec<ExplorationWorkerContext>,
3958 no_reread_guidance: String,
3959}
3960
3961impl TraversalGraphBuild {
3962 fn add_node(&mut self, node: TraversalNode) {
3963 self.nodes.entry(node.handle.clone()).or_insert(node);
3964 }
3965
3966 fn add_edge(
3967 &mut self,
3968 from: &str,
3969 to: &str,
3970 relation: &str,
3971 label: Option<String>,
3972 weight: usize,
3973 ) {
3974 if from == to || !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
3975 return;
3976 }
3977 let key = (from.to_string(), to.to_string(), relation.to_string());
3978 if self.edge_keys.insert(key) {
3979 self.edges.push(TraversalEdge {
3980 from: from.to_string(),
3981 to: to.to_string(),
3982 relation: relation.to_string(),
3983 label,
3984 weight,
3985 });
3986 }
3987 }
3988}
3989
3990pub(crate) fn graph_substrate_db_path(root: &Path, scope: Option<&str>) -> PathBuf {
3991 match scope {
3992 Some(scope) => root.join(".tsift/indexes").join(scope).join("graph.db"),
3993 None => root.join(".tsift/graph.db"),
3994 }
3995}
3996
3997fn graph_projection_meta_id(scope: Option<&str>) -> String {
3998 format!("projection:tsift-traversal:{}", scope.unwrap_or("root"))
3999}
4000
4001pub(crate) fn content_hash<T: Serialize>(value: &T) -> Result<String> {
4002 let bytes = serde_json::to_vec(value)?;
4003 Ok(blake3::hash(&bytes).to_hex().to_string())
4004}
4005
4006fn node_with_content_freshness(mut node: SubstrateGraphNode) -> Result<SubstrateGraphNode> {
4007 let mut hashable = node.clone();
4008 hashable.freshness = None;
4009 node.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4010 Ok(node)
4011}
4012
4013fn edge_with_content_freshness(mut edge: SubstrateGraphEdge) -> Result<SubstrateGraphEdge> {
4014 let mut hashable = edge.clone();
4015 hashable.freshness = None;
4016 edge.freshness = Some(GraphFreshness::content_hash(content_hash(&hashable)?));
4017 Ok(edge)
4018}
4019
4020const SEMANTIC_EMBEDDING_DIM: usize = 32;
4021const SEMANTIC_EMBEDDING_MODEL: &str = "tsift-local-hash-v1";
4022
4023fn semantic_related_kind_name(kind: SemanticRelatedKind) -> &'static str {
4024 match kind {
4025 SemanticRelatedKind::Concept => "concept",
4026 SemanticRelatedKind::Entity => "entity",
4027 SemanticRelatedKind::All => "all",
4028 }
4029}
4030
4031fn semantic_related_command(root: &Path, query: &str, kind: SemanticRelatedKind) -> String {
4032 format!(
4033 "tsift semantic {} --path {} --kind {} --limit 10",
4034 shell_quote(query),
4035 shell_quote(root.to_string_lossy().as_ref()),
4036 semantic_related_kind_name(kind)
4037 )
4038}
4039
4040fn semantic_embedding(input: &str) -> Vec<f64> {
4041 let mut vector = vec![0.0; SEMANTIC_EMBEDDING_DIM];
4042 let mut tokens = traversal_tokens(input);
4043 if tokens.is_empty() {
4044 let trimmed = input.trim().to_ascii_lowercase();
4045 if !trimmed.is_empty() {
4046 tokens.insert(trimmed);
4047 }
4048 }
4049
4050 for token in tokens {
4051 let hash = blake3::hash(token.as_bytes());
4052 let bytes = hash.as_bytes();
4053 let idx = usize::from(bytes[0]) % SEMANTIC_EMBEDDING_DIM;
4054 let sign = if bytes[1] & 1 == 0 { 1.0 } else { -1.0 };
4055 vector[idx] += sign;
4056 }
4057
4058 let norm = vector.iter().map(|value| value * value).sum::<f64>().sqrt();
4059 if norm > 0.0 {
4060 for value in &mut vector {
4061 *value /= norm;
4062 }
4063 }
4064 vector
4065}
4066
4067fn semantic_embedding_property(input: &str) -> String {
4068 semantic_embedding(input)
4069 .iter()
4070 .map(|value| format!("{value:.6}"))
4071 .collect::<Vec<_>>()
4072 .join(",")
4073}
4074
4075fn parse_semantic_embedding_property(value: &str) -> Option<Vec<f64>> {
4076 let parsed = value
4077 .split(',')
4078 .map(str::trim)
4079 .map(str::parse::<f64>)
4080 .collect::<std::result::Result<Vec<_>, _>>()
4081 .ok()?;
4082 (parsed.len() == SEMANTIC_EMBEDDING_DIM).then_some(parsed)
4083}
4084
4085fn semantic_cosine(left: &[f64], right: &[f64]) -> f64 {
4086 if left.len() != right.len() {
4087 return 0.0;
4088 }
4089 left.iter()
4090 .zip(right.iter())
4091 .map(|(left, right)| left * right)
4092 .sum::<f64>()
4093}
4094
4095fn semantic_entity_handle(name: &str, kind: &str) -> String {
4096 stable_handle(
4097 "gent",
4098 &format!(
4099 "entity:{}:{}",
4100 kind.trim().to_ascii_lowercase(),
4101 name.trim().to_ascii_lowercase()
4102 ),
4103 )
4104}
4105
4106fn semantic_concept_handle(label: &str) -> String {
4107 stable_handle(
4108 "gcon",
4109 &format!("concept:{}", label.trim().to_ascii_lowercase()),
4110 )
4111}
4112
4113fn summary_source_handles(
4114 summary: &summarize::Summary,
4115 file_node_by_path: &BTreeMap<String, String>,
4116 symbol_node_by_file_label: &BTreeMap<(String, String), String>,
4117) -> Vec<String> {
4118 let mut handles = Vec::new();
4119 if let Some(handle) = file_node_by_path.get(&summary.file_path) {
4120 handles.push(handle.clone());
4121 }
4122 if let Some(handle) =
4123 symbol_node_by_file_label.get(&(summary.file_path.clone(), summary.symbol_name.clone()))
4124 && !handles.iter().any(|existing| existing == handle)
4125 {
4126 handles.push(handle.clone());
4127 }
4128 handles
4129}
4130
4131fn semantic_entity_node(
4132 root: &Path,
4133 summary: &summarize::Summary,
4134 name: &str,
4135 kind: &str,
4136 description: &str,
4137 provenance: &GraphProvenance,
4138) -> SubstrateGraphNode {
4139 let handle = semantic_entity_handle(name, kind);
4140 let detail = if description.trim().is_empty() {
4141 format!("{kind} entity from cached summaries")
4142 } else {
4143 format!("{kind}: {description}")
4144 };
4145 SubstrateGraphNode::new(handle.clone(), "semantic_entity", name.to_string())
4146 .with_property("handle", handle)
4147 .with_property("ref_id", name.to_string())
4148 .with_property("detail", detail)
4149 .with_property("entity_kind", kind.to_string())
4150 .with_property("description", description.to_string())
4151 .with_property("source_file", summary.file_path.clone())
4152 .with_property("source_symbol", summary.symbol_name.clone())
4153 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4154 .with_property(
4155 "embedding",
4156 semantic_embedding_property(&format!("{name} {kind} {description}")),
4157 )
4158 .with_property(
4159 "expand",
4160 semantic_related_command(root, name, SemanticRelatedKind::Entity),
4161 )
4162 .with_provenance(provenance.clone())
4163}
4164
4165fn semantic_concept_node(
4166 root: &Path,
4167 summary: &summarize::Summary,
4168 label: &str,
4169 provenance: &GraphProvenance,
4170) -> SubstrateGraphNode {
4171 let handle = semantic_concept_handle(label);
4172 SubstrateGraphNode::new(handle.clone(), "semantic_concept", label.to_string())
4173 .with_property("handle", handle)
4174 .with_property("ref_id", label.to_string())
4175 .with_property("detail", "concept label from cached summaries".to_string())
4176 .with_property("source_file", summary.file_path.clone())
4177 .with_property("source_symbol", summary.symbol_name.clone())
4178 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
4179 .with_property("embedding", semantic_embedding_property(label))
4180 .with_property(
4181 "expand",
4182 semantic_related_command(root, label, SemanticRelatedKind::Concept),
4183 )
4184 .with_provenance(provenance.clone())
4185}
4186
4187fn insert_semantic_edge(
4188 edge_map: &mut BTreeMap<(String, String, String), SubstrateGraphEdge>,
4189 edge: SubstrateGraphEdge,
4190) {
4191 edge_map
4192 .entry((edge.from_id.clone(), edge.to_id.clone(), edge.kind.clone()))
4193 .or_insert(edge);
4194}
4195
4196fn append_summary_semantic_projection_rows(
4197 root: &Path,
4198 graph: &TraversalGraphBuild,
4199 provenance: &GraphProvenance,
4200 nodes: &mut Vec<SubstrateGraphNode>,
4201 edges: &mut Vec<SubstrateGraphEdge>,
4202) -> Result<()> {
4203 let summaries_db = root.join(".tsift/summaries.db");
4204 if !summaries_db.exists() {
4205 return Ok(());
4206 }
4207
4208 let summary_db = summarize::SummaryDb::open_read_only_resilient(&summaries_db)?;
4209 let summaries = summary_db.all()?;
4210 if summaries.is_empty() {
4211 return Ok(());
4212 }
4213
4214 let file_node_by_path = graph
4215 .nodes
4216 .values()
4217 .filter(|node| node.kind == "file")
4218 .filter_map(|node| {
4219 node.path
4220 .as_ref()
4221 .map(|path| (path.clone(), node.handle.clone()))
4222 })
4223 .collect::<BTreeMap<_, _>>();
4224 let symbol_node_by_file_label = graph
4225 .nodes
4226 .values()
4227 .filter(|node| node.kind == "symbol")
4228 .filter_map(|node| {
4229 Some((
4230 (node.path.clone()?, node.label.clone()),
4231 node.handle.clone(),
4232 ))
4233 })
4234 .collect::<BTreeMap<_, _>>();
4235
4236 let mut semantic_nodes = BTreeMap::<String, SubstrateGraphNode>::new();
4237 let mut semantic_edges = BTreeMap::<(String, String, String), SubstrateGraphEdge>::new();
4238
4239 for summary in &summaries {
4240 let source_handles =
4241 summary_source_handles(summary, &file_node_by_path, &symbol_node_by_file_label);
4242 let mut entity_ids_by_name = BTreeMap::<String, String>::new();
4243
4244 if let Some(entities) = &summary.entities {
4245 for entity in entities {
4246 let node = semantic_entity_node(
4247 root,
4248 summary,
4249 &entity.name,
4250 &entity.kind,
4251 &entity.description,
4252 provenance,
4253 );
4254 let entity_id = node.id.clone();
4255 entity_ids_by_name.insert(entity.name.to_ascii_lowercase(), entity_id.clone());
4256 semantic_nodes.entry(entity_id.clone()).or_insert(node);
4257
4258 for source_handle in &source_handles {
4259 insert_semantic_edge(
4260 &mut semantic_edges,
4261 SubstrateGraphEdge::new(
4262 source_handle.clone(),
4263 entity_id.clone(),
4264 "mentions_entity",
4265 )
4266 .with_property("label", format!("summary entity: {}", entity.name))
4267 .with_property("source_file", summary.file_path.clone())
4268 .with_provenance(provenance.clone()),
4269 );
4270 }
4271 }
4272 }
4273
4274 let mut concept_ids = Vec::new();
4275 if let Some(labels) = &summary.concept_labels {
4276 for label in labels
4277 .iter()
4278 .map(|label| label.trim())
4279 .filter(|label| !label.is_empty())
4280 {
4281 let node = semantic_concept_node(root, summary, label, provenance);
4282 let concept_id = node.id.clone();
4283 semantic_nodes.entry(concept_id.clone()).or_insert(node);
4284 concept_ids.push(concept_id.clone());
4285
4286 for source_handle in &source_handles {
4287 insert_semantic_edge(
4288 &mut semantic_edges,
4289 SubstrateGraphEdge::new(
4290 source_handle.clone(),
4291 concept_id.clone(),
4292 "mentions_concept",
4293 )
4294 .with_property("label", format!("summary concept: {label}"))
4295 .with_property("source_file", summary.file_path.clone())
4296 .with_provenance(provenance.clone()),
4297 );
4298 }
4299 }
4300 }
4301
4302 for entity_id in entity_ids_by_name.values() {
4303 for concept_id in &concept_ids {
4304 insert_semantic_edge(
4305 &mut semantic_edges,
4306 SubstrateGraphEdge::new(
4307 entity_id.clone(),
4308 concept_id.clone(),
4309 "tagged_concept",
4310 )
4311 .with_property("label", "entity concept label".to_string())
4312 .with_property("source_file", summary.file_path.clone())
4313 .with_provenance(provenance.clone()),
4314 );
4315 }
4316 }
4317
4318 for idx in 0..concept_ids.len() {
4319 for next_idx in (idx + 1)..concept_ids.len() {
4320 insert_semantic_edge(
4321 &mut semantic_edges,
4322 SubstrateGraphEdge::new(
4323 concept_ids[idx].clone(),
4324 concept_ids[next_idx].clone(),
4325 "related_concept",
4326 )
4327 .with_property("label", format!("co-occurs in {}", summary.symbol_name))
4328 .with_property("source_file", summary.file_path.clone())
4329 .with_provenance(provenance.clone()),
4330 );
4331 }
4332 }
4333
4334 if let Some(relationships) = &summary.relationships {
4335 for relationship in relationships {
4336 let from_id = entity_ids_by_name
4337 .get(&relationship.from.to_ascii_lowercase())
4338 .cloned()
4339 .unwrap_or_else(|| {
4340 let node = semantic_entity_node(
4341 root,
4342 summary,
4343 &relationship.from,
4344 "unknown",
4345 "",
4346 provenance,
4347 );
4348 let id = node.id.clone();
4349 semantic_nodes.entry(id.clone()).or_insert(node);
4350 id
4351 });
4352 let to_id = entity_ids_by_name
4353 .get(&relationship.to.to_ascii_lowercase())
4354 .cloned()
4355 .unwrap_or_else(|| {
4356 let node = semantic_entity_node(
4357 root,
4358 summary,
4359 &relationship.to,
4360 "unknown",
4361 "",
4362 provenance,
4363 );
4364 let id = node.id.clone();
4365 semantic_nodes.entry(id.clone()).or_insert(node);
4366 id
4367 });
4368 insert_semantic_edge(
4369 &mut semantic_edges,
4370 SubstrateGraphEdge::new(from_id, to_id, "semantic_relation")
4371 .with_property("relationship_kind", relationship.kind.clone())
4372 .with_property("label", relationship.kind.clone())
4373 .with_property("source_file", summary.file_path.clone())
4374 .with_property("source_symbol", summary.symbol_name.clone())
4375 .with_provenance(provenance.clone()),
4376 );
4377 }
4378 }
4379 }
4380
4381 for node in semantic_nodes.into_values() {
4382 nodes.push(node_with_content_freshness(node)?);
4383 }
4384 for edge in semantic_edges.into_values() {
4385 edges.push(edge_with_content_freshness(edge)?);
4386 }
4387
4388 Ok(())
4389}
4390
4391fn projection_content_hash(
4392 nodes: &[SubstrateGraphNode],
4393 edges: &[SubstrateGraphEdge],
4394) -> Result<String> {
4395 #[derive(Serialize)]
4396 struct Payload<'a> {
4397 version: &'static str,
4398 nodes: &'a [SubstrateGraphNode],
4399 edges: &'a [SubstrateGraphEdge],
4400 }
4401
4402 content_hash(&Payload {
4403 version: GRAPH_PROJECTION_VERSION,
4404 nodes,
4405 edges,
4406 })
4407}
4408
4409pub(crate) fn graph_projection_content_hash(projection: &GraphProjection) -> Option<String> {
4410 projection
4411 .nodes
4412 .iter()
4413 .find(|node| node.kind == GRAPH_PROJECTION_META_KIND)
4414 .and_then(|node| node.properties.get("content_hash").cloned())
4415}
4416
4417fn traversal_projection_from_graph(
4418 root: &Path,
4419 scope: Option<&str>,
4420 graph: &TraversalGraphBuild,
4421) -> Result<GraphProjection> {
4422 let provenance = GraphProvenance::new(
4423 "tsift.traverse",
4424 format!("{}:{}", root.display(), scope.unwrap_or("root")),
4425 );
4426 let mut nodes = Vec::with_capacity(graph.nodes.len() + 1);
4427 for node in graph.nodes.values() {
4428 let mut projected =
4429 SubstrateGraphNode::new(node.handle.clone(), node.kind.clone(), node.label.clone())
4430 .with_property("handle", node.handle.clone())
4431 .with_property("expand", node.expand.clone())
4432 .with_provenance(provenance.clone());
4433 if let Some(ref_id) = &node.ref_id {
4434 projected = projected.with_property("ref_id", ref_id.clone());
4435 }
4436 if let Some(path) = &node.path {
4437 projected = projected.with_property("path", path.clone());
4438 }
4439 if let Some(line) = node.line {
4440 projected = projected.with_property("line", line.to_string());
4441 }
4442 if let Some(detail) = &node.detail {
4443 projected = projected.with_property("detail", detail.clone());
4444 }
4445 for (key, value) in &node.properties {
4446 projected = projected.with_property(key.clone(), value.clone());
4447 }
4448 nodes.push(node_with_content_freshness(projected)?);
4449 }
4450
4451 let mut edges = Vec::with_capacity(graph.edges.len());
4452 for edge in &graph.edges {
4453 let mut projected =
4454 SubstrateGraphEdge::new(edge.from.clone(), edge.to.clone(), edge.relation.clone())
4455 .with_property("weight", edge.weight.to_string())
4456 .with_provenance(provenance.clone());
4457 if let Some(label) = &edge.label {
4458 projected = projected.with_property("label", label.clone());
4459 }
4460 edges.push(edge_with_content_freshness(projected)?);
4461 }
4462
4463 append_traversal_context_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4464 append_summary_semantic_projection_rows(root, graph, &provenance, &mut nodes, &mut edges)?;
4465 append_tsift_memory_graph_projection_rows(root, &mut nodes, &mut edges)?;
4466
4467 let projection_hash = projection_content_hash(&nodes, &edges)?;
4468 let meta = SubstrateGraphNode::new(
4469 graph_projection_meta_id(scope),
4470 GRAPH_PROJECTION_META_KIND,
4471 "tsift traversal projection",
4472 )
4473 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
4474 .with_property("content_hash", projection_hash.clone())
4475 .with_property("root", root.to_string_lossy().to_string())
4476 .with_property("scope", scope.unwrap_or("root"))
4477 .with_property("node_count", graph.nodes.len().to_string())
4478 .with_property("edge_count", graph.edges.len().to_string())
4479 .with_provenance(provenance)
4480 .with_freshness(GraphFreshness::content_hash(projection_hash));
4481 nodes.push(meta);
4482
4483 Ok(GraphProjection { nodes, edges })
4484}
4485
4486#[allow(clippy::too_many_arguments)]
4487fn ensure_traversal_source_handle(
4488 root: &Path,
4489 provenance: &GraphProvenance,
4490 file_node_by_path: &BTreeMap<String, String>,
4491 node: &TraversalNode,
4492 budget: &ExplorationBudget,
4493 source_handle_by_node: &mut BTreeMap<String, String>,
4494 seen_windows: &mut BTreeMap<(String, usize, usize), String>,
4495 nodes: &mut Vec<SubstrateGraphNode>,
4496 edges: &mut Vec<SubstrateGraphEdge>,
4497) -> Result<Option<String>> {
4498 if let Some(handle) = source_handle_by_node.get(&node.handle) {
4499 return Ok(Some(handle.clone()));
4500 }
4501 let Some(window) = exploration_source_window_for_node(root, node, budget) else {
4502 return Ok(None);
4503 };
4504 let window_key = (window.file.clone(), window.start, window.end);
4505 let handle = if let Some(handle) = seen_windows.get(&window_key) {
4506 handle.clone()
4507 } else {
4508 let label = format!("{}:{}-{}", window.file, window.start, window.end);
4509 let projected = SubstrateGraphNode::new(window.handle.clone(), "source_handle", label)
4510 .with_property("handle", window.handle.clone())
4511 .with_property("file", window.file.clone())
4512 .with_property("start", window.start.to_string())
4513 .with_property("end", window.end.to_string())
4514 .with_property("reason", window.reason.clone())
4515 .with_property("expand", window.expand.clone())
4516 .with_provenance(provenance.clone());
4517 nodes.push(node_with_content_freshness(projected)?);
4518
4519 if let Some(file_handle) = file_node_by_path.get(&window.file) {
4520 let edge = SubstrateGraphEdge::new(
4521 window.handle.clone(),
4522 file_handle.clone(),
4523 "expands_source",
4524 )
4525 .with_property("label", window.reason.clone())
4526 .with_provenance(provenance.clone());
4527 edges.push(edge_with_content_freshness(edge)?);
4528 }
4529 if node.kind != "file" {
4530 let edge = SubstrateGraphEdge::new(
4531 window.handle.clone(),
4532 node.handle.clone(),
4533 "anchors_source",
4534 )
4535 .with_property("label", window.reason.clone())
4536 .with_provenance(provenance.clone());
4537 edges.push(edge_with_content_freshness(edge)?);
4538 }
4539 seen_windows.insert(window_key, window.handle.clone());
4540 window.handle
4541 };
4542 source_handle_by_node.insert(node.handle.clone(), handle.clone());
4543 Ok(Some(handle))
4544}
4545
4546fn push_traversal_backlog_target_handles<'a>(
4547 backlog: &TraversalNode,
4548 edges_by_from: &BTreeMap<&'a str, Vec<&'a TraversalEdge>>,
4549 node_by_handle: &BTreeMap<&'a str, &'a TraversalNode>,
4550 max_handles: usize,
4551 seen_target_nodes: &mut BTreeSet<String>,
4552 target_node_handles: &mut Vec<String>,
4553) {
4554 for edge in edges_by_from
4555 .get(backlog.handle.as_str())
4556 .into_iter()
4557 .flatten()
4558 .filter(|edge| edge.relation == "mentions")
4559 {
4560 let Some(target_node) = node_by_handle.get(edge.to.as_str()) else {
4561 continue;
4562 };
4563 if !matches!(
4564 target_node.kind.as_str(),
4565 "file" | "symbol" | "route" | "cargo_package" | "cargo_workspace"
4566 ) {
4567 continue;
4568 }
4569 if target_node
4570 .path
4571 .as_deref()
4572 .zip(backlog.path.as_deref())
4573 .is_some_and(|(target_path, backlog_path)| {
4574 target_path == backlog_path && target_path.ends_with(".md")
4575 })
4576 {
4577 continue;
4578 }
4579 if seen_target_nodes.insert(target_node.handle.clone()) {
4580 target_node_handles.push(target_node.handle.clone());
4581 }
4582 if target_node_handles.len() >= max_handles {
4583 break;
4584 }
4585 }
4586}
4587
4588fn append_traversal_context_projection_rows(
4589 root: &Path,
4590 graph: &TraversalGraphBuild,
4591 provenance: &GraphProvenance,
4592 nodes: &mut Vec<SubstrateGraphNode>,
4593 edges: &mut Vec<SubstrateGraphEdge>,
4594) -> Result<()> {
4595 let budget = exploration_budget_for_counts(graph.nodes.len(), graph.edges.len());
4596 let file_node_by_path = graph
4597 .nodes
4598 .values()
4599 .filter(|node| node.kind == "file")
4600 .filter_map(|node| {
4601 node.path
4602 .as_ref()
4603 .map(|path| (path.clone(), node.handle.clone()))
4604 })
4605 .collect::<BTreeMap<_, _>>();
4606
4607 let node_by_handle = graph
4608 .nodes
4609 .values()
4610 .map(|node| (node.handle.as_str(), node))
4611 .collect::<BTreeMap<_, _>>();
4612 let mut edges_by_from = BTreeMap::<&str, Vec<&TraversalEdge>>::new();
4613 for edge in &graph.edges {
4614 edges_by_from
4615 .entry(edge.from.as_str())
4616 .or_default()
4617 .push(edge);
4618 }
4619 for rows in edges_by_from.values_mut() {
4620 rows.sort_by(|left, right| {
4621 right
4622 .weight
4623 .cmp(&left.weight)
4624 .then(left.relation.cmp(&right.relation))
4625 .then(left.to.cmp(&right.to))
4626 });
4627 }
4628
4629 let mut seen_windows = BTreeMap::<(String, usize, usize), String>::new();
4630 let mut source_handle_by_node = BTreeMap::<String, String>::new();
4631
4632 let mut code_context_count = 0usize;
4633 let code_context_limit = budget.relationship_limit.min(8);
4634 for node in graph.nodes.values() {
4635 if !matches!(
4636 node.kind.as_str(),
4637 "backlog" | "job_packet" | "worker_result"
4638 ) {
4639 continue;
4640 }
4641 let mut target_node_handles = Vec::new();
4642 let mut fallback_target_handles = Vec::new();
4643 let mut seen_target_nodes = BTreeSet::new();
4644 if node.kind == "backlog" || node.kind == "worker_result" {
4645 push_traversal_backlog_target_handles(
4646 node,
4647 &edges_by_from,
4648 &node_by_handle,
4649 budget.max_source_windows,
4650 &mut seen_target_nodes,
4651 &mut target_node_handles,
4652 );
4653 fallback_target_handles.push(node.handle.clone());
4654 } else {
4655 for edge in edges_by_from
4656 .get(node.handle.as_str())
4657 .into_iter()
4658 .flatten()
4659 .filter(|edge| edge.relation == "targets")
4660 {
4661 let Some(backlog) = node_by_handle.get(edge.to.as_str()) else {
4662 continue;
4663 };
4664 fallback_target_handles.push(backlog.handle.clone());
4665 push_traversal_backlog_target_handles(
4666 backlog,
4667 &edges_by_from,
4668 &node_by_handle,
4669 budget.max_source_windows,
4670 &mut seen_target_nodes,
4671 &mut target_node_handles,
4672 );
4673 if target_node_handles.len() >= budget.max_source_windows {
4674 break;
4675 }
4676 }
4677 if fallback_target_handles.is_empty() {
4678 continue;
4679 }
4680 }
4681 let code_context = !target_node_handles.is_empty();
4682 if target_node_handles.is_empty() {
4683 target_node_handles = dedupe_preserve_order(fallback_target_handles);
4684 } else if code_context_count >= code_context_limit {
4685 continue;
4686 }
4687
4688 let mut worker_source_handles = Vec::new();
4689 let mut seen_worker_handles = BTreeSet::new();
4690 for target_handle in target_node_handles {
4691 if worker_source_handles.len() >= budget.max_source_windows {
4692 break;
4693 }
4694 let Some(target_node) = node_by_handle.get(target_handle.as_str()) else {
4695 continue;
4696 };
4697 let Some(handle) = ensure_traversal_source_handle(
4698 root,
4699 provenance,
4700 &file_node_by_path,
4701 target_node,
4702 &budget,
4703 &mut source_handle_by_node,
4704 &mut seen_windows,
4705 nodes,
4706 edges,
4707 )?
4708 else {
4709 continue;
4710 };
4711 if seen_worker_handles.insert(handle.clone()) {
4712 worker_source_handles.push(handle);
4713 }
4714 }
4715 if worker_source_handles.is_empty() {
4716 continue;
4717 }
4718 let target = node
4719 .path
4720 .clone()
4721 .unwrap_or_else(|| root.to_string_lossy().to_string());
4722 let summary = node.detail.clone().unwrap_or_else(|| node.label.clone());
4723 let handle = stable_handle("xwrk", &format!("{}:{}:{}", target, node.handle, summary));
4724 let projected = SubstrateGraphNode::new(handle.clone(), "worker_context", summary.clone())
4725 .with_property("handle", handle.clone())
4726 .with_property("target", target.clone())
4727 .with_property("summary", summary)
4728 .with_property(
4729 "source_handle_count",
4730 worker_source_handles.len().to_string(),
4731 )
4732 .with_property(
4733 "expand",
4734 format!(
4735 "tsift --envelope context-pack {} --budget normal",
4736 shell_quote(&target)
4737 ),
4738 )
4739 .with_provenance(provenance.clone());
4740 nodes.push(node_with_content_freshness(projected)?);
4741
4742 let request_edge =
4743 SubstrateGraphEdge::new(node.handle.clone(), handle.clone(), "requests_context")
4744 .with_property("label", "bounded worker context".to_string())
4745 .with_provenance(provenance.clone());
4746 edges.push(edge_with_content_freshness(request_edge)?);
4747
4748 for source_handle in &worker_source_handles {
4749 let scope_edge =
4750 SubstrateGraphEdge::new(handle.clone(), source_handle.clone(), "scopes_source")
4751 .with_property("label", "bounded worker source window".to_string())
4752 .with_provenance(provenance.clone());
4753 edges.push(edge_with_content_freshness(scope_edge)?);
4754 }
4755 if code_context {
4756 code_context_count += 1;
4757 }
4758 }
4759
4760 Ok(())
4761}
4762
4763fn traversal_node_from_graph_node(root: &Path, node: SubstrateGraphNode) -> TraversalNode {
4764 let handle = node
4765 .properties
4766 .get("handle")
4767 .cloned()
4768 .unwrap_or_else(|| node.id.clone());
4769 TraversalNode {
4770 expand: node
4771 .properties
4772 .get("expand")
4773 .cloned()
4774 .unwrap_or_else(|| traversal_expand_command(root, &handle)),
4775 handle,
4776 kind: node.kind,
4777 label: node.label,
4778 ref_id: node.properties.get("ref_id").cloned(),
4779 path: node.properties.get("path").cloned(),
4780 line: node
4781 .properties
4782 .get("line")
4783 .and_then(|value| value.parse::<i64>().ok()),
4784 detail: node.properties.get("detail").cloned(),
4785 properties: node.properties,
4786 }
4787}
4788
4789fn traversal_graph_from_store(root: &Path, store: &impl GraphStore) -> Result<TraversalGraphBuild> {
4790 let mut graph = TraversalGraphBuild::default();
4791 for node in store.all_nodes()? {
4792 if node.kind == GRAPH_PROJECTION_META_KIND {
4793 continue;
4794 }
4795 graph.add_node(traversal_node_from_graph_node(root, node));
4796 }
4797 for edge in store.all_edges()? {
4798 graph.add_edge(
4799 &edge.from_id,
4800 &edge.to_id,
4801 &edge.kind,
4802 edge.properties.get("label").cloned(),
4803 edge.properties
4804 .get("weight")
4805 .and_then(|value| value.parse::<usize>().ok())
4806 .unwrap_or(1),
4807 );
4808 }
4809 Ok(graph)
4810}
4811
4812pub(crate) fn convex_rows_from_graph_store(
4813 store: &impl GraphStore,
4814) -> Result<ConvexProjectionRows> {
4815 Ok(GraphProjection {
4816 nodes: store.all_nodes()?,
4817 edges: store.all_edges()?,
4818 }
4819 .to_convex_rows())
4820}
4821
4822#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
4823struct ConvexRequiredIndex {
4824 table: String,
4825 name: String,
4826 fields: Vec<String>,
4827}
4828
4829#[derive(Clone, Debug, Serialize, PartialEq)]
4830struct ConvexSyncChunk {
4831 operation: String,
4832 chunk: usize,
4833 count: usize,
4834 keys: Vec<String>,
4835 max_attempts: usize,
4836 retry_policy: String,
4837}
4838
4839#[derive(Clone, Debug, Serialize, PartialEq)]
4840struct ConvexTransportSummary {
4841 endpoint_env: String,
4842 endpoint_configured: bool,
4843 auth_token_env: String,
4844 auth_configured: bool,
4845 remote_snapshot: bool,
4846 applied_chunks: usize,
4847}
4848
4849#[derive(Clone, Debug, Serialize, PartialEq)]
4850struct ConvexTransportReceipt {
4851 operation: String,
4852 chunk: usize,
4853 attempt: usize,
4854 status: String,
4855 message: Option<String>,
4856}
4857
4858#[derive(Serialize)]
4859#[serde(rename_all = "camelCase")]
4860struct ConvexTransportRequest<'a> {
4861 operation: &'a str,
4862 chunk: usize,
4863 projection_version: &'a str,
4864 projection_hash: Option<&'a str>,
4865 #[serde(skip_serializing_if = "Option::is_none")]
4866 projection_meta_id: Option<&'a str>,
4867 node_rows: Vec<ConvexNodeRow>,
4868 edge_rows: Vec<ConvexEdgeRow>,
4869 keys: Vec<String>,
4870 #[serde(skip_serializing_if = "Option::is_none")]
4871 cursor: Option<String>,
4872 #[serde(skip_serializing_if = "Option::is_none")]
4873 limit: Option<usize>,
4874}
4875
4876#[derive(Deserialize)]
4877#[serde(rename_all = "camelCase")]
4878struct ConvexTransportResponse {
4879 status: Option<String>,
4880 message: Option<String>,
4881 rows: Option<ConvexProjectionRows>,
4882 #[serde(default)]
4883 meta: Option<ConvexSnapshotMeta>,
4884 #[serde(default)]
4885 page: Option<ConvexSnapshotPage>,
4886}
4887
4888#[derive(Deserialize, Debug, Clone)]
4889#[serde(rename_all = "camelCase")]
4890struct ConvexSnapshotMeta {
4891 #[serde(default)]
4895 #[allow(dead_code)]
4896 indexes: Vec<ConvexRequiredIndex>,
4897 #[serde(default)]
4898 #[allow(dead_code)]
4899 node_count: Option<usize>,
4900 #[serde(default)]
4901 #[allow(dead_code)]
4902 edge_count: Option<usize>,
4903 #[serde(default)]
4904 projection_hash: Option<String>,
4905 #[serde(default)]
4906 #[allow(dead_code)]
4907 page_size: Option<usize>,
4908}
4909
4910#[derive(Deserialize, Debug, Clone)]
4915#[serde(rename_all = "camelCase")]
4916struct ConvexSnapshotPage {
4917 rows: Vec<serde_json::Value>,
4918 #[serde(default)]
4919 next_cursor: Option<String>,
4920}
4921
4922#[derive(Clone, Debug, Serialize, PartialEq)]
4923struct ConvexProjectionFreshness {
4924 status: String,
4925 fail_closed: bool,
4926 local_hash: Option<String>,
4927 snapshot_hash: Option<String>,
4928 missing_nodes: Vec<String>,
4929 stale_nodes: Vec<String>,
4930 missing_edges: Vec<String>,
4931 stale_edges: Vec<String>,
4932 diagnostics: Vec<String>,
4933}
4934
4935const DEFAULT_CONVEX_GRAPH_URL_ENV: &str = "TSIFT_CONVEX_GRAPH_URL";
4936
4937impl ConvexProjectionFreshness {
4938 fn current(local_hash: Option<String>, snapshot_hash: Option<String>) -> Self {
4939 Self {
4940 status: "current".to_string(),
4941 fail_closed: false,
4942 local_hash,
4943 snapshot_hash,
4944 missing_nodes: Vec::new(),
4945 stale_nodes: Vec::new(),
4946 missing_edges: Vec::new(),
4947 stale_edges: Vec::new(),
4948 diagnostics: Vec::new(),
4949 }
4950 }
4951}
4952
4953#[derive(Clone, Debug, Serialize, PartialEq)]
4954struct ConvexSyncReport {
4955 root: String,
4956 #[serde(skip_serializing_if = "Option::is_none")]
4957 scope: Option<String>,
4958 graph_db: String,
4959 dry_run: bool,
4960 projection_version: String,
4961 projection_hash: Option<String>,
4962 required_indexes: Vec<ConvexRequiredIndex>,
4963 node_upserts: Vec<ConvexNodeRow>,
4964 edge_upserts: Vec<ConvexEdgeRow>,
4965 node_tombstones: Vec<String>,
4966 edge_tombstones: Vec<String>,
4967 chunks: Vec<ConvexSyncChunk>,
4968 freshness: ConvexProjectionFreshness,
4969 transport: Option<ConvexTransportSummary>,
4970 receipts: Vec<ConvexTransportReceipt>,
4971 diagnostics: Vec<String>,
4972 warnings: Vec<String>,
4973}
4974
4975fn convex_required_indexes() -> Vec<ConvexRequiredIndex> {
4976 vec![
4977 ConvexRequiredIndex {
4978 table: "nodes".to_string(),
4979 name: "by_external_id".to_string(),
4980 fields: vec!["externalId".to_string()],
4981 },
4982 ConvexRequiredIndex {
4983 table: "nodes".to_string(),
4984 name: "by_kind".to_string(),
4985 fields: vec!["kind".to_string()],
4986 },
4987 ConvexRequiredIndex {
4988 table: "edges".to_string(),
4989 name: "by_edge_key".to_string(),
4990 fields: vec!["edgeKey".to_string()],
4991 },
4992 ConvexRequiredIndex {
4993 table: "edges".to_string(),
4994 name: "by_from_kind".to_string(),
4995 fields: vec!["fromExternalId".to_string(), "kind".to_string()],
4996 },
4997 ConvexRequiredIndex {
4998 table: "edges".to_string(),
4999 name: "by_to_kind".to_string(),
5000 fields: vec!["toExternalId".to_string(), "kind".to_string()],
5001 },
5002 ]
5003}
5004
5005pub(crate) fn load_convex_projection_rows(path: &Path) -> Result<ConvexProjectionRows> {
5006 let content = fs::read_to_string(path)
5007 .with_context(|| format!("reading Convex projection snapshot {}", path.display()))?;
5008 serde_json::from_str(&content)
5009 .with_context(|| format!("parsing Convex projection snapshot {}", path.display()))
5010}
5011
5012fn convex_projection_row_diagnostics(rows: &ConvexProjectionRows) -> Vec<String> {
5013 let mut diagnostics = Vec::new();
5014 let mut node_counts = BTreeMap::<&str, usize>::new();
5015 for row in &rows.nodes {
5016 *node_counts.entry(row.external_id.as_str()).or_default() += 1;
5017 }
5018 for (external_id, count) in node_counts.iter().filter(|(_, count)| **count > 1) {
5019 diagnostics.push(format!(
5020 "Convex snapshot contains duplicate node externalId {external_id} ({count} rows)"
5021 ));
5022 }
5023
5024 let node_ids = node_counts.keys().copied().collect::<BTreeSet<_>>();
5025 let mut edge_counts = BTreeMap::<&str, usize>::new();
5026 for edge in &rows.edges {
5027 *edge_counts.entry(edge.edge_key.as_str()).or_default() += 1;
5028 if !node_ids.contains(edge.from_external_id.as_str()) {
5029 diagnostics.push(format!(
5030 "Convex snapshot edge {} references missing from node {}",
5031 edge.edge_key, edge.from_external_id
5032 ));
5033 }
5034 if !node_ids.contains(edge.to_external_id.as_str()) {
5035 diagnostics.push(format!(
5036 "Convex snapshot edge {} references missing to node {}",
5037 edge.edge_key, edge.to_external_id
5038 ));
5039 }
5040 let expected_key =
5041 ConvexEdgeRow::stable_key(&edge.from_external_id, &edge.to_external_id, &edge.kind);
5042 if edge.edge_key != expected_key {
5043 diagnostics.push(format!(
5044 "Convex snapshot edge {} has non-canonical key; expected {} for ({}, {}, {})",
5045 edge.edge_key, expected_key, edge.from_external_id, edge.kind, edge.to_external_id
5046 ));
5047 }
5048 }
5049 for (edge_key, count) in edge_counts.iter().filter(|(_, count)| **count > 1) {
5050 diagnostics.push(format!(
5051 "Convex snapshot contains duplicate edgeKey {edge_key} ({count} rows)"
5052 ));
5053 }
5054 diagnostics
5055}
5056
5057pub(crate) fn validate_convex_projection_rows(rows: &ConvexProjectionRows) -> Result<()> {
5058 let diagnostics = convex_projection_row_diagnostics(rows);
5059 if diagnostics.is_empty() {
5060 Ok(())
5061 } else {
5062 bail!("{}", diagnostics.join("; "))
5063 }
5064}
5065
5066pub(crate) struct ConvexHttpTransport {
5067 endpoint: String,
5068 auth_token_env: String,
5069 auth_token: Option<String>,
5070}
5071
5072impl ConvexHttpTransport {
5073 fn from_options(endpoint: Option<&str>, auth_token_env: &str) -> Result<Self> {
5074 let endpoint = endpoint
5075 .map(str::to_string)
5076 .or_else(|| env::var(DEFAULT_CONVEX_GRAPH_URL_ENV).ok())
5077 .context("Convex transport requires --endpoint or TSIFT_CONVEX_GRAPH_URL")?;
5078 let auth_token = env::var(auth_token_env)
5079 .ok()
5080 .filter(|value| !value.trim().is_empty());
5081 Ok(Self {
5082 endpoint,
5083 auth_token_env: auth_token_env.to_string(),
5084 auth_token,
5085 })
5086 }
5087
5088 fn summary(&self, remote_snapshot: bool, applied_chunks: usize) -> ConvexTransportSummary {
5089 ConvexTransportSummary {
5090 endpoint_env: DEFAULT_CONVEX_GRAPH_URL_ENV.to_string(),
5091 endpoint_configured: true,
5092 auth_token_env: self.auth_token_env.clone(),
5093 auth_configured: self.auth_token.is_some(),
5094 remote_snapshot,
5095 applied_chunks,
5096 }
5097 }
5098
5099 fn post(&self, request: &ConvexTransportRequest<'_>) -> Result<ConvexTransportResponse> {
5100 let mut builder = ureq::post(&self.endpoint);
5101 if let Some(token) = &self.auth_token {
5102 builder = builder.header("Authorization", &format!("Bearer {token}"));
5103 }
5104 builder
5105 .send_json(request)
5106 .with_context(|| format!("calling Convex graph transport {}", self.endpoint))?
5107 .body_mut()
5108 .read_json::<ConvexTransportResponse>()
5109 .with_context(|| format!("parsing Convex graph transport response {}", self.endpoint))
5110 }
5111
5112 fn fetch_snapshot(
5123 &self,
5124 projection_version: &str,
5125 scope: Option<&str>,
5126 local_hash: Option<&str>,
5127 local_rows: Option<&ConvexProjectionRows>,
5128 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5129 match self.fetch_snapshot_paginated(projection_version, scope, local_hash, local_rows) {
5130 Ok(rows) => Ok(rows),
5131 Err(err) => {
5132 let msg = format!("{err:#}");
5137 let is_unknown_op = msg.contains("unknown operation")
5138 || msg.contains("snapshot_meta")
5139 || msg.contains("404");
5140 if !is_unknown_op {
5141 return Err(err);
5142 }
5143 self.fetch_snapshot_legacy(projection_version)
5144 .map(|rows| (rows, Vec::new()))
5145 }
5146 }
5147 }
5148
5149 fn fetch_snapshot_legacy(&self, projection_version: &str) -> Result<ConvexProjectionRows> {
5150 let response = self.post(&ConvexTransportRequest {
5151 operation: "snapshot",
5152 chunk: 0,
5153 projection_version,
5154 projection_hash: None,
5155 projection_meta_id: None,
5156 node_rows: Vec::new(),
5157 edge_rows: Vec::new(),
5158 keys: Vec::new(),
5159 cursor: None,
5160 limit: None,
5161 })?;
5162 response
5163 .rows
5164 .context("Convex snapshot response did not include rows")
5165 }
5166
5167 fn fetch_snapshot_paginated(
5168 &self,
5169 projection_version: &str,
5170 scope: Option<&str>,
5171 local_hash: Option<&str>,
5172 local_rows: Option<&ConvexProjectionRows>,
5173 ) -> Result<(ConvexProjectionRows, Vec<String>)> {
5174 let projection_meta_id = graph_projection_meta_id(scope);
5175 let meta_response = self.post(&ConvexTransportRequest {
5176 operation: "snapshot_meta",
5177 chunk: 0,
5178 projection_version,
5179 projection_hash: None,
5180 projection_meta_id: Some(&projection_meta_id),
5181 node_rows: Vec::new(),
5182 edge_rows: Vec::new(),
5183 keys: Vec::new(),
5184 cursor: None,
5185 limit: None,
5186 })?;
5187 if matches!(meta_response.status.as_deref(), Some("error")) {
5188 anyhow::bail!(
5189 "Convex snapshot_meta returned error: {}",
5190 meta_response.message.unwrap_or_default()
5191 );
5192 }
5193 let meta = meta_response
5194 .meta
5195 .context("Convex snapshot_meta response did not include meta")?;
5196 if let (Some(remote_hash), Some(local_hash), Some(local_rows)) =
5197 (meta.projection_hash.as_deref(), local_hash, local_rows)
5198 && remote_hash == local_hash
5199 {
5200 return Ok((
5201 local_rows.clone(),
5202 vec![
5203 "remote projection hash matched local graph; skipped full row-page snapshot diff"
5204 .to_string(),
5205 ],
5206 ));
5207 }
5208
5209 let mut nodes: Vec<ConvexNodeRow> = Vec::with_capacity(meta.node_count.unwrap_or_default());
5210 let mut node_cursor: Option<String> = None;
5211 loop {
5212 let response = self.post(&ConvexTransportRequest {
5213 operation: "snapshot_nodes_page",
5214 chunk: 0,
5215 projection_version,
5216 projection_hash: None,
5217 projection_meta_id: None,
5218 node_rows: Vec::new(),
5219 edge_rows: Vec::new(),
5220 keys: Vec::new(),
5221 cursor: node_cursor.clone(),
5222 limit: None,
5223 })?;
5224 let page = response
5225 .page
5226 .context("Convex snapshot_nodes_page response did not include page")?;
5227 for raw in page.rows {
5228 let row: ConvexNodeRow =
5229 serde_json::from_value(raw).context("decoding Convex snapshot node row")?;
5230 nodes.push(row);
5231 }
5232 match page.next_cursor {
5233 Some(next) => node_cursor = Some(next),
5234 None => break,
5235 }
5236 }
5237
5238 let mut edges: Vec<ConvexEdgeRow> = Vec::with_capacity(meta.edge_count.unwrap_or_default());
5239 let mut edge_cursor: Option<String> = None;
5240 loop {
5241 let response = self.post(&ConvexTransportRequest {
5242 operation: "snapshot_edges_page",
5243 chunk: 0,
5244 projection_version,
5245 projection_hash: None,
5246 projection_meta_id: None,
5247 node_rows: Vec::new(),
5248 edge_rows: Vec::new(),
5249 keys: Vec::new(),
5250 cursor: edge_cursor.clone(),
5251 limit: None,
5252 })?;
5253 let page = response
5254 .page
5255 .context("Convex snapshot_edges_page response did not include page")?;
5256 for raw in page.rows {
5257 let row: ConvexEdgeRow =
5258 serde_json::from_value(raw).context("decoding Convex snapshot edge row")?;
5259 edges.push(row);
5260 }
5261 match page.next_cursor {
5262 Some(next) => edge_cursor = Some(next),
5263 None => break,
5264 }
5265 }
5266
5267 Ok((ConvexProjectionRows { nodes, edges }, Vec::new()))
5268 }
5269
5270 fn apply_chunk(
5271 &self,
5272 report: &ConvexSyncReport,
5273 chunk: &ConvexSyncChunk,
5274 ) -> Result<ConvexTransportReceipt> {
5275 let node_rows = if chunk.operation == "upsert_nodes" {
5276 report
5277 .node_upserts
5278 .iter()
5279 .filter(|row| chunk.keys.contains(&row.external_id))
5280 .cloned()
5281 .collect()
5282 } else {
5283 Vec::new()
5284 };
5285 let edge_rows = if chunk.operation == "upsert_edges" {
5286 report
5287 .edge_upserts
5288 .iter()
5289 .filter(|row| chunk.keys.contains(&row.edge_key))
5290 .cloned()
5291 .collect()
5292 } else {
5293 Vec::new()
5294 };
5295 let request = ConvexTransportRequest {
5296 operation: &chunk.operation,
5297 chunk: chunk.chunk,
5298 projection_version: &report.projection_version,
5299 projection_hash: report.projection_hash.as_deref(),
5300 projection_meta_id: None,
5301 node_rows,
5302 edge_rows,
5303 keys: chunk.keys.clone(),
5304 cursor: None,
5305 limit: None,
5306 };
5307 let mut last_error = None;
5308 for attempt in 1..=chunk.max_attempts {
5309 match self.post(&request) {
5310 Ok(response) => {
5311 return Ok(ConvexTransportReceipt {
5312 operation: chunk.operation.clone(),
5313 chunk: chunk.chunk,
5314 attempt,
5315 status: response.status.unwrap_or_else(|| "ok".to_string()),
5316 message: response.message,
5317 });
5318 }
5319 Err(err) => {
5320 last_error = Some(err);
5321 if attempt < chunk.max_attempts {
5322 std::thread::sleep(Duration::from_millis(100 * attempt as u64));
5323 }
5324 }
5325 }
5326 }
5327 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Convex transport chunk failed")))
5328 .with_context(|| format!("applying Convex {} chunk {}", chunk.operation, chunk.chunk))
5329 }
5330}
5331
5332fn convex_projection_hash(rows: &ConvexProjectionRows, scope: Option<&str>) -> Option<String> {
5333 let meta_id = graph_projection_meta_id(scope);
5334 rows.nodes
5335 .iter()
5336 .find(|row| row.external_id == meta_id && row.kind == GRAPH_PROJECTION_META_KIND)
5337 .and_then(|row| row.properties.get("content_hash").cloned())
5338}
5339
5340fn convex_projection_freshness(
5341 local: &ConvexProjectionRows,
5342 snapshot: Option<&ConvexProjectionRows>,
5343 scope: Option<&str>,
5344) -> ConvexProjectionFreshness {
5345 let local_hash = convex_projection_hash(local, scope);
5346 let Some(snapshot) = snapshot else {
5347 return ConvexProjectionFreshness {
5348 status: "unchecked".to_string(),
5349 fail_closed: false,
5350 local_hash,
5351 snapshot_hash: None,
5352 missing_nodes: Vec::new(),
5353 stale_nodes: Vec::new(),
5354 missing_edges: Vec::new(),
5355 stale_edges: Vec::new(),
5356 diagnostics: vec![
5357 "no Convex snapshot supplied; sync output is a local dry-run plan".to_string(),
5358 ],
5359 };
5360 };
5361
5362 let snapshot_hash = convex_projection_hash(snapshot, scope);
5363 let snapshot_nodes = snapshot
5364 .nodes
5365 .iter()
5366 .map(|row| (row.external_id.as_str(), row))
5367 .collect::<BTreeMap<_, _>>();
5368 let snapshot_edges = snapshot
5369 .edges
5370 .iter()
5371 .map(|row| (row.edge_key.as_str(), row))
5372 .collect::<BTreeMap<_, _>>();
5373
5374 let mut missing_nodes = Vec::new();
5375 let mut stale_nodes = Vec::new();
5376 for row in &local.nodes {
5377 match snapshot_nodes.get(row.external_id.as_str()) {
5378 Some(snapshot_row) if *snapshot_row == row => {}
5379 Some(_) => stale_nodes.push(row.external_id.clone()),
5380 None => missing_nodes.push(row.external_id.clone()),
5381 }
5382 }
5383
5384 let mut missing_edges = Vec::new();
5385 let mut stale_edges = Vec::new();
5386 for row in &local.edges {
5387 match snapshot_edges.get(row.edge_key.as_str()) {
5388 Some(snapshot_row) if *snapshot_row == row => {}
5389 Some(_) => stale_edges.push(row.edge_key.clone()),
5390 None => missing_edges.push(row.edge_key.clone()),
5391 }
5392 }
5393
5394 let hash_current = local_hash.is_some() && local_hash == snapshot_hash;
5395 let rows_current = missing_nodes.is_empty()
5396 && stale_nodes.is_empty()
5397 && missing_edges.is_empty()
5398 && stale_edges.is_empty();
5399 if hash_current && rows_current {
5400 return ConvexProjectionFreshness::current(local_hash, snapshot_hash);
5401 }
5402
5403 let mut diagnostics = Vec::new();
5404 if local_hash != snapshot_hash {
5405 diagnostics.push(format!(
5406 "projection hash mismatch: local={} snapshot={}",
5407 local_hash.as_deref().unwrap_or("missing"),
5408 snapshot_hash.as_deref().unwrap_or("missing")
5409 ));
5410 }
5411 if !missing_nodes.is_empty() || !missing_edges.is_empty() {
5412 diagnostics.push(format!(
5413 "Convex snapshot is missing {} node(s) and {} edge(s)",
5414 missing_nodes.len(),
5415 missing_edges.len()
5416 ));
5417 }
5418 if !stale_nodes.is_empty() || !stale_edges.is_empty() {
5419 diagnostics.push(format!(
5420 "Convex snapshot has {} stale node row(s) and {} stale edge row(s)",
5421 stale_nodes.len(),
5422 stale_edges.len()
5423 ));
5424 }
5425
5426 ConvexProjectionFreshness {
5427 status: "stale".to_string(),
5428 fail_closed: true,
5429 local_hash,
5430 snapshot_hash,
5431 missing_nodes,
5432 stale_nodes,
5433 missing_edges,
5434 stale_edges,
5435 diagnostics,
5436 }
5437}
5438
5439pub(crate) fn verify_convex_projection_snapshot(
5440 root: &Path,
5441 scope: Option<&str>,
5442 snapshot_path: &Path,
5443) -> Result<()> {
5444 let graph_db = graph_substrate_db_path(root, scope);
5445 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5446 let local = convex_rows_from_graph_store(&store)?;
5447 let snapshot = load_convex_projection_rows(snapshot_path)?;
5448 validate_convex_projection_rows(&snapshot)?;
5449 let freshness = convex_projection_freshness(&local, Some(&snapshot), scope);
5450 if freshness.fail_closed {
5451 bail!(
5452 "Convex graph projection is not current for {}: {}",
5453 root.display(),
5454 freshness.diagnostics.join("; ")
5455 );
5456 }
5457 Ok(())
5458}
5459
5460fn convex_rows_diff(
5461 local: &ConvexProjectionRows,
5462 snapshot: Option<&ConvexProjectionRows>,
5463) -> (
5464 Vec<ConvexNodeRow>,
5465 Vec<ConvexEdgeRow>,
5466 Vec<String>,
5467 Vec<String>,
5468) {
5469 let Some(snapshot) = snapshot else {
5470 return (
5471 local.nodes.clone(),
5472 local.edges.clone(),
5473 Vec::new(),
5474 Vec::new(),
5475 );
5476 };
5477 let local_nodes = local
5478 .nodes
5479 .iter()
5480 .map(|row| (row.external_id.as_str(), row))
5481 .collect::<BTreeMap<_, _>>();
5482 let local_edges = local
5483 .edges
5484 .iter()
5485 .map(|row| (row.edge_key.as_str(), row))
5486 .collect::<BTreeMap<_, _>>();
5487 let snapshot_nodes = snapshot
5488 .nodes
5489 .iter()
5490 .map(|row| (row.external_id.as_str(), row))
5491 .collect::<BTreeMap<_, _>>();
5492 let snapshot_edges = snapshot
5493 .edges
5494 .iter()
5495 .map(|row| (row.edge_key.as_str(), row))
5496 .collect::<BTreeMap<_, _>>();
5497
5498 let node_upserts = local
5499 .nodes
5500 .iter()
5501 .filter(|row| {
5502 snapshot_nodes
5503 .get(row.external_id.as_str())
5504 .is_none_or(|snapshot_row| *snapshot_row != *row)
5505 })
5506 .cloned()
5507 .collect::<Vec<_>>();
5508 let edge_upserts = local
5509 .edges
5510 .iter()
5511 .filter(|row| {
5512 snapshot_edges
5513 .get(row.edge_key.as_str())
5514 .is_none_or(|snapshot_row| *snapshot_row != *row)
5515 })
5516 .cloned()
5517 .collect::<Vec<_>>();
5518 let node_tombstones = snapshot
5519 .nodes
5520 .iter()
5521 .filter(|row| !local_nodes.contains_key(row.external_id.as_str()))
5522 .map(|row| row.external_id.clone())
5523 .collect::<Vec<_>>();
5524 let edge_tombstones = snapshot
5525 .edges
5526 .iter()
5527 .filter(|row| !local_edges.contains_key(row.edge_key.as_str()))
5528 .map(|row| row.edge_key.clone())
5529 .collect::<Vec<_>>();
5530
5531 (node_upserts, edge_upserts, node_tombstones, edge_tombstones)
5532}
5533
5534fn push_sync_chunks(
5535 chunks: &mut Vec<ConvexSyncChunk>,
5536 operation: &str,
5537 keys: Vec<String>,
5538 size: usize,
5539) {
5540 if keys.is_empty() {
5541 return;
5542 }
5543 for (idx, chunk) in keys.chunks(size).enumerate() {
5544 chunks.push(ConvexSyncChunk {
5545 operation: operation.to_string(),
5546 chunk: idx + 1,
5547 count: chunk.len(),
5548 keys: chunk.to_vec(),
5549 max_attempts: 3,
5550 retry_policy:
5551 "retry the whole chunk; rows are idempotent by externalId/edgeKey, stop on a repeated partial failure"
5552 .to_string(),
5553 });
5554 }
5555}
5556
5557pub(crate) fn build_convex_sync_report_with_snapshot(
5558 path: &Path,
5559 scope: Option<&str>,
5560 snapshot: Option<ConvexProjectionRows>,
5561 chunk_size: usize,
5562 dry_run: bool,
5563) -> Result<ConvexSyncReport> {
5564 if chunk_size == 0 {
5565 bail!("--chunk-size must be greater than zero");
5566 }
5567 let root = lint::resolve_project_root_or_canonical_path(path)?;
5568 let (graph, _refresh) = write_traversal_graph_store(&root, path, scope)?;
5569 let graph_db = graph_substrate_db_path(&root, scope);
5570 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
5571 let local = convex_rows_from_graph_store(&store)?;
5572 let freshness = convex_projection_freshness(&local, snapshot.as_ref(), scope);
5573 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
5574 convex_rows_diff(&local, snapshot.as_ref());
5575
5576 let mut chunks = Vec::new();
5577 push_sync_chunks(
5578 &mut chunks,
5579 "delete_edges",
5580 edge_tombstones.clone(),
5581 chunk_size,
5582 );
5583 push_sync_chunks(
5584 &mut chunks,
5585 "upsert_nodes",
5586 node_upserts
5587 .iter()
5588 .map(|row| row.external_id.clone())
5589 .collect(),
5590 chunk_size,
5591 );
5592 push_sync_chunks(
5593 &mut chunks,
5594 "upsert_edges",
5595 edge_upserts
5596 .iter()
5597 .map(|row| row.edge_key.clone())
5598 .collect(),
5599 chunk_size,
5600 );
5601 push_sync_chunks(
5602 &mut chunks,
5603 "delete_nodes",
5604 node_tombstones.clone(),
5605 chunk_size,
5606 );
5607
5608 let mut diagnostics = vec![
5609 "apply node upserts before edge upserts; apply edge tombstones before node tombstones"
5610 .to_string(),
5611 ];
5612 if dry_run {
5613 diagnostics.push("dry-run only: no Convex network mutation was attempted".to_string());
5614 }
5615 if freshness.fail_closed {
5616 diagnostics.push(
5617 "Convex-backed traverse/context-pack reads must fail closed until this plan is applied"
5618 .to_string(),
5619 );
5620 }
5621
5622 Ok(ConvexSyncReport {
5623 root: root.to_string_lossy().to_string(),
5624 scope: scope.map(str::to_string),
5625 graph_db: graph_db.to_string_lossy().to_string(),
5626 dry_run,
5627 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
5628 projection_hash: convex_projection_hash(&local, scope),
5629 required_indexes: convex_required_indexes(),
5630 node_upserts,
5631 edge_upserts,
5632 node_tombstones,
5633 edge_tombstones,
5634 chunks,
5635 freshness,
5636 transport: None,
5637 receipts: Vec::new(),
5638 diagnostics,
5639 warnings: graph.warnings,
5640 })
5641}
5642
5643#[cfg(test)]
5644fn build_convex_sync_report(
5645 path: &Path,
5646 scope: Option<&str>,
5647 snapshot_path: Option<&Path>,
5648 chunk_size: usize,
5649) -> Result<ConvexSyncReport> {
5650 let snapshot = snapshot_path.map(load_convex_projection_rows).transpose()?;
5651 build_convex_sync_report_with_snapshot(path, scope, snapshot, chunk_size, true)
5652}
5653
5654pub(crate) fn print_convex_sync_human(report: &ConvexSyncReport, compact: bool) {
5655 if compact {
5656 println!(
5657 "convex-sync nodes:+{} -{} edges:+{} -{} chunks:{} freshness:{}",
5658 report.node_upserts.len(),
5659 report.node_tombstones.len(),
5660 report.edge_upserts.len(),
5661 report.edge_tombstones.len(),
5662 report.chunks.len(),
5663 report.freshness.status
5664 );
5665 return;
5666 }
5667
5668 println!(
5669 "Convex graph sync {}",
5670 if report.dry_run { "dry-run" } else { "apply" }
5671 );
5672 println!("root: {}", report.root);
5673 println!("graph_db: {}", report.graph_db);
5674 println!(
5675 "upserts: {} node(s), {} edge(s)",
5676 report.node_upserts.len(),
5677 report.edge_upserts.len()
5678 );
5679 println!(
5680 "tombstones: {} node(s), {} edge(s)",
5681 report.node_tombstones.len(),
5682 report.edge_tombstones.len()
5683 );
5684 println!("chunks: {}", report.chunks.len());
5685 println!("freshness: {}", report.freshness.status);
5686 if let Some(transport) = &report.transport {
5687 println!(
5688 "transport: endpoint_env={} auth_env={} applied_chunks={}",
5689 transport.endpoint_env, transport.auth_token_env, transport.applied_chunks
5690 );
5691 }
5692 for receipt in &report.receipts {
5693 println!(
5694 "receipt: {} chunk {} attempt {} {}",
5695 receipt.operation, receipt.chunk, receipt.attempt, receipt.status
5696 );
5697 }
5698 for diagnostic in report
5699 .diagnostics
5700 .iter()
5701 .chain(report.freshness.diagnostics.iter())
5702 {
5703 println!("- {}", diagnostic);
5704 }
5705}
5706
5707pub(crate) struct ConvexSyncOptions<'a> {
5708 path: &'a Path,
5709 scope: Option<&'a str>,
5710 snapshot: Option<&'a Path>,
5711 chunk_size: usize,
5712 remote_snapshot: bool,
5713 apply: bool,
5714 endpoint: Option<&'a str>,
5715 auth_token_env: &'a str,
5716}
5717
5718#[derive(Serialize)]
5719struct GraphDbSchemaField {
5720 name: &'static str,
5721 value_type: &'static str,
5722 description: &'static str,
5723}
5724
5725#[derive(Serialize)]
5726struct GraphDbSchemaOperation {
5727 command: &'static str,
5728 description: &'static str,
5729}
5730
5731#[derive(Serialize)]
5732struct GraphDbSchemaContract {
5733 name: &'static str,
5734 version: &'static str,
5735 description: &'static str,
5736}
5737
5738#[derive(Serialize)]
5739struct GraphDbSchema {
5740 contract_versions: Vec<GraphDbSchemaContract>,
5741 node_fields: Vec<GraphDbSchemaField>,
5742 edge_fields: Vec<GraphDbSchemaField>,
5743 operations: Vec<GraphDbSchemaOperation>,
5744}
5745
5746#[derive(Clone, Serialize, Deserialize)]
5747struct GraphDbFreshnessReport {
5748 status: String,
5749 fail_closed: bool,
5750 projection_version: Option<String>,
5751 content_hash: Option<String>,
5752 source_watermark: Option<String>,
5753 diagnostics: Vec<String>,
5754}
5755
5756#[derive(Clone, Debug, Serialize)]
5757pub(crate) struct GraphEffectivenessReadiness {
5758 pub(crate) status: String,
5759 pub(crate) fail_closed: bool,
5760 pub(crate) reason: String,
5761 pub(crate) diagnostics: Vec<String>,
5762 pub(crate) next_commands: Vec<String>,
5763}
5764
5765#[derive(Clone, Debug, Serialize, PartialEq)]
5766struct GraphDbPropertyFilter {
5767 key: String,
5768 value: String,
5769}
5770
5771#[derive(Clone, Debug, Default)]
5772struct GraphDbQueryOptions {
5773 cursor: Option<String>,
5774 limit: Option<usize>,
5775 property_filters: Vec<GraphDbPropertyFilter>,
5776}
5777
5778#[derive(Clone, Debug, Serialize, PartialEq)]
5779struct GraphDbPageReport {
5780 #[serde(skip_serializing_if = "Option::is_none")]
5781 cursor: Option<String>,
5782 #[serde(skip_serializing_if = "Option::is_none")]
5783 limit: Option<usize>,
5784 #[serde(skip_serializing_if = "Option::is_none")]
5785 next_cursor: Option<String>,
5786 returned_nodes: usize,
5787 returned_edges: usize,
5788 truncated: bool,
5789 property_filters: Vec<GraphDbPropertyFilter>,
5790 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5791 diagnostics: Vec<String>,
5792}
5793
5794type GraphDbRankedNeighbor = resolution::RankedNeighbor;
5795
5796#[derive(Clone, Debug, Serialize)]
5797struct CommunityTruncationSummary {
5798 total_communities: usize,
5799 fully_kept: usize,
5800 partially_pruned: usize,
5801 fully_pruned: usize,
5802 pruned_community_kinds: Vec<String>,
5803 pruned_community_top_labels: Vec<String>,
5804}
5805
5806#[derive(Clone, Debug, Serialize)]
5807struct GraphDbRankedNeighborhoodComparison {
5808 traversal_nodes: usize,
5809 traversal_edges: usize,
5810 pruned_count: usize,
5811 total_discovered: usize,
5812 latency_micros: u128,
5813 overlap_with_unranked_pct: f64,
5814 useful_hit_density_ranked: f64,
5815 useful_hit_density_unranked: f64,
5816 duplicate_name_count_ranked: usize,
5817 duplicate_name_count_unranked: usize,
5818 handle_coverage_ranked_pct: f64,
5819 handle_coverage_unranked_pct: f64,
5820 #[serde(skip_serializing_if = "Option::is_none")]
5821 community_truncation_summary: Option<CommunityTruncationSummary>,
5822 diagnostics: Vec<String>,
5823}
5824
5825#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5826struct GraphDbDroppedByBudget {
5827 item: String,
5828 kind: String,
5829 dropped: usize,
5830 reason: String,
5831}
5832
5833#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5834struct GraphDbOutputBudgetReport {
5835 max_tokens: usize,
5836 estimated_tokens: usize,
5837 selected_nodes: usize,
5838 selected_edges: usize,
5839 candidate_nodes: usize,
5840 candidate_edges: usize,
5841 dropped_by_budget: Vec<GraphDbDroppedByBudget>,
5842 diagnostics: Vec<String>,
5843}
5844
5845#[derive(Clone, Debug, Serialize, PartialEq)]
5846struct GraphDbKnowledgeRetrieval {
5847 mode: String,
5848 query: String,
5849 seed_kind: String,
5850 seed_limit: usize,
5851 seed_count: usize,
5852 depth: usize,
5853 limit: usize,
5854 node_count: usize,
5855 edge_count: usize,
5856 truncated: bool,
5857 traversal: String,
5858 freshness_boundary: String,
5859 privacy_boundary: String,
5860 diagnostics: Vec<String>,
5861}
5862
5863struct GraphDbSemanticSeededSubgraph {
5864 nodes: Vec<SubstrateGraphNode>,
5865 edges: Vec<SubstrateGraphEdge>,
5866 truncated: bool,
5867 diagnostics: Vec<String>,
5868}
5869
5870type GraphDbNeighborhoodRankingGate = resolution::NeighborhoodRankingGate;
5871
5872#[derive(Serialize)]
5873struct GraphDbReport {
5874 root: String,
5875 #[serde(skip_serializing_if = "Option::is_none")]
5876 scope: Option<String>,
5877 backend: String,
5878 query: String,
5879 freshness: GraphDbFreshnessReport,
5880 #[serde(skip_serializing_if = "Option::is_none")]
5881 readiness: Option<GraphEffectivenessReadiness>,
5882 #[serde(skip_serializing_if = "Option::is_none")]
5883 schema: Option<GraphDbSchema>,
5884 #[serde(skip_serializing_if = "Option::is_none")]
5885 node: Option<SubstrateTerseGraphNode>,
5886 #[serde(skip_serializing_if = "Option::is_none")]
5887 edge: Option<SubstrateTerseGraphEdge>,
5888 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5889 nodes: Vec<SubstrateTerseGraphNode>,
5890 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5891 edges: Vec<SubstrateTerseGraphEdge>,
5892 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5893 ranked_neighbors: Vec<GraphDbRankedNeighbor>,
5894 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5895 semantic_related: Vec<SemanticRelatedItem>,
5896 #[serde(skip_serializing_if = "Option::is_none")]
5897 neighborhood_ranking_gate: Option<GraphDbNeighborhoodRankingGate>,
5898 #[serde(skip_serializing_if = "Option::is_none")]
5899 ranked_neighborhood_comparison: Option<GraphDbRankedNeighborhoodComparison>,
5900 #[serde(skip_serializing_if = "Option::is_none")]
5901 knowledge_retrieval: Option<GraphDbKnowledgeRetrieval>,
5902 #[serde(skip_serializing_if = "Option::is_none")]
5903 output_budget: Option<GraphDbOutputBudgetReport>,
5904 #[serde(skip_serializing_if = "Option::is_none")]
5905 path: Option<substrate::GraphPath>,
5906 #[serde(skip_serializing_if = "Option::is_none")]
5907 page: Option<GraphDbPageReport>,
5908 #[serde(skip_serializing_if = "Vec::is_empty", default)]
5909 warnings: Vec<String>,
5910}
5911
5912struct ExperimentalReadOnlyGraphStore {
5913 backend: GraphDbExperimentalBackend,
5914 nodes: BTreeMap<String, SubstrateGraphNode>,
5915 edges: BTreeMap<String, SubstrateGraphEdge>,
5916 node_ids_by_kind: BTreeMap<String, Vec<String>>,
5917 outgoing_edge_keys_by_from: BTreeMap<String, Vec<String>>,
5918}
5919
5920impl ExperimentalReadOnlyGraphStore {
5921 fn from_rows(backend: GraphDbExperimentalBackend, rows: &ConvexProjectionRows) -> Result<Self> {
5922 validate_convex_projection_rows(rows)?;
5923 let nodes = rows
5924 .nodes
5925 .iter()
5926 .map(|row| {
5927 let node = SubstrateGraphNode {
5928 id: row.external_id.clone(),
5929 kind: row.kind.clone(),
5930 label: row.label.clone(),
5931 properties: row.properties.clone(),
5932 provenance: row.provenance.clone(),
5933 freshness: row.freshness.clone(),
5934 };
5935 (node.id.clone(), node)
5936 })
5937 .collect::<BTreeMap<_, _>>();
5938 let edges = rows
5939 .edges
5940 .iter()
5941 .map(|row| {
5942 let edge = SubstrateGraphEdge {
5943 id: row.edge_key.clone(),
5944 from_id: row.from_external_id.clone(),
5945 to_id: row.to_external_id.clone(),
5946 kind: row.kind.clone(),
5947 properties: row.properties.clone(),
5948 provenance: row.provenance.clone(),
5949 freshness: row.freshness.clone(),
5950 };
5951 (graph_db_edge_key(&edge), edge)
5952 })
5953 .collect::<BTreeMap<_, _>>();
5954 let mut node_ids_by_kind = BTreeMap::<String, Vec<String>>::new();
5955 for node in nodes.values() {
5956 node_ids_by_kind
5957 .entry(node.kind.clone())
5958 .or_default()
5959 .push(node.id.clone());
5960 }
5961 for ids in node_ids_by_kind.values_mut() {
5962 ids.sort();
5963 }
5964 let mut outgoing_edge_keys_by_from = BTreeMap::<String, Vec<String>>::new();
5965 for edge in edges.values() {
5966 outgoing_edge_keys_by_from
5967 .entry(edge.from_id.clone())
5968 .or_default()
5969 .push(graph_db_edge_key(edge));
5970 }
5971 for edge_keys in outgoing_edge_keys_by_from.values_mut() {
5972 edge_keys.sort_by(|left_key, right_key| {
5973 let left = &edges[left_key];
5974 let right = &edges[right_key];
5975 left.to_id
5976 .cmp(&right.to_id)
5977 .then(left.kind.cmp(&right.kind))
5978 .then(left_key.cmp(right_key))
5979 });
5980 }
5981 Ok(Self {
5982 backend,
5983 nodes,
5984 edges,
5985 node_ids_by_kind,
5986 outgoing_edge_keys_by_from,
5987 })
5988 }
5989}
5990
5991impl GraphStore for ExperimentalReadOnlyGraphStore {
5992 fn upsert_node(&self, _node: &SubstrateGraphNode) -> Result<()> {
5993 bail!("{} backend-eval adapter is read-only", self.backend.name())
5994 }
5995
5996 fn upsert_edge(&self, _edge: &SubstrateGraphEdge) -> Result<()> {
5997 bail!("{} backend-eval adapter is read-only", self.backend.name())
5998 }
5999
6000 fn delete_node(&self, _id: &str) -> Result<usize> {
6001 bail!("{} backend-eval adapter is read-only", self.backend.name())
6002 }
6003
6004 fn delete_edge(&self, _from_id: &str, _to_id: &str, _kind: &str) -> Result<usize> {
6005 bail!("{} backend-eval adapter is read-only", self.backend.name())
6006 }
6007
6008 fn node(&self, id: &str) -> Result<Option<SubstrateGraphNode>> {
6009 Ok(self.nodes.get(id).cloned())
6010 }
6011
6012 fn all_nodes(&self) -> Result<Vec<SubstrateGraphNode>> {
6013 Ok(self.nodes.values().cloned().collect())
6014 }
6015
6016 fn all_edges(&self) -> Result<Vec<SubstrateGraphEdge>> {
6017 let mut edges = self.edges.values().cloned().collect::<Vec<_>>();
6018 edges.sort_by(|left, right| {
6019 left.from_id
6020 .cmp(&right.from_id)
6021 .then(left.kind.cmp(&right.kind))
6022 .then(left.to_id.cmp(&right.to_id))
6023 });
6024 Ok(edges)
6025 }
6026
6027 fn graph_counts(&self) -> Result<(usize, usize)> {
6028 Ok((self.nodes.len(), self.edges.len()))
6029 }
6030
6031 fn sample_edge(&self, kind: Option<&str>) -> Result<Option<SubstrateGraphEdge>> {
6032 let mut edges = self
6033 .edges
6034 .values()
6035 .filter(|edge| edge.from_id != edge.to_id)
6036 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6037 .cloned()
6038 .collect::<Vec<_>>();
6039 edges.sort_by(|left, right| {
6040 left.from_id
6041 .cmp(&right.from_id)
6042 .then(left.kind.cmp(&right.kind))
6043 .then(left.to_id.cmp(&right.to_id))
6044 });
6045 Ok(edges.into_iter().next())
6046 }
6047
6048 fn sample_edge_with_property(
6049 &self,
6050 ) -> Result<Option<(SubstrateGraphEdge, GraphPropertyFilter)>> {
6051 Ok(self
6052 .edges
6053 .values()
6054 .filter(|edge| edge.from_id != edge.to_id)
6055 .filter_map(|edge| {
6056 edge.properties.iter().next().map(|(key, value)| {
6057 (
6058 edge,
6059 GraphPropertyFilter {
6060 key: key.clone(),
6061 value: value.clone(),
6062 },
6063 )
6064 })
6065 })
6066 .min_by(|(left_edge, left_filter), (right_edge, right_filter)| {
6067 left_filter
6068 .key
6069 .cmp(&right_filter.key)
6070 .then(left_filter.value.cmp(&right_filter.value))
6071 .then_with(|| graph_db_edge_key(left_edge).cmp(&graph_db_edge_key(right_edge)))
6072 })
6073 .map(|(edge, filter)| (edge.clone(), filter)))
6074 }
6075
6076 fn nodes_by_kind(&self, kind: &str) -> Result<Vec<SubstrateGraphNode>> {
6077 Ok(self
6078 .node_ids_by_kind
6079 .get(kind)
6080 .into_iter()
6081 .flatten()
6082 .filter_map(|id| self.nodes.get(id).cloned())
6083 .collect())
6084 }
6085
6086 fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<SubstrateGraphEdge>> {
6087 Ok(self
6088 .outgoing_edge_keys_by_from
6089 .get(from_id)
6090 .into_iter()
6091 .flatten()
6092 .filter_map(|key| self.edges.get(key))
6093 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
6094 .cloned()
6095 .collect())
6096 }
6097
6098 fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<SubstrateGraphEdge>> {
6099 Ok(self
6100 .edges
6101 .values()
6102 .filter(|edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id))
6103 .cloned()
6104 .collect())
6105 }
6106
6107 fn shortest_path(
6108 &self,
6109 from_id: &str,
6110 to_id: &str,
6111 kind: Option<&str>,
6112 ) -> Result<Option<substrate::GraphPath>> {
6113 if from_id == to_id {
6114 return Ok(Some(substrate::GraphPath {
6115 nodes: vec![from_id.to_string()],
6116 hops: 0,
6117 }));
6118 }
6119
6120 let mut queue = VecDeque::new();
6121 let mut parent = BTreeMap::<String, String>::new();
6122 parent.insert(from_id.to_string(), String::new());
6123 queue.push_back(from_id.to_string());
6124
6125 while let Some(current) = queue.pop_front() {
6126 for edge in self.outgoing_edges(¤t, kind)? {
6127 if parent.contains_key(&edge.to_id) {
6128 continue;
6129 }
6130 parent.insert(edge.to_id.clone(), current.clone());
6131 if edge.to_id == to_id {
6132 let mut nodes = vec![to_id.to_string()];
6133 let mut cursor = to_id;
6134 while let Some(previous) = parent.get(cursor) {
6135 if previous.is_empty() {
6136 break;
6137 }
6138 nodes.push(previous.clone());
6139 cursor = previous;
6140 }
6141 nodes.reverse();
6142 return Ok(Some(substrate::GraphPath {
6143 hops: nodes.len().saturating_sub(1),
6144 nodes,
6145 }));
6146 }
6147 queue.push_back(edge.to_id);
6148 }
6149 }
6150
6151 Ok(None)
6152 }
6153
6154 fn reachable_nodes_by_kinds(
6155 &self,
6156 from_id: &str,
6157 kinds: &[&str],
6158 depth: usize,
6159 limit: usize,
6160 ) -> Result<BTreeMap<String, Vec<(SubstrateGraphNode, substrate::GraphPath)>>> {
6161 let requested = kinds.iter().copied().collect::<BTreeSet<_>>();
6162 let mut rows = requested
6163 .iter()
6164 .map(|kind| {
6165 (
6166 (*kind).to_string(),
6167 BTreeMap::<String, (SubstrateGraphNode, substrate::GraphPath)>::new(),
6168 )
6169 })
6170 .collect::<BTreeMap<_, _>>();
6171 if requested.is_empty() {
6172 return Ok(BTreeMap::new());
6173 }
6174
6175 let mut seen = BTreeSet::from([from_id.to_string()]);
6176 let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
6177 while let Some((current, path)) = queue.pop_front() {
6178 let current_depth = path.len().saturating_sub(1);
6179 if current_depth >= depth {
6180 continue;
6181 }
6182 for edge in self.outgoing_edges(¤t, None)? {
6183 if !seen.insert(edge.to_id.clone()) {
6184 continue;
6185 }
6186 let Some(node) = self.nodes.get(&edge.to_id).cloned() else {
6187 continue;
6188 };
6189 let mut next_path = path.clone();
6190 next_path.push(edge.to_id.clone());
6191 let graph_path = substrate::GraphPath {
6192 hops: next_path.len().saturating_sub(1),
6193 nodes: next_path.clone(),
6194 };
6195 if requested.contains(node.kind.as_str()) {
6196 rows.entry(node.kind.clone())
6197 .or_default()
6198 .entry(node.id.clone())
6199 .or_insert((node.clone(), graph_path));
6200 }
6201 queue.push_back((edge.to_id, next_path));
6202 }
6203 }
6204
6205 Ok(rows
6206 .into_iter()
6207 .map(|(kind, values)| {
6208 let mut values = values.into_values().collect::<Vec<_>>();
6209 values.sort_by(|(left_node, left_path), (right_node, right_path)| {
6210 left_path
6211 .hops
6212 .cmp(&right_path.hops)
6213 .then(left_node.label.cmp(&right_node.label))
6214 .then(left_node.id.cmp(&right_node.id))
6215 });
6216 if limit > 0 && values.len() > limit {
6217 values.truncate(limit);
6218 }
6219 (kind, values)
6220 })
6221 .collect())
6222 }
6223}
6224
6225pub(crate) const GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS: usize = 64;
6226pub(crate) const GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS: [usize; 3] = [128, 256, 512];
6227pub(crate) const GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS: usize = 1;
6228const GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT: f64 = 10.0;
6229pub(crate) const GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT: f64 = 1000.0;
6230const GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS: usize = 3;
6231const CONFLICT_MATRIX_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-prep-v1";
6232const CONFLICT_MATRIX_GRAPH_PREPARATION_CACHE_VERSION: &str = "conflict-matrix-graph-prep-v1";
6233const GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION: &str = "backend-eval-full-projection-v5";
6234
6235#[derive(Clone, Serialize, Deserialize)]
6236pub(crate) struct GraphDbBackendEvalPhaseTiming {
6237 name: String,
6238 duration_micros: u128,
6239 detail: String,
6240}
6241
6242#[derive(Serialize, Deserialize)]
6243struct GraphDbBackendEvalFullProjectionCache {
6244 version: String,
6245 key: String,
6246 source_watermark: String,
6247 projection: GraphProjection,
6248 warnings: Vec<String>,
6249}
6250
6251#[derive(Clone, Default)]
6252struct GraphDbBackendEvalFullProjectionCacheStats {
6253 hit: bool,
6254 disk_bytes: u64,
6255 json_bytes: u64,
6256 pruned_files: usize,
6257 pruned_bytes: u64,
6258}
6259
6260#[derive(Serialize)]
6261struct GraphDbBackendEvalRawSourceWatermarkRow {
6262 path: String,
6263 bytes: u64,
6264 content_hash: String,
6265}
6266
6267#[derive(Clone)]
6268struct GraphDbBackendEvalFullProjectionSourceWatermark {
6269 value: String,
6270 detail: String,
6271}
6272
6273#[derive(Serialize)]
6274pub(crate) struct GraphDbBackendEvalConfig {
6275 high_degree_nodes: usize,
6276 high_degree_fanout: usize,
6277 deep_chain_nodes: usize,
6278 deep_chain_fanout: usize,
6279 depth: usize,
6280 limit: usize,
6281 impact_limit: usize,
6282 path_max_hops: usize,
6283 path_direct_hop_budget: usize,
6284 path_deep_chain_hop_budget: usize,
6285 path_extended_hop_budgets: Vec<usize>,
6286 path_hop_policy: String,
6287 path_probe_strategy: String,
6288 path_query_plan_checks: Vec<String>,
6289 full_projection_enabled: bool,
6290 full_projection_profile: String,
6291 normalization_row_unit: usize,
6292}
6293
6294#[derive(Clone)]
6295struct GraphDbBackendEvalSignature {
6296 operation: String,
6297 value: serde_json::Value,
6298}
6299
6300#[derive(Serialize)]
6301struct GraphDbBackendEvalOperation {
6302 name: String,
6303 supported: bool,
6304 status: String,
6305 duration_micros: u128,
6306 #[serde(skip_serializing_if = "Option::is_none")]
6307 rows: Option<usize>,
6308 #[serde(skip_serializing_if = "Option::is_none")]
6309 error: Option<String>,
6310}
6311
6312#[derive(Serialize)]
6313struct GraphDbBackendEvalParity {
6314 matches_sqlite: bool,
6315 diagnostics: Vec<String>,
6316}
6317
6318#[derive(Serialize)]
6319struct GraphDbBackendEvalBackendReport {
6320 backend: String,
6321 adapter: String,
6322 read_only: bool,
6323 projection_load: String,
6324 operations: Vec<GraphDbBackendEvalOperation>,
6325 total_micros: u128,
6326 parity: GraphDbBackendEvalParity,
6327 lock_behavior: String,
6328 install_portability: String,
6329}
6330
6331#[derive(Serialize)]
6332struct GraphDbBackendEvalDataset {
6333 name: String,
6334 target_count: usize,
6335 nodes: usize,
6336 edges: usize,
6337 backends: Vec<GraphDbBackendEvalBackendReport>,
6338}
6339
6340#[derive(Serialize)]
6341struct GraphDbBackendPromotionDecision {
6342 backend: String,
6343 decision: String,
6344 reasons: Vec<String>,
6345 gate: GraphDbBackendPromotionGate,
6346}
6347
6348#[derive(Serialize)]
6349struct GraphDbBackendEvalPerformanceGate {
6350 baseline_fixture: String,
6351 ci_profile: String,
6352 opt_in_real_profile: String,
6353 full_projection_cache_hit_gate: String,
6354 allowed_regression_percent: f64,
6355 minimum_sample_runs: usize,
6356 normalized_metric_unit: String,
6357 required_metrics: Vec<String>,
6358 digest_command: String,
6359 repeated_sample_command: String,
6360 hop_cap_promotion: GraphDbHopCapPromotionGate,
6361 backend_adapter_spike: GraphDbBackendAdapterSpikeGate,
6362}
6363
6364#[derive(Serialize)]
6365struct GraphDbHopCapPromotionGate {
6366 status: String,
6367 current_default_hops: usize,
6368 candidate_hop_tiers: Vec<usize>,
6369 required_backend: String,
6370 required_workloads: Vec<String>,
6371 required_metrics: Vec<String>,
6372 allowed_regression_percent: f64,
6373 minimum_sample_runs: usize,
6374 decision_rule: String,
6375}
6376
6377#[derive(Serialize)]
6378struct GraphDbBackendAdapterSpikeGate {
6379 status: String,
6380 candidate_backends: Vec<GraphDbBackendAdapterSpikeCandidate>,
6381 required_workloads: Vec<String>,
6382 required_checks: Vec<String>,
6383 decision_rule: String,
6384 evidence_plan: String,
6385}
6386
6387#[derive(Serialize)]
6388struct GraphDbBackendAdapterSpikeCandidate {
6389 backend: String,
6390 adapter_label: String,
6391 projection_load: String,
6392 lock_behavior: String,
6393 install_portability: String,
6394}
6395
6396#[derive(Serialize)]
6397pub(crate) struct GraphDbBackendEvalReport {
6398 root: String,
6399 #[serde(skip_serializing_if = "Option::is_none")]
6400 scope: Option<String>,
6401 label: String,
6402 baseline_backend: String,
6403 candidates: Vec<String>,
6404 targets: Vec<String>,
6405 config: GraphDbBackendEvalConfig,
6406 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6407 datasets: Vec<GraphDbBackendEvalDataset>,
6408 promotion: Vec<GraphDbBackendPromotionDecision>,
6409 performance_gate: GraphDbBackendEvalPerformanceGate,
6410 metrics: BTreeMap<String, f64>,
6411 metric_digest_command: String,
6412 warnings: Vec<String>,
6413}
6414
6415#[derive(Clone, Debug, Serialize)]
6416struct GraphDbDoctorCheck {
6417 name: String,
6418 status: String,
6419 fail_closed: bool,
6420 diagnostics: Vec<String>,
6421 repair_commands: Vec<String>,
6422}
6423
6424#[derive(Serialize)]
6425pub(crate) struct GraphDbDoctorReport {
6426 root: String,
6427 #[serde(skip_serializing_if = "Option::is_none")]
6428 scope: Option<String>,
6429 backend: String,
6430 graph_db: String,
6431 #[serde(skip_serializing_if = "Option::is_none")]
6432 convex_snapshot: Option<String>,
6433 status: String,
6434 fail_closed: bool,
6435 checks: Vec<GraphDbDoctorCheck>,
6436 repair_commands: Vec<String>,
6437 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6438 required_indexes: Vec<ConvexRequiredIndex>,
6439}
6440
6441#[derive(Serialize)]
6442struct GraphDbDriftSummary {
6443 node_upserts: usize,
6444 edge_upserts: usize,
6445 node_tombstones: usize,
6446 edge_tombstones: usize,
6447 stale_nodes: usize,
6448 stale_edges: usize,
6449 stale_projection_metadata: usize,
6450 duplicate_failures: usize,
6451 orphan_failures: usize,
6452 missing_required_indexes: usize,
6453}
6454
6455#[derive(Serialize)]
6456struct GraphDbDriftReport {
6457 root: String,
6458 #[serde(skip_serializing_if = "Option::is_none")]
6459 scope: Option<String>,
6460 graph_db: String,
6461 convex_snapshot: String,
6462 status: String,
6463 graph_reads_allowed: bool,
6464 projection_version: String,
6465 local_hash: Option<String>,
6466 snapshot_hash: Option<String>,
6467 summary: GraphDbDriftSummary,
6468 node_upserts: Vec<String>,
6469 edge_upserts: Vec<String>,
6470 node_tombstones: Vec<String>,
6471 edge_tombstones: Vec<String>,
6472 stale_nodes: Vec<String>,
6473 stale_edges: Vec<String>,
6474 diagnostics: Vec<String>,
6475 next_commands: Vec<String>,
6476 required_indexes: Vec<ConvexRequiredIndex>,
6477 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6478 warnings: Vec<String>,
6479}
6480
6481#[derive(Clone, Serialize)]
6482struct GraphDbTombstoneCounts {
6483 nodes: usize,
6484 edges: usize,
6485 total: usize,
6486}
6487
6488#[derive(Clone, Serialize)]
6489struct GraphDbOperatorCounts {
6490 nodes: usize,
6491 edges: usize,
6492 tombstones: GraphDbTombstoneCounts,
6493 #[serde(skip_serializing_if = "Option::is_none")]
6494 file_size_bytes: Option<u64>,
6495 #[serde(skip_serializing_if = "Option::is_none")]
6496 freelist_bytes: Option<u64>,
6497}
6498
6499#[derive(Clone, Serialize)]
6500struct GraphDbCompactionPolicy {
6501 status: String,
6502 tombstone_scan_rows: usize,
6503 live_rows: usize,
6504 file_size_bytes: Option<u64>,
6505 freelist_bytes: Option<u64>,
6506 safe_to_prune_tombstones: bool,
6507 requires_convex_reconciliation: bool,
6508 recommendations: Vec<String>,
6509 proof: Vec<String>,
6510}
6511
6512#[derive(Serialize)]
6513pub(crate) struct GraphDbRefreshSummary {
6514 scope: String,
6515 projection_version: String,
6516 mode: String,
6517 #[serde(skip_serializing_if = "Option::is_none")]
6518 source_watermark: Option<String>,
6519 tombstoned_nodes: usize,
6520 tombstoned_edges: usize,
6521 upserted_nodes: usize,
6522 upserted_edges: usize,
6523 unchanged_nodes: usize,
6524 unchanged_edges: usize,
6525 upserted_properties: usize,
6526 unchanged_properties: usize,
6527 deleted_properties: usize,
6528 deleted_nodes: usize,
6529 deleted_edges: usize,
6530 pruned_tombstones: usize,
6531 #[serde(skip_serializing_if = "Option::is_none")]
6532 file_size_bytes_before: Option<u64>,
6533 #[serde(skip_serializing_if = "Option::is_none")]
6534 file_size_bytes_after: Option<u64>,
6535 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6536 phase_timings: Vec<GraphDbBackendEvalPhaseTiming>,
6537}
6538
6539#[derive(Serialize)]
6540struct GraphDbOperatorReport {
6541 root: String,
6542 #[serde(skip_serializing_if = "Option::is_none")]
6543 scope: Option<String>,
6544 graph_db: String,
6545 operation: String,
6546 status: String,
6547 materialized: bool,
6548 freshness: GraphDbFreshnessReport,
6549 readiness: GraphEffectivenessReadiness,
6550 counts: GraphDbOperatorCounts,
6551 #[serde(skip_serializing_if = "Option::is_none")]
6552 refresh: Option<GraphDbRefreshSummary>,
6553 compaction: GraphDbCompactionPolicy,
6554 #[serde(skip_serializing_if = "Option::is_none")]
6555 recovery: Option<index::ReadOnlyRecovery>,
6556 next_commands: Vec<String>,
6557 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6558 warnings: Vec<String>,
6559}
6560
6561#[derive(Serialize)]
6562pub(crate) struct GraphDbCompactionReport {
6563 root: String,
6564 #[serde(skip_serializing_if = "Option::is_none")]
6565 scope: Option<String>,
6566 graph_db: String,
6567 applied: bool,
6568 pruned_tombstones: usize,
6569 counts_before: GraphDbOperatorCounts,
6570 counts_after: GraphDbOperatorCounts,
6571 compaction_before: GraphDbCompactionPolicy,
6572 compaction_after: GraphDbCompactionPolicy,
6573 reclaimed_bytes: i64,
6574 next_commands: Vec<String>,
6575 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6576 warnings: Vec<String>,
6577}
6578
6579#[derive(Clone, Serialize, Deserialize)]
6580struct GraphDbEvidencePath {
6581 to: String,
6582 kind: String,
6583 label: String,
6584 #[serde(skip_serializing_if = "Option::is_none")]
6585 path: Option<substrate::GraphPath>,
6586 #[serde(skip_serializing_if = "Option::is_none")]
6587 expand: Option<String>,
6588}
6589
6590#[derive(Clone, Serialize, Deserialize)]
6591struct GraphDbFixtureCoverage {
6592 test: String,
6593 fixture: String,
6594 assertions: Vec<String>,
6595}
6596
6597#[derive(Clone, Serialize, Deserialize)]
6598struct GraphDbEvidenceReport {
6599 root: String,
6600 #[serde(skip_serializing_if = "Option::is_none")]
6601 scope: Option<String>,
6602 backend: String,
6603 contract_version: String,
6604 target: String,
6605 packet_id: String,
6606 #[serde(skip_serializing_if = "Option::is_none")]
6607 projection_hash: Option<String>,
6608 freshness: GraphDbFreshnessReport,
6609 target_node: SubstrateTerseGraphNode,
6610 worker_context: Vec<SubstrateTerseGraphNode>,
6611 source_handles: Vec<SubstrateTerseGraphNode>,
6612 worker_results: Vec<SubstrateTerseGraphNode>,
6613 semantic_related: Vec<SubstrateTerseGraphNode>,
6614 shortest_paths: Vec<GraphDbEvidencePath>,
6615 #[serde(skip_serializing_if = "Option::is_none")]
6616 output_budget: Option<GraphDbOutputBudgetReport>,
6617 #[serde(default)]
6618 truncated: bool,
6619 #[serde(skip_serializing_if = "Option::is_none")]
6620 next_cursor: Option<String>,
6621 next_commands: Vec<String>,
6622 replay_commands: Vec<String>,
6623 repair_commands: Vec<String>,
6624 fixture_coverage: GraphDbFixtureCoverage,
6625 #[serde(skip_serializing_if = "Vec::is_empty", default)]
6626 warnings: Vec<String>,
6627}
6628
6629pub(crate) struct GraphDbEvidenceInput<'a, S: GraphStore> {
6630 root: &'a Path,
6631 scope: Option<&'a str>,
6632 backend: &'a str,
6633 target: &'a str,
6634 preferred_path: Option<&'a str>,
6635 depth: usize,
6636 limit: usize,
6637 cursor: Option<&'a str>,
6638 store: &'a S,
6639 freshness: GraphDbFreshnessReport,
6640 warnings: Vec<String>,
6641}
6642
6643impl GraphDbDoctorReport {
6644 fn new(
6645 root: &Path,
6646 scope: Option<&str>,
6647 backend: &str,
6648 graph_db: &Path,
6649 convex_snapshot: Option<&Path>,
6650 ) -> Self {
6651 Self {
6652 root: root.to_string_lossy().to_string(),
6653 scope: scope.map(str::to_string),
6654 backend: backend.to_string(),
6655 graph_db: graph_db.to_string_lossy().to_string(),
6656 convex_snapshot: convex_snapshot.map(|path| path.to_string_lossy().to_string()),
6657 status: "ok".to_string(),
6658 fail_closed: false,
6659 checks: Vec::new(),
6660 repair_commands: Vec::new(),
6661 required_indexes: Vec::new(),
6662 }
6663 }
6664
6665 fn push_check(&mut self, check: GraphDbDoctorCheck) {
6666 self.checks.push(check);
6667 }
6668
6669 fn finalize(&mut self) {
6670 self.fail_closed = self.checks.iter().any(|check| check.fail_closed);
6671 self.status = if self.fail_closed {
6672 "fail_closed"
6673 } else {
6674 "ok"
6675 }
6676 .to_string();
6677 let mut commands = BTreeSet::new();
6678 for check in &self.checks {
6679 commands.extend(check.repair_commands.iter().cloned());
6680 }
6681 self.repair_commands = commands.into_iter().collect();
6682 }
6683
6684 fn summary(&self) -> String {
6685 self.checks
6686 .iter()
6687 .filter(|check| check.fail_closed)
6688 .flat_map(|check| check.diagnostics.iter())
6689 .take(3)
6690 .cloned()
6691 .collect::<Vec<_>>()
6692 .join("; ")
6693 }
6694}
6695
6696fn graph_db_doctor_check(
6697 name: impl Into<String>,
6698 diagnostics: Vec<String>,
6699 repair_commands: Vec<String>,
6700) -> GraphDbDoctorCheck {
6701 let fail_closed = !diagnostics.is_empty();
6702 GraphDbDoctorCheck {
6703 name: name.into(),
6704 status: if fail_closed { "fail_closed" } else { "ok" }.to_string(),
6705 fail_closed,
6706 diagnostics,
6707 repair_commands: if fail_closed {
6708 repair_commands
6709 } else {
6710 Vec::new()
6711 },
6712 }
6713}
6714
6715pub(crate) fn graph_db_scope_arg(scope: Option<&str>) -> String {
6716 scope
6717 .map(|scope| format!(" --scope {}", shell_quote(scope)))
6718 .unwrap_or_default()
6719}
6720
6721fn graph_db_refresh_command(root: &Path, scope: Option<&str>) -> String {
6722 format!(
6723 "tsift graph-db --path {}{} refresh --json",
6724 shell_quote(root.to_string_lossy().as_ref()),
6725 graph_db_scope_arg(scope)
6726 )
6727}
6728
6729fn graph_db_rebuild_command(root: &Path, scope: Option<&str>) -> String {
6730 graph_db_refresh_command(root, scope)
6731}
6732
6733fn graph_db_backup_rebuild_command(root: &Path, scope: Option<&str>, graph_db: &Path) -> String {
6734 let backup = format!("{}.bak", graph_db.to_string_lossy());
6735 format!(
6736 "mv {} {} && {}",
6737 shell_quote(graph_db.to_string_lossy().as_ref()),
6738 shell_quote(&backup),
6739 graph_db_rebuild_command(root, scope)
6740 )
6741}
6742
6743fn convex_refresh_command(root: &Path, scope: Option<&str>) -> String {
6744 format!(
6745 "tsift convex-sync {}{} --remote-snapshot --apply --json",
6746 shell_quote(root.to_string_lossy().as_ref()),
6747 graph_db_scope_arg(scope)
6748 )
6749}
6750
6751fn open_sqlite_graph_db_readonly(graph_db: &Path) -> Result<substrate::SqliteReadOnlyConnection> {
6752 substrate::open_graph_read_only_connection_resilient(graph_db)
6753}
6754
6755fn sqlite_table_exists(conn: &Connection, table: &str) -> Result<bool> {
6756 conn.query_row(
6757 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
6758 [table],
6759 |row| row.get::<_, bool>(0),
6760 )
6761 .map_err(Into::into)
6762}
6763
6764fn row_usize(row: &Row<'_>, idx: usize) -> rusqlite::Result<usize> {
6765 let value: i64 = row.get(idx)?;
6766 usize::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6767}
6768
6769fn row_u64(row: &Row<'_>, idx: usize) -> rusqlite::Result<u64> {
6770 let value: i64 = row.get(idx)?;
6771 u64::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(idx, value))
6772}
6773
6774fn sqlite_known_table_count(conn: &Connection, table: &str) -> Result<usize> {
6775 let sql = match table {
6776 "graph_nodes" => "SELECT COUNT(*) FROM graph_nodes",
6777 "graph_edges" => "SELECT COUNT(*) FROM graph_edges",
6778 "graph_tombstones" => "SELECT COUNT(*) FROM graph_tombstones",
6779 other => bail!("unsupported graph count table {other}"),
6780 };
6781 conn.query_row(sql, [], |row| row_usize(row, 0))
6782 .map_err(Into::into)
6783}
6784
6785fn sqlite_tombstone_counts(conn: &Connection) -> Result<GraphDbTombstoneCounts> {
6786 if !sqlite_table_exists(conn, "graph_tombstones")? {
6787 return Ok(GraphDbTombstoneCounts {
6788 nodes: 0,
6789 edges: 0,
6790 total: 0,
6791 });
6792 }
6793 let mut stmt =
6794 conn.prepare("SELECT row_kind, COUNT(*) FROM graph_tombstones GROUP BY row_kind")?;
6795 let mut rows = stmt.query([])?;
6796 let mut nodes = 0usize;
6797 let mut edges = 0usize;
6798 while let Some(row) = rows.next()? {
6799 let row_kind: String = row.get(0)?;
6800 let count = row_usize(row, 1)?;
6801 match row_kind.as_str() {
6802 "node" => nodes = count,
6803 "edge" => edges = count,
6804 _ => {}
6805 }
6806 }
6807 Ok(GraphDbTombstoneCounts {
6808 nodes,
6809 edges,
6810 total: nodes + edges,
6811 })
6812}
6813
6814fn sqlite_graph_counts_from_cache(
6815 conn: &Connection,
6816 scope: &str,
6817) -> Result<Option<GraphDbOperatorCounts>> {
6818 if !sqlite_table_exists(conn, "graph_operator_stats")? {
6819 return Ok(None);
6820 }
6821 let row = conn
6822 .query_row(
6823 r#"
6824 SELECT nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes
6825 FROM graph_operator_stats
6826 WHERE scope = ?1
6827 "#,
6828 [scope],
6829 |row| {
6830 Ok((
6831 row_usize(row, 0)?,
6832 row_usize(row, 1)?,
6833 row_usize(row, 2)?,
6834 row_usize(row, 3)?,
6835 row.get::<_, Option<i64>>(4)?,
6836 row.get::<_, Option<i64>>(5)?,
6837 ))
6838 },
6839 )
6840 .optional()?;
6841 Ok(row.map(
6842 |(nodes, edges, tombstone_nodes, tombstone_edges, file_size_bytes, freelist_bytes)| {
6843 GraphDbOperatorCounts {
6844 nodes,
6845 edges,
6846 tombstones: GraphDbTombstoneCounts {
6847 nodes: tombstone_nodes,
6848 edges: tombstone_edges,
6849 total: tombstone_nodes + tombstone_edges,
6850 },
6851 file_size_bytes: file_size_bytes
6852 .and_then(|value| u64::try_from(value).ok())
6853 .or_else(|| sqlite_database_size_bytes(conn).ok()),
6854 freelist_bytes: freelist_bytes
6855 .and_then(|value| u64::try_from(value).ok())
6856 .or_else(|| sqlite_database_freelist_bytes(conn).ok()),
6857 }
6858 },
6859 ))
6860}
6861
6862fn sqlite_graph_counts(conn: &Connection, scope: &str) -> Result<GraphDbOperatorCounts> {
6863 if let Some(counts) = sqlite_graph_counts_from_cache(conn, scope)? {
6864 return Ok(counts);
6865 }
6866 let nodes = if sqlite_table_exists(conn, "graph_nodes")? {
6867 sqlite_known_table_count(conn, "graph_nodes")?
6868 } else {
6869 0
6870 };
6871 let edges = if sqlite_table_exists(conn, "graph_edges")? {
6872 sqlite_known_table_count(conn, "graph_edges")?
6873 } else {
6874 0
6875 };
6876 Ok(GraphDbOperatorCounts {
6877 nodes,
6878 edges,
6879 tombstones: sqlite_tombstone_counts(conn)?,
6880 file_size_bytes: sqlite_database_size_bytes(conn).ok(),
6881 freelist_bytes: sqlite_database_freelist_bytes(conn).ok(),
6882 })
6883}
6884
6885fn sqlite_graph_semantic_node_count(conn: &Connection) -> Result<usize> {
6886 if !sqlite_table_exists(conn, "graph_nodes")? {
6887 return Ok(0);
6888 }
6889 let count: i64 = conn.query_row(
6890 "SELECT COUNT(*) FROM graph_nodes WHERE kind IN ('semantic_concept', 'semantic_entity')",
6891 [],
6892 |row| row.get(0),
6893 )?;
6894 Ok(count as usize)
6895}
6896
6897pub(crate) fn graph_db_compaction_policy(
6898 root: &Path,
6899 scope: Option<&str>,
6900 counts: &GraphDbOperatorCounts,
6901 prune_confirmed: bool,
6902) -> GraphDbCompactionPolicy {
6903 let live_rows = counts.nodes + counts.edges;
6904 let tombstone_scan_rows = counts.tombstones.total;
6905 let tombstone_heavy = tombstone_scan_rows > live_rows.max(1);
6906 let freelist_heavy = counts
6907 .file_size_bytes
6908 .zip(counts.freelist_bytes)
6909 .is_some_and(|(file_size, freelist)| freelist > 0 && freelist >= file_size / 20);
6910 let status = if tombstone_heavy || freelist_heavy {
6911 "recommended"
6912 } else {
6913 "not_needed"
6914 }
6915 .to_string();
6916 let mut recommendations = vec![
6917 convex_refresh_command(root, scope),
6918 graph_db_refresh_command(root, scope),
6919 format!(
6920 "tsift graph-db --path {}{} compact --apply --json",
6921 shell_quote(root.to_string_lossy().as_ref()),
6922 graph_db_scope_arg(scope)
6923 ),
6924 ];
6925 if prune_confirmed {
6926 recommendations.push(format!(
6927 "tsift graph-db --path {}{} compact --apply --prune-tombstones --confirmed-convex-reconciled --json",
6928 shell_quote(root.to_string_lossy().as_ref()),
6929 graph_db_scope_arg(scope)
6930 ));
6931 }
6932 let proof = vec![
6933 format!("{live_rows} live graph row(s)"),
6934 format!("{tombstone_scan_rows} retained tombstone row(s) scanned by status/doctor"),
6935 format!(
6936 "graph.db file_size={} byte(s), freelist={} byte(s)",
6937 counts.file_size_bytes.unwrap_or(0),
6938 counts.freelist_bytes.unwrap_or(0)
6939 ),
6940 ];
6941 GraphDbCompactionPolicy {
6942 status,
6943 tombstone_scan_rows,
6944 live_rows,
6945 file_size_bytes: counts.file_size_bytes,
6946 freelist_bytes: counts.freelist_bytes,
6947 safe_to_prune_tombstones: prune_confirmed,
6948 requires_convex_reconciliation: tombstone_scan_rows > 0 && !prune_confirmed,
6949 recommendations,
6950 proof,
6951 }
6952}
6953
6954fn sqlite_database_size_bytes(conn: &Connection) -> Result<u64> {
6955 let page_count = conn.query_row("PRAGMA page_count", [], |row| row_u64(row, 0))?;
6956 let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
6957 Ok(page_count.saturating_mul(page_size))
6958}
6959
6960fn sqlite_database_freelist_bytes(conn: &Connection) -> Result<u64> {
6961 let freelist_count = conn.query_row("PRAGMA freelist_count", [], |row| row_u64(row, 0))?;
6962 let page_size = conn.query_row("PRAGMA page_size", [], |row| row_u64(row, 0))?;
6963 Ok(freelist_count.saturating_mul(page_size))
6964}
6965
6966fn sqlite_graph_tombstone_retention_diagnostics(
6967 conn: &Connection,
6968 scope: &str,
6969) -> Result<Vec<String>> {
6970 if !sqlite_table_exists(conn, "graph_tombstones")? {
6971 return Ok(Vec::new());
6972 }
6973 let cached = sqlite_graph_counts_from_cache(conn, scope)?;
6974 let counts = match cached.clone() {
6975 Some(counts) => counts,
6976 None => sqlite_graph_counts(conn, scope)?,
6977 };
6978 let live_rows = counts.nodes + counts.edges;
6979 let file_size = counts.file_size_bytes.unwrap_or(0);
6980 let freelist = counts.freelist_bytes.unwrap_or(0);
6981 let stale_live_tombstones = if cached.is_some() {
6982 0
6983 } else {
6984 let mut live_keys = BTreeSet::new();
6985 if sqlite_table_exists(conn, "graph_nodes")? {
6986 let mut stmt = conn.prepare("SELECT id FROM graph_nodes")?;
6987 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6988 live_keys.insert(format!("node:{}", row?));
6989 }
6990 }
6991 if sqlite_table_exists(conn, "graph_edges")? {
6992 let mut stmt = conn.prepare("SELECT edge_key FROM graph_edges")?;
6993 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
6994 live_keys.insert(format!("edge:{}", row?));
6995 }
6996 }
6997 let mut stale_live_tombstones = 0usize;
6998 let mut stmt = conn.prepare("SELECT row_key FROM graph_tombstones ORDER BY row_key")?;
6999 for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
7000 if live_keys.contains(&row?) {
7001 stale_live_tombstones += 1;
7002 }
7003 }
7004 stale_live_tombstones
7005 };
7006
7007 let mut diagnostics = Vec::new();
7008 if stale_live_tombstones > 0 {
7009 diagnostics.push(format!(
7010 "{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"
7011 ));
7012 }
7013 if counts.tombstones.total > live_rows.max(1) {
7014 let source = if cached.is_some() {
7015 "cached refresh stats"
7016 } else {
7017 "live row scan"
7018 };
7019 diagnostics.push(format!(
7020 "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.",
7021 counts.tombstones.total,
7022 live_rows,
7023 source,
7024 file_size,
7025 freelist,
7026 counts.tombstones.total
7027 ));
7028 }
7029 Ok(diagnostics)
7030}
7031
7032fn sqlite_graph_freshness_from_conn(
7033 conn: &Connection,
7034 scope: &str,
7035) -> Result<GraphDbFreshnessReport> {
7036 if !sqlite_table_exists(conn, "graph_projection_versions")? {
7037 return Ok(GraphDbFreshnessReport {
7038 status: "missing".to_string(),
7039 fail_closed: true,
7040 projection_version: None,
7041 content_hash: None,
7042 source_watermark: None,
7043 diagnostics: vec![
7044 "graph projection metadata table is missing; refresh graph.db before trusting reads"
7045 .to_string(),
7046 ],
7047 });
7048 }
7049 let version = conn
7050 .query_row(
7051 r#"
7052 SELECT projection_version, content_hash, source_watermark
7053 FROM graph_projection_versions
7054 WHERE scope = ?1
7055 "#,
7056 [scope],
7057 |row| {
7058 Ok((
7059 row.get::<_, String>(0)?,
7060 row.get::<_, Option<String>>(1)?,
7061 row.get::<_, Option<String>>(2)?,
7062 ))
7063 },
7064 )
7065 .optional()?;
7066 let Some((projection_version, content_hash, source_watermark)) = version else {
7067 return Ok(GraphDbFreshnessReport {
7068 status: "missing".to_string(),
7069 fail_closed: true,
7070 projection_version: None,
7071 content_hash: None,
7072 source_watermark: None,
7073 diagnostics: vec![
7074 "graph projection metadata is missing; refresh graph.db before trusting reads"
7075 .to_string(),
7076 ],
7077 });
7078 };
7079
7080 let mut diagnostics = Vec::new();
7081 if projection_version != GRAPH_PROJECTION_VERSION {
7082 diagnostics.push(format!(
7083 "projection version mismatch: expected {} got {}",
7084 GRAPH_PROJECTION_VERSION, projection_version
7085 ));
7086 }
7087 if content_hash.is_none() {
7088 diagnostics.push("projection content hash is missing".to_string());
7089 }
7090 let fail_closed = !diagnostics.is_empty();
7091 Ok(GraphDbFreshnessReport {
7092 status: if fail_closed { "stale" } else { "current" }.to_string(),
7093 fail_closed,
7094 projection_version: Some(projection_version),
7095 content_hash,
7096 source_watermark,
7097 diagnostics,
7098 })
7099}
7100
7101fn graph_db_operator_next_commands(
7102 root: &Path,
7103 scope: Option<&str>,
7104 include_refresh: bool,
7105) -> Vec<String> {
7106 let mut commands = Vec::new();
7107 if include_refresh {
7108 commands.push(graph_db_refresh_command(root, scope));
7109 }
7110 commands.push(format!(
7111 "tsift graph-db --path {}{} doctor --json",
7112 shell_quote(root.to_string_lossy().as_ref()),
7113 graph_db_scope_arg(scope)
7114 ));
7115 commands.push(format!(
7116 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot <rows.json> drift --json",
7117 shell_quote(root.to_string_lossy().as_ref()),
7118 graph_db_scope_arg(scope)
7119 ));
7120 commands.push(format!(
7121 "tsift convex-sync {}{} --remote-snapshot --apply --json",
7122 shell_quote(root.to_string_lossy().as_ref()),
7123 graph_db_scope_arg(scope)
7124 ));
7125 commands
7126}
7127
7128pub(crate) fn graph_db_read_recovery_diagnostic(recovery: index::ReadOnlyRecovery) -> String {
7129 match recovery {
7130 index::ReadOnlyRecovery::SnapshotFallback => {
7131 "graph.db read recovered through snapshot fallback after a rollback-journal lock on the live database".to_string()
7132 }
7133 index::ReadOnlyRecovery::SnapshotFallbackWal => {
7134 "graph.db read recovered through WAL-aware snapshot fallback after copying live -wal/-shm sidecars".to_string()
7135 }
7136 }
7137}
7138
7139fn sqlite_string_set(conn: &Connection, sql: &str) -> Result<BTreeSet<String>> {
7140 let mut stmt = conn.prepare(sql)?;
7141 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7142 let mut values = BTreeSet::new();
7143 for row in rows {
7144 values.insert(row?);
7145 }
7146 Ok(values)
7147}
7148
7149fn sqlite_column_names(conn: &Connection, table: &str) -> Result<BTreeSet<String>> {
7150 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
7151 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
7152 let mut columns = BTreeSet::new();
7153 for row in rows {
7154 columns.insert(row?);
7155 }
7156 Ok(columns)
7157}
7158
7159fn sqlite_graph_schema_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7160 let mut diagnostics = Vec::new();
7161 let user_version: i64 =
7162 conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
7163 if user_version > SQLITE_GRAPH_SCHEMA_VERSION {
7164 diagnostics.push(format!(
7165 "graph.db schema version {user_version} is newer than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7166 ));
7167 } else if user_version < SQLITE_GRAPH_SCHEMA_VERSION {
7168 diagnostics.push(format!(
7169 "graph.db schema version {user_version} is older than supported version {SQLITE_GRAPH_SCHEMA_VERSION}"
7170 ));
7171 }
7172
7173 let tables = sqlite_string_set(
7174 conn,
7175 "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
7176 )?;
7177 let required_tables = [
7178 (
7179 "graph_nodes",
7180 vec![
7181 "id",
7182 "kind",
7183 "label",
7184 "properties_json",
7185 "provenance_json",
7186 "freshness_json",
7187 "row_hash",
7188 "source_watermark",
7189 ],
7190 ),
7191 (
7192 "graph_edges",
7193 vec![
7194 "edge_key",
7195 "from_id",
7196 "to_id",
7197 "kind",
7198 "properties_json",
7199 "provenance_json",
7200 "freshness_json",
7201 "row_hash",
7202 "source_watermark",
7203 ],
7204 ),
7205 (
7206 "graph_projection_versions",
7207 vec![
7208 "scope",
7209 "projection_version",
7210 "content_hash",
7211 "source_watermark",
7212 "observed_at_unix",
7213 ],
7214 ),
7215 (
7216 "graph_tombstones",
7217 vec!["row_key", "row_kind", "deleted_at_unix"],
7218 ),
7219 ("graph_node_properties", vec!["node_id", "key", "value"]),
7220 ("graph_edge_properties", vec!["edge_key", "key", "value"]),
7221 ];
7222 for (table, required_columns) in required_tables {
7223 if !tables.contains(table) {
7224 diagnostics.push(format!("graph.db schema drift: missing table {table}"));
7225 continue;
7226 }
7227 let columns = sqlite_column_names(conn, table)?;
7228 for column in required_columns {
7229 if !columns.contains(column) {
7230 diagnostics.push(format!(
7231 "graph.db schema drift: missing column {table}.{column}"
7232 ));
7233 }
7234 }
7235 }
7236
7237 let indexes = sqlite_string_set(
7238 conn,
7239 "SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name",
7240 )?;
7241 for index in [
7242 "idx_graph_nodes_kind",
7243 "idx_graph_edges_from_kind",
7244 "idx_graph_edges_to_kind",
7245 "idx_graph_edges_edge_key",
7246 "idx_graph_node_properties_key_value_node",
7247 "idx_graph_edge_properties_key_value_edge",
7248 ] {
7249 if !indexes.contains(index) {
7250 diagnostics.push(format!("graph.db schema drift: missing index {index}"));
7251 }
7252 }
7253
7254 if tables.contains("graph_edges") {
7255 let mut stmt = conn.prepare("PRAGMA foreign_key_list(graph_edges)")?;
7256 let rows = stmt.query_map([], |row| {
7257 Ok((row.get::<_, String>(3)?, row.get::<_, String>(4)?))
7258 })?;
7259 let mut fks = BTreeSet::new();
7260 for row in rows {
7261 fks.insert(row?);
7262 }
7263 for expected in [
7264 ("from_id".to_string(), "id".to_string()),
7265 ("to_id".to_string(), "id".to_string()),
7266 ] {
7267 if !fks.contains(&expected) {
7268 diagnostics.push(format!(
7269 "graph.db schema drift: missing graph_edges foreign key {} -> graph_nodes.{}",
7270 expected.0, expected.1
7271 ));
7272 }
7273 }
7274 }
7275
7276 Ok(diagnostics)
7277}
7278
7279fn sqlite_query_diagnostics(conn: &Connection, sql: &str) -> Result<Vec<String>> {
7280 let mut stmt = conn.prepare(sql)?;
7281 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7282 let mut diagnostics = Vec::new();
7283 for row in rows {
7284 diagnostics.push(row?);
7285 }
7286 Ok(diagnostics)
7287}
7288
7289fn sqlite_graph_duplicate_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7290 let mut diagnostics = sqlite_query_diagnostics(
7291 conn,
7292 r#"
7293 SELECT 'duplicate graph_nodes.id ' || id || ' (' || COUNT(*) || ' rows)'
7294 FROM graph_nodes
7295 GROUP BY id
7296 HAVING COUNT(*) > 1
7297 ORDER BY id
7298 "#,
7299 )?;
7300 diagnostics.extend(sqlite_query_diagnostics(
7301 conn,
7302 r#"
7303 SELECT 'duplicate graph_edges key ' || from_id || ' -' || kind || '-> ' || to_id || ' (' || COUNT(*) || ' rows)'
7304 FROM graph_edges
7305 GROUP BY from_id, to_id, kind
7306 HAVING COUNT(*) > 1
7307 ORDER BY from_id, kind, to_id
7308 "#,
7309 )?);
7310 diagnostics.extend(sqlite_query_diagnostics(
7311 conn,
7312 r#"
7313 SELECT 'duplicate graph_edges.edge_key ' || edge_key || ' (' || COUNT(*) || ' rows)'
7314 FROM graph_edges
7315 GROUP BY edge_key
7316 HAVING COUNT(*) > 1
7317 ORDER BY edge_key
7318 "#,
7319 )?);
7320 Ok(diagnostics)
7321}
7322
7323fn sqlite_graph_orphan_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7324 sqlite_query_diagnostics(
7325 conn,
7326 r#"
7327 SELECT 'orphan edge missing from node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7328 FROM graph_edges e
7329 LEFT JOIN graph_nodes n ON n.id = e.from_id
7330 WHERE n.id IS NULL
7331 UNION ALL
7332 SELECT 'orphan edge missing to node: ' || e.from_id || ' -' || e.kind || '-> ' || e.to_id
7333 FROM graph_edges e
7334 LEFT JOIN graph_nodes n ON n.id = e.to_id
7335 WHERE n.id IS NULL
7336 ORDER BY 1
7337 "#,
7338 )
7339}
7340
7341fn sqlite_graph_json_diagnostics(conn: &Connection) -> Result<Vec<String>> {
7342 let mut diagnostics = Vec::new();
7343 let mut node_stmt = conn.prepare(
7344 "SELECT id, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7345 )?;
7346 let node_rows = node_stmt.query_map([], |row| {
7347 Ok((
7348 row.get::<_, String>(0)?,
7349 row.get::<_, String>(1)?,
7350 row.get::<_, String>(2)?,
7351 row.get::<_, Option<String>>(3)?,
7352 ))
7353 })?;
7354 for row in node_rows {
7355 let (id, properties_json, provenance_json, freshness_json) = row?;
7356 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7357 diagnostics.push(format!(
7358 "graph_nodes {id} properties_json is invalid: {err}"
7359 ));
7360 }
7361 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7362 diagnostics.push(format!(
7363 "graph_nodes {id} provenance_json is invalid: {err}"
7364 ));
7365 }
7366 if let Some(freshness_json) = freshness_json
7367 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7368 {
7369 diagnostics.push(format!("graph_nodes {id} freshness_json is invalid: {err}"));
7370 }
7371 }
7372
7373 let mut edge_stmt = conn.prepare(
7374 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7375 )?;
7376 let edge_rows = edge_stmt.query_map([], |row| {
7377 Ok((
7378 row.get::<_, String>(0)?,
7379 row.get::<_, String>(1)?,
7380 row.get::<_, String>(2)?,
7381 row.get::<_, String>(3)?,
7382 row.get::<_, String>(4)?,
7383 row.get::<_, String>(5)?,
7384 row.get::<_, Option<String>>(6)?,
7385 ))
7386 })?;
7387 for row in edge_rows {
7388 let (edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json) =
7389 row?;
7390 let edge = format!("{edge_key} {from_id} -{kind}-> {to_id}");
7391 if let Err(err) = serde_json::from_str::<BTreeMap<String, String>>(&properties_json) {
7392 diagnostics.push(format!(
7393 "graph_edges {edge} properties_json is invalid: {err}"
7394 ));
7395 }
7396 if let Err(err) = serde_json::from_str::<Vec<GraphProvenance>>(&provenance_json) {
7397 diagnostics.push(format!(
7398 "graph_edges {edge} provenance_json is invalid: {err}"
7399 ));
7400 }
7401 if let Some(freshness_json) = freshness_json
7402 && let Err(err) = serde_json::from_str::<GraphFreshness>(&freshness_json)
7403 {
7404 diagnostics.push(format!(
7405 "graph_edges {edge} freshness_json is invalid: {err}"
7406 ));
7407 }
7408 }
7409 Ok(diagnostics)
7410}
7411
7412fn sqlite_graph_projection_metadata_diagnostics(
7413 conn: &Connection,
7414 scope: Option<&str>,
7415) -> Result<Vec<String>> {
7416 let mut diagnostics = Vec::new();
7417 let scope_key = scope.unwrap_or("root");
7418 let version = conn
7419 .query_row(
7420 r#"
7421 SELECT projection_version, content_hash, source_watermark
7422 FROM graph_projection_versions
7423 WHERE scope = ?1
7424 "#,
7425 [scope_key],
7426 |row| {
7427 Ok((
7428 row.get::<_, String>(0)?,
7429 row.get::<_, Option<String>>(1)?,
7430 row.get::<_, Option<String>>(2)?,
7431 ))
7432 },
7433 )
7434 .optional()?;
7435 let Some((projection_version, content_hash, _source_watermark)) = version else {
7436 diagnostics.push(format!(
7437 "graph projection metadata is missing for scope {scope_key}"
7438 ));
7439 return Ok(diagnostics);
7440 };
7441 if projection_version != GRAPH_PROJECTION_VERSION {
7442 diagnostics.push(format!(
7443 "projection version mismatch: expected {GRAPH_PROJECTION_VERSION} got {projection_version}"
7444 ));
7445 }
7446 if content_hash.is_none() {
7447 diagnostics.push("projection content hash is missing".to_string());
7448 }
7449
7450 let meta_id = graph_projection_meta_id(scope);
7451 let meta_properties = conn
7452 .query_row(
7453 "SELECT properties_json FROM graph_nodes WHERE id = ?1 AND kind = ?2",
7454 (&meta_id, GRAPH_PROJECTION_META_KIND),
7455 |row| row.get::<_, String>(0),
7456 )
7457 .optional()?;
7458 let Some(meta_properties) = meta_properties else {
7459 diagnostics.push(format!("projection_meta node {meta_id} is missing"));
7460 return Ok(diagnostics);
7461 };
7462 let properties = serde_json::from_str::<BTreeMap<String, String>>(&meta_properties)
7463 .with_context(|| format!("parsing projection_meta properties for {meta_id}"))?;
7464 if properties.get("projection_version").map(String::as_str) != Some(GRAPH_PROJECTION_VERSION) {
7465 diagnostics.push(format!(
7466 "projection_meta node {meta_id} has stale projection_version"
7467 ));
7468 }
7469 if properties.get("content_hash") != content_hash.as_ref() {
7470 diagnostics.push(format!(
7471 "projection_meta node {meta_id} content_hash does not match graph_projection_versions"
7472 ));
7473 }
7474 Ok(diagnostics)
7475}
7476
7477pub(crate) fn sqlite_convex_rows_from_conn(conn: &Connection) -> Result<ConvexProjectionRows> {
7478 let mut node_stmt = conn.prepare(
7479 "SELECT id, kind, label, properties_json, provenance_json, freshness_json FROM graph_nodes ORDER BY id",
7480 )?;
7481 let node_rows = node_stmt.query_map([], |row| {
7482 let properties_json: String = row.get(3)?;
7483 let provenance_json: String = row.get(4)?;
7484 let freshness_json: Option<String> = row.get(5)?;
7485 Ok((
7486 row.get::<_, String>(0)?,
7487 row.get::<_, String>(1)?,
7488 row.get::<_, String>(2)?,
7489 properties_json,
7490 provenance_json,
7491 freshness_json,
7492 ))
7493 })?;
7494 let mut nodes = Vec::new();
7495 for row in node_rows {
7496 let (external_id, kind, label, properties_json, provenance_json, freshness_json) = row?;
7497 nodes.push(ConvexNodeRow {
7498 external_id,
7499 kind,
7500 label,
7501 properties: serde_json::from_str(&properties_json)?,
7502 provenance: serde_json::from_str(&provenance_json)?,
7503 freshness: freshness_json
7504 .map(|value| serde_json::from_str(&value))
7505 .transpose()?,
7506 });
7507 }
7508
7509 let mut edge_stmt = conn.prepare(
7510 "SELECT edge_key, from_id, to_id, kind, properties_json, provenance_json, freshness_json FROM graph_edges ORDER BY from_id, kind, to_id",
7511 )?;
7512 let edge_rows = edge_stmt.query_map([], |row| {
7513 let properties_json: String = row.get(4)?;
7514 let provenance_json: String = row.get(5)?;
7515 let freshness_json: Option<String> = row.get(6)?;
7516 Ok((
7517 row.get::<_, String>(0)?,
7518 row.get::<_, String>(1)?,
7519 row.get::<_, String>(2)?,
7520 row.get::<_, String>(3)?,
7521 properties_json,
7522 provenance_json,
7523 freshness_json,
7524 ))
7525 })?;
7526 let mut edges = Vec::new();
7527 for row in edge_rows {
7528 let (
7529 edge_key,
7530 from_external_id,
7531 to_external_id,
7532 kind,
7533 properties_json,
7534 provenance_json,
7535 freshness_json,
7536 ) = row?;
7537 edges.push(ConvexEdgeRow {
7538 edge_key,
7539 from_external_id,
7540 to_external_id,
7541 kind,
7542 properties: serde_json::from_str(&properties_json)?,
7543 provenance: serde_json::from_str(&provenance_json)?,
7544 freshness: freshness_json
7545 .map(|value| serde_json::from_str(&value))
7546 .transpose()?,
7547 });
7548 }
7549 Ok(ConvexProjectionRows { nodes, edges })
7550}
7551
7552fn convex_required_index_label(index: &ConvexRequiredIndex) -> String {
7553 format!("{}.{}({})", index.table, index.name, index.fields.join(","))
7554}
7555
7556fn convex_snapshot_index_value(value: &serde_json::Value) -> Option<&serde_json::Value> {
7557 value
7558 .get("indexes")
7559 .or_else(|| value.get("requiredIndexes"))
7560 .or_else(|| {
7561 value
7562 .get("metadata")
7563 .and_then(|metadata| metadata.get("indexes"))
7564 })
7565}
7566
7567fn convex_snapshot_declared_indexes(
7568 value: &serde_json::Value,
7569) -> Result<Option<Vec<ConvexRequiredIndex>>> {
7570 convex_snapshot_index_value(value)
7571 .map(|indexes| {
7572 serde_json::from_value::<Vec<ConvexRequiredIndex>>(indexes.clone())
7573 .context("parsing Convex snapshot index metadata")
7574 })
7575 .transpose()
7576}
7577
7578fn convex_snapshot_index_diagnostics(value: &serde_json::Value) -> Result<Vec<String>> {
7579 let required = convex_required_indexes();
7580 let Some(declared) = convex_snapshot_declared_indexes(value)? else {
7581 return Ok(vec![format!(
7582 "Convex snapshot index metadata is missing; required indexes not confirmed: {}",
7583 required
7584 .iter()
7585 .map(convex_required_index_label)
7586 .collect::<Vec<_>>()
7587 .join(", ")
7588 )]);
7589 };
7590 let declared = declared.into_iter().collect::<BTreeSet<_>>();
7591 let missing = required
7592 .iter()
7593 .filter(|index| !declared.contains(*index))
7594 .map(convex_required_index_label)
7595 .collect::<Vec<_>>();
7596 if missing.is_empty() {
7597 Ok(Vec::new())
7598 } else {
7599 Ok(vec![format!(
7600 "Convex snapshot is missing required index metadata: {}",
7601 missing.join(", ")
7602 )])
7603 }
7604}
7605
7606pub(crate) fn load_convex_projection_snapshot_value(
7607 snapshot_path: &Path,
7608) -> Result<(ConvexProjectionRows, serde_json::Value)> {
7609 let content = fs::read_to_string(snapshot_path).with_context(|| {
7610 format!(
7611 "reading Convex projection snapshot {}",
7612 snapshot_path.display()
7613 )
7614 })?;
7615 let value = serde_json::from_str::<serde_json::Value>(&content).with_context(|| {
7616 format!(
7617 "parsing Convex projection snapshot {}",
7618 snapshot_path.display()
7619 )
7620 })?;
7621 let rows = serde_json::from_value::<ConvexProjectionRows>(value.clone())
7622 .with_context(|| format!("parsing Convex projection rows {}", snapshot_path.display()))?;
7623 Ok((rows, value))
7624}
7625
7626pub(crate) fn append_sqlite_graph_doctor_checks(
7627 report: &mut GraphDbDoctorReport,
7628 root: &Path,
7629 scope: Option<&str>,
7630 graph_db: &Path,
7631) -> Option<substrate::SqliteReadOnlyConnection> {
7632 let rebuild = graph_db_rebuild_command(root, scope);
7633 let backup_rebuild = graph_db_backup_rebuild_command(root, scope, graph_db);
7634 if !graph_db.exists() {
7635 report.push_check(graph_db_doctor_check(
7636 "sqlite_graph_db_exists",
7637 vec![format!("graph.db is missing at {}", graph_db.display())],
7638 vec![rebuild],
7639 ));
7640 return None;
7641 }
7642 report.push_check(graph_db_doctor_check(
7643 "sqlite_graph_db_exists",
7644 Vec::new(),
7645 vec![rebuild.clone()],
7646 ));
7647
7648 let conn = match open_sqlite_graph_db_readonly(graph_db) {
7649 Ok(conn) => conn,
7650 Err(err) => {
7651 report.push_check(graph_db_doctor_check(
7652 "sqlite_graph_db_open",
7653 vec![err.to_string()],
7654 vec![backup_rebuild],
7655 ));
7656 return None;
7657 }
7658 };
7659 report.push_check(graph_db_doctor_check(
7660 "sqlite_graph_db_open",
7661 Vec::new(),
7662 vec![rebuild.clone()],
7663 ));
7664 if let Some(recovery) = conn.recovery() {
7665 report.push_check(GraphDbDoctorCheck {
7666 name: "sqlite_graph_db_read_recovery".to_string(),
7667 status: "recovered".to_string(),
7668 fail_closed: false,
7669 diagnostics: vec![graph_db_read_recovery_diagnostic(recovery)],
7670 repair_commands: Vec::new(),
7671 });
7672 }
7673
7674 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
7675 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
7676 report.push_check(graph_db_doctor_check(
7677 "sqlite_schema",
7678 schema_diagnostics,
7679 vec![backup_rebuild.clone()],
7680 ));
7681
7682 let metadata_diagnostics = sqlite_graph_projection_metadata_diagnostics(conn.conn(), scope)
7683 .unwrap_or_else(|err| {
7684 vec![format!(
7685 "graph projection metadata inspection failed: {err}"
7686 )]
7687 });
7688 report.push_check(graph_db_doctor_check(
7689 "sqlite_projection_metadata",
7690 metadata_diagnostics,
7691 vec![rebuild.clone()],
7692 ));
7693
7694 let duplicate_diagnostics = sqlite_graph_duplicate_diagnostics(conn.conn())
7695 .unwrap_or_else(|err| vec![format!("duplicate id inspection failed: {err}")]);
7696 report.push_check(graph_db_doctor_check(
7697 "sqlite_duplicate_ids",
7698 duplicate_diagnostics,
7699 vec![backup_rebuild.clone()],
7700 ));
7701
7702 let orphan_diagnostics = sqlite_graph_orphan_diagnostics(conn.conn())
7703 .unwrap_or_else(|err| vec![format!("orphan edge inspection failed: {err}")]);
7704 report.push_check(graph_db_doctor_check(
7705 "sqlite_orphan_edges",
7706 orphan_diagnostics,
7707 vec![rebuild.clone()],
7708 ));
7709
7710 let json_diagnostics = sqlite_graph_json_diagnostics(conn.conn())
7711 .unwrap_or_else(|err| vec![format!("graph row JSON inspection failed: {err}")]);
7712 report.push_check(graph_db_doctor_check(
7713 "sqlite_row_json",
7714 json_diagnostics,
7715 vec![backup_rebuild],
7716 ));
7717
7718 let tombstone_diagnostics =
7719 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
7720 .unwrap_or_else(|err| {
7721 vec![format!(
7722 "graph tombstone retention inspection failed: {err}"
7723 )]
7724 });
7725 report.push_check(GraphDbDoctorCheck {
7726 name: "sqlite_tombstone_retention".to_string(),
7727 status: if tombstone_diagnostics.is_empty() {
7728 "ok".to_string()
7729 } else {
7730 "warning".to_string()
7731 },
7732 fail_closed: false,
7733 diagnostics: tombstone_diagnostics,
7734 repair_commands: Vec::new(),
7735 });
7736 let compaction_check = match sqlite_graph_counts(conn.conn(), scope.unwrap_or("root")) {
7737 Ok(counts) => {
7738 let policy = graph_db_compaction_policy(root, scope, &counts, false);
7739 GraphDbDoctorCheck {
7740 name: "sqlite_compaction_policy".to_string(),
7741 status: policy.status.clone(),
7742 fail_closed: false,
7743 diagnostics: policy.proof,
7744 repair_commands: if policy.status == "recommended" {
7745 policy.recommendations
7746 } else {
7747 Vec::new()
7748 },
7749 }
7750 }
7751 Err(err) => GraphDbDoctorCheck {
7752 name: "sqlite_compaction_policy".to_string(),
7753 status: "warning".to_string(),
7754 fail_closed: false,
7755 diagnostics: vec![format!("graph compaction policy inspection failed: {err}")],
7756 repair_commands: Vec::new(),
7757 },
7758 };
7759 report.push_check(compaction_check);
7760
7761 Some(conn)
7762}
7763
7764pub(crate) fn append_convex_snapshot_doctor_checks(
7765 report: &mut GraphDbDoctorReport,
7766 root: &Path,
7767 scope: Option<&str>,
7768 local_rows: Option<&ConvexProjectionRows>,
7769 snapshot_path: Option<&Path>,
7770) {
7771 let repair = convex_refresh_command(root, scope);
7772 let Some(snapshot_path) = snapshot_path else {
7773 report.push_check(graph_db_doctor_check(
7774 "convex_snapshot_present",
7775 vec!["--backend convex-snapshot requires --convex-snapshot <rows.json>".to_string()],
7776 vec![format!(
7777 "tsift convex-sync {}{} --json > convex-rows.json",
7778 shell_quote(root.to_string_lossy().as_ref()),
7779 graph_db_scope_arg(scope)
7780 )],
7781 ));
7782 return;
7783 };
7784 report.push_check(graph_db_doctor_check(
7785 "convex_snapshot_present",
7786 Vec::new(),
7787 vec![repair.clone()],
7788 ));
7789
7790 let (snapshot, snapshot_value) = match load_convex_projection_snapshot_value(snapshot_path) {
7791 Ok(snapshot) => snapshot,
7792 Err(err) => {
7793 report.push_check(graph_db_doctor_check(
7794 "convex_snapshot_parse",
7795 vec![err.to_string()],
7796 vec![repair],
7797 ));
7798 return;
7799 }
7800 };
7801 report.push_check(graph_db_doctor_check(
7802 "convex_snapshot_parse",
7803 Vec::new(),
7804 vec![repair.clone()],
7805 ));
7806
7807 let row_diagnostics = convex_projection_row_diagnostics(&snapshot);
7808 report.push_check(graph_db_doctor_check(
7809 "convex_snapshot_rows",
7810 row_diagnostics,
7811 vec![repair.clone()],
7812 ));
7813
7814 let index_diagnostics = convex_snapshot_index_diagnostics(&snapshot_value)
7815 .unwrap_or_else(|err| vec![err.to_string()]);
7816 report.required_indexes = convex_required_indexes();
7817 report.push_check(graph_db_doctor_check(
7818 "convex_required_indexes",
7819 index_diagnostics,
7820 vec![
7821 "Add the indexes from examples/convex-graph/schema.ts, then redeploy the Convex app"
7822 .to_string(),
7823 ],
7824 ));
7825
7826 if let Some(local_rows) = local_rows {
7827 let freshness = convex_projection_freshness(local_rows, Some(&snapshot), scope);
7828 report.push_check(graph_db_doctor_check(
7829 "convex_projection_freshness",
7830 freshness.diagnostics,
7831 vec![repair],
7832 ));
7833 } else {
7834 report.push_check(graph_db_doctor_check(
7835 "convex_projection_freshness",
7836 vec![
7837 "local SQLite graph.db could not be read, so Convex freshness cannot be verified"
7838 .to_string(),
7839 ],
7840 vec![graph_db_rebuild_command(root, scope)],
7841 ));
7842 }
7843}
7844
7845fn graph_db_convex_snapshot_doctor_command(
7846 root: &Path,
7847 scope: Option<&str>,
7848 snapshot_path: &Path,
7849) -> String {
7850 format!(
7851 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} doctor --json",
7852 shell_quote(root.to_string_lossy().as_ref()),
7853 graph_db_scope_arg(scope),
7854 shell_quote(snapshot_path.to_string_lossy().as_ref())
7855 )
7856}
7857
7858fn graph_db_convex_snapshot_read_command(
7859 root: &Path,
7860 scope: Option<&str>,
7861 snapshot_path: &Path,
7862) -> String {
7863 format!(
7864 "tsift graph-db --path {}{} --backend convex-snapshot --convex-snapshot {} schema --json",
7865 shell_quote(root.to_string_lossy().as_ref()),
7866 graph_db_scope_arg(scope),
7867 shell_quote(snapshot_path.to_string_lossy().as_ref())
7868 )
7869}
7870
7871fn convex_sync_snapshot_diff_command(
7872 root: &Path,
7873 scope: Option<&str>,
7874 snapshot_path: &Path,
7875) -> String {
7876 format!(
7877 "tsift convex-sync {}{} --snapshot {} --json",
7878 shell_quote(root.to_string_lossy().as_ref()),
7879 graph_db_scope_arg(scope),
7880 shell_quote(snapshot_path.to_string_lossy().as_ref())
7881 )
7882}
7883
7884pub(crate) struct GraphDbDriftInput<'a> {
7885 root: &'a Path,
7886 scope: Option<&'a str>,
7887 graph_db: &'a Path,
7888 snapshot_path: &'a Path,
7889 local: &'a ConvexProjectionRows,
7890 snapshot: &'a ConvexProjectionRows,
7891 snapshot_value: &'a serde_json::Value,
7892 warnings: Vec<String>,
7893}
7894
7895pub(crate) fn graph_db_drift_report(input: GraphDbDriftInput<'_>) -> GraphDbDriftReport {
7896 let GraphDbDriftInput {
7897 root,
7898 scope,
7899 graph_db,
7900 snapshot_path,
7901 local,
7902 snapshot,
7903 snapshot_value,
7904 warnings,
7905 } = input;
7906 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
7907 let (node_upserts, edge_upserts, node_tombstones, edge_tombstones) =
7908 convex_rows_diff(local, Some(snapshot));
7909 let row_diagnostics = convex_projection_row_diagnostics(snapshot);
7910 let index_diagnostics = convex_snapshot_index_diagnostics(snapshot_value)
7911 .unwrap_or_else(|err| vec![format!("Convex snapshot index metadata failed: {err}")]);
7912 let local_hash = freshness.local_hash.clone();
7913 let snapshot_hash = freshness.snapshot_hash.clone();
7914 let stale_nodes = freshness.stale_nodes.clone();
7915 let stale_edges = freshness.stale_edges.clone();
7916
7917 let duplicate_failures = row_diagnostics
7918 .iter()
7919 .filter(|diagnostic| diagnostic.contains("duplicate"))
7920 .count();
7921 let orphan_failures = row_diagnostics
7922 .iter()
7923 .filter(|diagnostic| diagnostic.contains("references missing"))
7924 .count();
7925 let missing_required_indexes = index_diagnostics.len();
7926 let stale_projection_metadata =
7927 usize::from(local_hash != snapshot_hash || snapshot_hash.is_none());
7928 let hard_failures = duplicate_failures + orphan_failures + missing_required_indexes;
7929 let has_drift = freshness.fail_closed
7930 || !node_upserts.is_empty()
7931 || !edge_upserts.is_empty()
7932 || !node_tombstones.is_empty()
7933 || !edge_tombstones.is_empty();
7934 let status = if hard_failures > 0 {
7935 "fail_closed"
7936 } else if has_drift {
7937 "drift"
7938 } else {
7939 "current"
7940 }
7941 .to_string();
7942
7943 let mut diagnostics = Vec::new();
7944 diagnostics.extend(row_diagnostics);
7945 diagnostics.extend(index_diagnostics);
7946 diagnostics.extend(freshness.diagnostics.clone());
7947 if has_drift {
7948 diagnostics.push(format!(
7949 "projection diff: {} node upsert(s), {} edge upsert(s), {} node tombstone(s), {} edge tombstone(s)",
7950 node_upserts.len(),
7951 edge_upserts.len(),
7952 node_tombstones.len(),
7953 edge_tombstones.len()
7954 ));
7955 }
7956
7957 let mut next_commands = vec![graph_db_convex_snapshot_doctor_command(
7958 root,
7959 scope,
7960 snapshot_path,
7961 )];
7962 if status == "current" {
7963 next_commands.push(graph_db_convex_snapshot_read_command(
7964 root,
7965 scope,
7966 snapshot_path,
7967 ));
7968 } else {
7969 next_commands.push(convex_sync_snapshot_diff_command(
7970 root,
7971 scope,
7972 snapshot_path,
7973 ));
7974 next_commands.push(convex_refresh_command(root, scope));
7975 }
7976
7977 GraphDbDriftReport {
7978 root: root.to_string_lossy().to_string(),
7979 scope: scope.map(str::to_string),
7980 graph_db: graph_db.to_string_lossy().to_string(),
7981 convex_snapshot: snapshot_path.to_string_lossy().to_string(),
7982 status: status.clone(),
7983 graph_reads_allowed: status == "current",
7984 projection_version: GRAPH_PROJECTION_VERSION.to_string(),
7985 local_hash,
7986 snapshot_hash,
7987 summary: GraphDbDriftSummary {
7988 node_upserts: node_upserts.len(),
7989 edge_upserts: edge_upserts.len(),
7990 node_tombstones: node_tombstones.len(),
7991 edge_tombstones: edge_tombstones.len(),
7992 stale_nodes: stale_nodes.len(),
7993 stale_edges: stale_edges.len(),
7994 stale_projection_metadata,
7995 duplicate_failures,
7996 orphan_failures,
7997 missing_required_indexes,
7998 },
7999 node_upserts: node_upserts
8000 .into_iter()
8001 .map(|row| row.external_id)
8002 .collect(),
8003 edge_upserts: edge_upserts.into_iter().map(|row| row.edge_key).collect(),
8004 node_tombstones,
8005 edge_tombstones,
8006 stale_nodes,
8007 stale_edges,
8008 diagnostics,
8009 next_commands,
8010 required_indexes: convex_required_indexes(),
8011 warnings,
8012 }
8013}
8014
8015pub(crate) fn print_graph_db_drift_human(report: &GraphDbDriftReport) {
8016 println!(
8017 "graph-db drift status: {} reads_allowed: {}",
8018 report.status, report.graph_reads_allowed
8019 );
8020 println!("graph_db: {}", report.graph_db);
8021 println!("convex_snapshot: {}", report.convex_snapshot);
8022 println!(
8023 "upserts: {} node(s), {} edge(s)",
8024 report.summary.node_upserts, report.summary.edge_upserts
8025 );
8026 println!(
8027 "tombstones: {} node(s), {} edge(s)",
8028 report.summary.node_tombstones, report.summary.edge_tombstones
8029 );
8030 for diagnostic in &report.diagnostics {
8031 println!("diagnostic: {diagnostic}");
8032 }
8033 for command in &report.next_commands {
8034 println!("next: {command}");
8035 }
8036}
8037
8038pub(crate) fn print_graph_db_doctor_human(report: &GraphDbDoctorReport) {
8039 println!(
8040 "graph-db doctor backend: {} status: {}",
8041 report.backend, report.status
8042 );
8043 println!("graph_db: {}", report.graph_db);
8044 if let Some(snapshot) = &report.convex_snapshot {
8045 println!("convex_snapshot: {snapshot}");
8046 }
8047 for check in &report.checks {
8048 println!("check: {} {}", check.name, check.status);
8049 for diagnostic in &check.diagnostics {
8050 println!(" diagnostic: {diagnostic}");
8051 }
8052 }
8053 for command in &report.repair_commands {
8054 println!("repair: {command}");
8055 }
8056}
8057
8058pub(crate) fn graph_db_operator_report_from_disk(
8059 root: &Path,
8060 scope: Option<&str>,
8061 graph_db: &Path,
8062 operation: &str,
8063 refresh: Option<GraphDbRefreshSummary>,
8064 warnings: Vec<String>,
8065) -> Result<GraphDbOperatorReport> {
8066 if !graph_db.exists() {
8067 let next_commands = graph_db_operator_next_commands(root, scope, true);
8068 let counts = GraphDbOperatorCounts {
8069 nodes: 0,
8070 edges: 0,
8071 tombstones: GraphDbTombstoneCounts {
8072 nodes: 0,
8073 edges: 0,
8074 total: 0,
8075 },
8076 file_size_bytes: None,
8077 freelist_bytes: None,
8078 };
8079 return Ok(GraphDbOperatorReport {
8080 root: root.to_string_lossy().to_string(),
8081 scope: scope.map(str::to_string),
8082 graph_db: graph_db.to_string_lossy().to_string(),
8083 operation: operation.to_string(),
8084 status: "missing".to_string(),
8085 materialized: false,
8086 freshness: GraphDbFreshnessReport {
8087 status: "missing".to_string(),
8088 fail_closed: true,
8089 projection_version: None,
8090 content_hash: None,
8091 source_watermark: None,
8092 diagnostics: vec![
8093 "graph.db is missing; run graph-db refresh before trusting graph reads"
8094 .to_string(),
8095 ],
8096 },
8097 readiness: graph_effectiveness_blocked(
8098 "graph_db_missing",
8099 vec![
8100 "graph.db is missing; materialize the projection before relying on graph effectiveness".to_string(),
8101 ],
8102 next_commands.clone(),
8103 ),
8104 counts: counts.clone(),
8105 refresh,
8106 compaction: graph_db_compaction_policy(root, scope, &counts, false),
8107 recovery: None,
8108 next_commands,
8109 warnings,
8110 });
8111 }
8112
8113 let conn = open_sqlite_graph_db_readonly(graph_db)?;
8114 let recovery = conn.recovery();
8115 let mut warnings = warnings;
8116 if let Some(recovery) = recovery {
8117 warnings.push(graph_db_read_recovery_diagnostic(recovery));
8118 }
8119 let mut freshness = sqlite_graph_freshness_from_conn(conn.conn(), scope.unwrap_or("root"))?;
8120 let schema_diagnostics = sqlite_graph_schema_diagnostics(conn.conn())
8121 .unwrap_or_else(|err| vec![format!("graph.db schema inspection failed: {err}")]);
8122 if !schema_diagnostics.is_empty() {
8123 freshness.diagnostics.extend(schema_diagnostics);
8124 freshness.fail_closed = true;
8125 freshness.status = "stale".to_string();
8126 }
8127 let counts = sqlite_graph_counts(conn.conn(), scope.unwrap_or("root"))?;
8128 let semantic_row_count = sqlite_graph_semantic_node_count(conn.conn()).ok();
8129 warnings.extend(
8130 sqlite_graph_tombstone_retention_diagnostics(conn.conn(), scope.unwrap_or("root"))
8131 .unwrap_or_else(|err| {
8132 vec![format!(
8133 "graph tombstone retention inspection failed: {err}"
8134 )]
8135 }),
8136 );
8137 let status = if freshness.fail_closed {
8138 "stale"
8139 } else {
8140 "current"
8141 }
8142 .to_string();
8143
8144 Ok(GraphDbOperatorReport {
8145 root: root.to_string_lossy().to_string(),
8146 scope: scope.map(str::to_string),
8147 graph_db: graph_db.to_string_lossy().to_string(),
8148 operation: operation.to_string(),
8149 status,
8150 materialized: true,
8151 freshness,
8152 readiness: graph_db_semantic_readiness(root, scope, semantic_row_count),
8153 compaction: graph_db_compaction_policy(root, scope, &counts, false),
8154 counts,
8155 refresh,
8156 recovery,
8157 next_commands: graph_db_operator_next_commands(root, scope, false),
8158 warnings,
8159 })
8160}
8161
8162fn print_graph_db_operator_human(report: &GraphDbOperatorReport) {
8163 println!(
8164 "graph-db {} status: {} materialized: {}",
8165 report.operation, report.status, report.materialized
8166 );
8167 println!("graph_db: {}", report.graph_db);
8168 println!(
8169 "projection: version={} hash={} watermark={}",
8170 report
8171 .freshness
8172 .projection_version
8173 .as_deref()
8174 .unwrap_or("<missing>"),
8175 report
8176 .freshness
8177 .content_hash
8178 .as_deref()
8179 .unwrap_or("<missing>"),
8180 report
8181 .freshness
8182 .source_watermark
8183 .as_deref()
8184 .unwrap_or("<missing>")
8185 );
8186 println!(
8187 "rows: {} node(s), {} edge(s), {} tombstone(s)",
8188 report.counts.nodes, report.counts.edges, report.counts.tombstones.total
8189 );
8190 println!(
8191 "readiness: {} reason: {} fail_closed: {}",
8192 report.readiness.status, report.readiness.reason, report.readiness.fail_closed
8193 );
8194 if let Some(file_size) = report.counts.file_size_bytes {
8195 println!(
8196 "storage: {} byte(s), {} free byte(s)",
8197 file_size,
8198 report.counts.freelist_bytes.unwrap_or(0)
8199 );
8200 }
8201 if let Some(refresh) = &report.refresh {
8202 println!(
8203 "refresh: {} tombstoned node(s), {} tombstoned edge(s)",
8204 refresh.tombstoned_nodes, refresh.tombstoned_edges
8205 );
8206 println!(
8207 "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)",
8208 refresh.upserted_nodes,
8209 refresh.upserted_edges,
8210 refresh.upserted_properties,
8211 refresh.unchanged_nodes,
8212 refresh.unchanged_edges,
8213 refresh.unchanged_properties,
8214 refresh.deleted_properties,
8215 refresh.pruned_tombstones
8216 );
8217 }
8218 println!(
8219 "compaction: {} tombstone_scan_rows={} live_rows={}",
8220 report.compaction.status,
8221 report.compaction.tombstone_scan_rows,
8222 report.compaction.live_rows
8223 );
8224 for proof in &report.compaction.proof {
8225 println!("compaction proof: {proof}");
8226 }
8227 if let Some(recovery) = report.recovery {
8228 println!("recovery: {}", graph_db_read_recovery_diagnostic(recovery));
8229 }
8230 for diagnostic in &report.freshness.diagnostics {
8231 println!("diagnostic: {diagnostic}");
8232 }
8233 for diagnostic in &report.readiness.diagnostics {
8234 println!("readiness diagnostic: {diagnostic}");
8235 }
8236 for warning in &report.warnings {
8237 println!("warning: {warning}");
8238 }
8239 for command in &report.readiness.next_commands {
8240 println!("readiness next: {command}");
8241 }
8242 for command in &report.next_commands {
8243 println!("next: {command}");
8244 }
8245}
8246
8247pub(crate) fn print_graph_db_operator_report(
8248 report: &GraphDbOperatorReport,
8249 format: OutputFormat,
8250) -> Result<()> {
8251 if format.json_output {
8252 print_json_or_envelope(
8253 report,
8254 &format,
8255 "graph-db",
8256 &report.operation,
8257 ToolEnvelopeSummary {
8258 text: format!(
8259 "Graph DB {} status {} with {} node(s), {} edge(s), {} tombstone(s)",
8260 report.operation,
8261 report.status,
8262 report.counts.nodes,
8263 report.counts.edges,
8264 report.counts.tombstones.total
8265 ),
8266 metrics: vec![
8267 envelope_metric("operation", &report.operation),
8268 envelope_metric("status", &report.status),
8269 envelope_metric("nodes", report.counts.nodes),
8270 envelope_metric("edges", report.counts.edges),
8271 envelope_metric("tombstones", report.counts.tombstones.total),
8272 envelope_metric("compaction", &report.compaction.status),
8273 envelope_metric("readiness", &report.readiness.status),
8274 ],
8275 },
8276 false,
8277 report.next_commands.clone(),
8278 )
8279 } else {
8280 print_graph_db_operator_human(report);
8281 Ok(())
8282 }
8283}
8284
8285fn status_run_command_without_notes(run: &str) -> &str {
8286 run.split_once(" (")
8287 .map(|(command, _)| command)
8288 .unwrap_or(run)
8289}
8290
8291fn status_summarize_extract_command(run: &str) -> &str {
8292 let run = status_run_command_without_notes(run);
8293 run.split(" && ")
8294 .find(|command| command.contains("summarize --extract"))
8295 .unwrap_or(run)
8296}
8297
8298fn graph_db_status_summarize_command(report: &status::StatusReport) -> String {
8299 report
8300 .recommendations
8301 .run
8302 .as_deref()
8303 .filter(|command| command.contains("summarize --extract"))
8304 .map(status_summarize_extract_command)
8305 .unwrap_or("tsift summarize --extract .")
8306 .to_string()
8307}
8308
8309fn graph_db_semantic_rows_readiness(row_count: usize, source: &str) -> GraphEffectivenessReadiness {
8310 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8311 readiness.diagnostics.push(format!(
8312 "graph projection has {row_count} semantic_concept/semantic_entity row(s) from {source}; graph semantic rows are available"
8313 ));
8314 readiness
8315}
8316
8317fn graph_db_semantic_readiness(
8318 root: &Path,
8319 scope: Option<&str>,
8320 semantic_row_count: Option<usize>,
8321) -> GraphEffectivenessReadiness {
8322 if let Some(row_count) = semantic_row_count
8323 && row_count > 0
8324 {
8325 return graph_db_semantic_rows_readiness(row_count, "materialized graph projection");
8326 }
8327
8328 let report = match status::check_status(root) {
8329 Ok(report) => report,
8330 Err(err) => {
8331 return graph_effectiveness_blocked(
8332 "status_check_unavailable",
8333 vec![format!(
8334 "semantic readiness could not inspect summary cache after graph-db refresh: {err:#}"
8335 )],
8336 vec![graph_db_refresh_command(root, scope)],
8337 );
8338 }
8339 };
8340
8341 match &report.summaries {
8342 status::SummaryStatus::Available {
8343 cached_files,
8344 total_indexed_files,
8345 coverage_pct,
8346 ..
8347 } => {
8348 let mut readiness = graph_effectiveness_ready("semantic_rows_available");
8349 readiness.diagnostics.push(format!(
8350 "summary cache has {cached_files}/{total_indexed_files} indexed file(s) cached ({coverage_pct}% coverage); graph semantic rows are available"
8351 ));
8352 readiness
8353 }
8354 status::SummaryStatus::None { .. } => {
8355 let summarize = graph_db_status_summarize_command(&report);
8356 let index_command = report
8357 .recommendations
8358 .run
8359 .as_deref()
8360 .filter(|cmd| cmd.contains("index"))
8361 .map(str::to_string);
8362 let mut repair = Vec::new();
8363 if let Some(cmd) = index_command {
8364 repair.push(cmd);
8365 }
8366 repair.push(summarize.clone());
8367 repair.push(graph_db_refresh_command(root, scope));
8368 graph_effectiveness_blocked(
8369 "summary_cache_empty",
8370 vec![format!(
8371 "summary cache empty: graph-db materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8372 summarize,
8373 root.display(),
8374 graph_db_refresh_command(root, scope)
8375 )],
8376 repair,
8377 )
8378 }
8379 status::SummaryStatus::Unavailable => {
8380 let mut repair: Vec<String> = report.recommendations.run.clone().into_iter().collect();
8381 let summarize = "tsift summarize --extract .".to_string();
8382 repair.push(summarize);
8383 repair.push(graph_db_refresh_command(root, scope));
8384 graph_effectiveness_blocked(
8385 "summary_cache_unavailable",
8386 vec![
8387 "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(),
8388 ],
8389 repair,
8390 )
8391 }
8392 }
8393}
8394
8395pub(crate) fn graph_db_operator_status_warnings(root: &Path, scope: Option<&str>) -> Vec<String> {
8396 let report = match status::check_status(root) {
8397 Ok(report) => report,
8398 Err(err) => {
8399 return vec![format!(
8400 "status check unavailable after graph-db refresh: {err:#}"
8401 )];
8402 }
8403 };
8404
8405 let summarize_run = if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8406 Some(graph_db_status_summarize_command(&report))
8407 } else {
8408 None
8409 };
8410 let mut warnings = report.reminders;
8411 if matches!(report.summaries, status::SummaryStatus::None { .. }) {
8412 let run = summarize_run.unwrap_or_else(|| "tsift summarize --extract .".to_string());
8413 warnings.push(format!(
8414 "summary cache empty: graph-db refresh materialized code/session rows but semantic rows are unavailable; run `{}` from {} and rerun `{}` before relying on semantic evidence",
8415 run,
8416 root.display(),
8417 graph_db_refresh_command(root, scope)
8418 ));
8419 }
8420 dedupe_preserve_order(warnings)
8421}
8422
8423pub(crate) fn print_graph_db_compaction_human(report: &GraphDbCompactionReport) {
8424 println!(
8425 "graph-db compact applied:{} pruned_tombstones:{} reclaimed:{} byte(s)",
8426 report.applied, report.pruned_tombstones, report.reclaimed_bytes
8427 );
8428 println!("graph_db: {}", report.graph_db);
8429 println!(
8430 "before: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8431 report.counts_before.nodes,
8432 report.counts_before.edges,
8433 report.counts_before.tombstones.total,
8434 report.counts_before.file_size_bytes.unwrap_or(0),
8435 report.counts_before.freelist_bytes.unwrap_or(0)
8436 );
8437 println!(
8438 "after: {} node(s), {} edge(s), {} tombstone(s), file={} free={}",
8439 report.counts_after.nodes,
8440 report.counts_after.edges,
8441 report.counts_after.tombstones.total,
8442 report.counts_after.file_size_bytes.unwrap_or(0),
8443 report.counts_after.freelist_bytes.unwrap_or(0)
8444 );
8445 for proof in &report.compaction_after.proof {
8446 println!("proof: {proof}");
8447 }
8448 for warning in &report.warnings {
8449 println!("warning: {warning}");
8450 }
8451 for command in &report.next_commands {
8452 println!("next: {command}");
8453 }
8454}
8455
8456fn parse_graph_db_property_filters(raw: &[String]) -> Result<Vec<GraphDbPropertyFilter>> {
8457 raw.iter()
8458 .map(|value| {
8459 let (key, filter_value) = value
8460 .split_once('=')
8461 .with_context(|| format!("graph-db --property expects KEY=VALUE, got {value:?}"))?;
8462 let key = key.trim();
8463 let filter_value = filter_value.trim();
8464 if key.is_empty() || filter_value.is_empty() {
8465 bail!("graph-db --property expects non-empty KEY=VALUE, got {value:?}");
8466 }
8467 Ok(GraphDbPropertyFilter {
8468 key: key.to_string(),
8469 value: filter_value.to_string(),
8470 })
8471 })
8472 .collect()
8473}
8474
8475fn graph_db_query_options(
8476 cursor: Option<String>,
8477 limit: Option<usize>,
8478 property_filters: &[String],
8479) -> Result<GraphDbQueryOptions> {
8480 Ok(GraphDbQueryOptions {
8481 cursor,
8482 limit: limit.filter(|limit| *limit > 0),
8483 property_filters: parse_graph_db_property_filters(property_filters)?,
8484 })
8485}
8486
8487fn graph_db_query_options_for_store(options: &GraphDbQueryOptions) -> GraphQueryOptions {
8488 GraphQueryOptions {
8489 cursor: options.cursor.clone(),
8490 limit: options.limit,
8491 property_filters: options
8492 .property_filters
8493 .iter()
8494 .map(|filter| GraphPropertyFilter {
8495 key: filter.key.clone(),
8496 value: filter.value.clone(),
8497 })
8498 .collect(),
8499 }
8500}
8501
8502fn graph_db_page_report_from_store(
8503 page: GraphQueryPage,
8504 property_filters: Vec<GraphDbPropertyFilter>,
8505) -> GraphDbPageReport {
8506 GraphDbPageReport {
8507 cursor: page.cursor,
8508 limit: page.limit,
8509 next_cursor: page.next_cursor,
8510 returned_nodes: page.returned_nodes,
8511 returned_edges: page.returned_edges,
8512 truncated: page.truncated,
8513 property_filters,
8514 diagnostics: page.diagnostics,
8515 }
8516}
8517
8518fn graph_db_neighborhood_ranking_gate(
8519 ranked_neighbor_cap: usize,
8520) -> GraphDbNeighborhoodRankingGate {
8521 GraphDbNeighborhoodRankingGate {
8522 status: "held_default_order_unchanged".to_string(),
8523 ranked_output_default: false,
8524 default_order: "stable_node_id".to_string(),
8525 default_change_gate: "community_search_quality_metrics".to_string(),
8526 required_workloads: metric_digest::COMMUNITY_SEARCH_WORKLOADS
8527 .iter()
8528 .map(|workload| (*workload).to_string())
8529 .collect(),
8530 required_metrics: metric_digest::COMMUNITY_SEARCH_REQUIRED_METRICS
8531 .iter()
8532 .map(|metric| (*metric).to_string())
8533 .collect(),
8534 max_duration_regression_percent: metric_digest::COMMUNITY_MAX_DURATION_REGRESSION_PERCENT,
8535 min_handle_coverage_pct: metric_digest::COMMUNITY_MIN_HANDLE_COVERAGE_PCT,
8536 min_duplicate_name_precision: metric_digest::COMMUNITY_MIN_DUPLICATE_NAME_PRECISION,
8537 min_top_community_stability: metric_digest::COMMUNITY_MIN_TOP_COMMUNITY_STABILITY,
8538 diagnostics: vec![
8539 "ranked_neighbors is additive; neighborhood nodes remain ordered by stable node id for cursor pagination".to_string(),
8540 format!(
8541 "ranked_neighbors is score-capped at {ranked_neighbor_cap} entries so previews stay bounded while cursor pagination remains exhaustive"
8542 ),
8543 "changing the default neighborhood order requires the community-search gate to pass for every required workload".to_string(),
8544 ],
8545 }
8546}
8547
8548fn graph_db_ranked_neighbor_cap(limit: Option<usize>) -> usize {
8549 match limit {
8550 Some(0) | None => GRAPH_DB_RANKED_NEIGHBOR_CAP,
8551 Some(limit) => limit.clamp(1, GRAPH_DB_RANKED_NEIGHBOR_CAP),
8552 }
8553}
8554
8555fn graph_db_ranked_neighbors(
8556 center_id: &str,
8557 nodes: &[SubstrateGraphNode],
8558 edges: &[SubstrateGraphEdge],
8559 cap: usize,
8560) -> Vec<GraphDbRankedNeighbor> {
8561 resolution::ranked_neighbors_capped(center_id, nodes, edges, cap)
8562}
8563
8564fn graph_db_ranked_neighborhood_comparison<S: GraphStore>(
8565 center_id: &str,
8566 depth: usize,
8567 edge_kind: Option<&str>,
8568 limit: Option<usize>,
8569 unranked_nodes: &[SubstrateGraphNode],
8570 unranked_edges: &[SubstrateGraphEdge],
8571 store: &S,
8572) -> Result<Option<GraphDbRankedNeighborhoodComparison>> {
8573 use std::time::Instant;
8574 let max_nodes = match limit {
8575 Some(0) | None => 200,
8576 Some(n) => n.clamp(10, 500),
8577 };
8578 let mut options = RankedNeighborhoodOptions::new(depth, max_nodes)
8579 .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
8580 if let Some(kind) = edge_kind {
8581 options = options.with_edge_kind(kind);
8582 }
8583 let start = Instant::now();
8584 let result = store.ranked_neighborhood(center_id, &options)?;
8585 let latency = start.elapsed().as_micros();
8586 let Some(ranked) = result else {
8587 return Ok(None);
8588 };
8589 let unranked_ids: BTreeSet<_> = unranked_nodes.iter().map(|n| n.id.as_str()).collect();
8590 let ranked_ids: BTreeSet<_> = ranked.nodes.iter().map(|n| n.id.as_str()).collect();
8591 let overlap_count = ranked_ids.intersection(&unranked_ids).count();
8592 let overlap_pct = if unranked_ids.is_empty() || ranked_ids.is_empty() {
8593 0.0
8594 } else {
8595 (overlap_count as f64 / unranked_ids.len().max(ranked_ids.len()) as f64) * 100.0
8596 };
8597 let count_duplicates = |nodes: &[SubstrateGraphNode]| -> usize {
8598 let mut name_count = BTreeMap::<&str, usize>::new();
8599 for n in nodes {
8600 *name_count.entry(&n.label).or_default() += 1;
8601 }
8602 name_count.values().filter(|&&c| c > 1).count()
8603 };
8604 let count_handle_coverage = |nodes: &[SubstrateGraphNode]| -> f64 {
8605 if nodes.is_empty() {
8606 return 100.0;
8607 }
8608 let with_handle = nodes
8609 .iter()
8610 .filter(|n| n.properties.contains_key("handle") || n.properties.contains_key("ref_id"))
8611 .count();
8612 (with_handle as f64 / nodes.len() as f64) * 100.0
8613 };
8614 let useful_density = |nodes: &[SubstrateGraphNode], edges: &[SubstrateGraphEdge]| -> f64 {
8615 if nodes.is_empty() {
8616 return 0.0;
8617 }
8618 let semantic_kinds = [
8619 "semantic_concept",
8620 "semantic_entity",
8621 "symbol",
8622 "file",
8623 "source_handle",
8624 ];
8625 let useful = nodes
8626 .iter()
8627 .filter(|n| semantic_kinds.contains(&n.kind.as_str()))
8628 .count();
8629 let edge_diversity = edges.iter().map(|e| &e.kind).collect::<BTreeSet<_>>().len();
8630 let kind_diversity = nodes.iter().map(|n| &n.kind).collect::<BTreeSet<_>>().len();
8631 (useful as f64 * 0.5 + kind_diversity as f64 * 0.3 + edge_diversity as f64 * 0.2)
8632 / nodes.len() as f64
8633 };
8634 let community_truncation_summary = if ranked.pruned_count > 0 && !ranked.edges.is_empty() {
8635 let edge_pairs: Vec<(String, String)> = ranked
8636 .edges
8637 .iter()
8638 .map(|e| (e.from_id.clone(), e.to_id.clone()))
8639 .collect();
8640 let cr = tsift_graph::detect_communities(&edge_pairs);
8641 let kept_labels: BTreeSet<&str> = ranked.nodes.iter().map(|n| n.label.as_str()).collect();
8642 let mut fully_kept = 0usize;
8643 let mut partially_pruned = 0usize;
8644 let mut fully_pruned = 0usize;
8645 let mut pruned_kinds = BTreeSet::new();
8646 let mut pruned_labels = Vec::new();
8647 for comm in &cr.communities {
8648 let kept_in_comm: Vec<&str> = comm
8649 .members
8650 .iter()
8651 .filter(|m| kept_labels.contains(m.name.as_str()))
8652 .map(|m| m.name.as_str())
8653 .collect();
8654 if kept_in_comm.len() == comm.members.len() {
8655 fully_kept += 1;
8656 } else if kept_in_comm.is_empty() {
8657 fully_pruned += 1;
8658 for m in &comm.members {
8659 if let Some(n) = ranked.nodes.iter().find(|n| n.label == m.name) {
8660 pruned_kinds.insert(n.kind.clone());
8661 }
8662 pruned_labels.push(m.name.clone());
8663 }
8664 } else {
8665 partially_pruned += 1;
8666 }
8667 }
8668 pruned_labels.truncate(5);
8669 Some(CommunityTruncationSummary {
8670 total_communities: cr.communities.len(),
8671 fully_kept,
8672 partially_pruned,
8673 fully_pruned,
8674 pruned_community_kinds: pruned_kinds.into_iter().collect(),
8675 pruned_community_top_labels: pruned_labels,
8676 })
8677 } else {
8678 None
8679 };
8680 Ok(Some(GraphDbRankedNeighborhoodComparison {
8681 traversal_nodes: ranked.nodes.len(),
8682 traversal_edges: ranked.edges.len(),
8683 pruned_count: ranked.pruned_count,
8684 total_discovered: ranked.total_discovered,
8685 latency_micros: latency,
8686 overlap_with_unranked_pct: (overlap_pct * 100.0).round() / 100.0,
8687 useful_hit_density_ranked: (useful_density(&ranked.nodes, &ranked.edges) * 1000.0).round()
8688 / 1000.0,
8689 useful_hit_density_unranked: (useful_density(unranked_nodes, unranked_edges) * 1000.0)
8690 .round()
8691 / 1000.0,
8692 duplicate_name_count_ranked: count_duplicates(&ranked.nodes),
8693 duplicate_name_count_unranked: count_duplicates(unranked_nodes),
8694 handle_coverage_ranked_pct: (count_handle_coverage(&ranked.nodes) * 100.0).round() / 100.0,
8695 handle_coverage_unranked_pct: (count_handle_coverage(unranked_nodes) * 100.0).round()
8696 / 100.0,
8697 community_truncation_summary,
8698 diagnostics: vec![
8699 format!(
8700 "ranked_neighborhood traversed {} node(s), {} edge(s) with {} pruned of {} discovered in {}µs",
8701 ranked.nodes.len(),
8702 ranked.edges.len(),
8703 ranked.pruned_count,
8704 ranked.total_discovered,
8705 latency
8706 ),
8707 format!(
8708 "overlap with unranked BFS: {:.1}% ({} shared of {} unranked, {} ranked)",
8709 overlap_pct,
8710 overlap_count,
8711 unranked_ids.len(),
8712 ranked_ids.len()
8713 ),
8714 "comparison is diagnostic; promotion requires community-search quality gate to pass for every required workload".to_string(),
8715 ],
8716 }))
8717}
8718
8719struct GraphDbBudgetedSubgraph {
8720 nodes: Vec<SubstrateGraphNode>,
8721 edges: Vec<SubstrateGraphEdge>,
8722 report: GraphDbOutputBudgetReport,
8723 truncated: bool,
8724 next_cursor: Option<String>,
8725}
8726
8727const GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP: usize = 6_000;
8728const GRAPH_DB_OUTPUT_MIN_TOKEN_CAP: usize = 1_200;
8729const GRAPH_DB_OUTPUT_MAX_TOKEN_CAP: usize = 12_000;
8730
8731fn graph_db_output_token_cap(limit: Option<usize>) -> usize {
8732 match limit {
8733 Some(0) | None => GRAPH_DB_OUTPUT_DEFAULT_TOKEN_CAP,
8734 Some(limit) => limit
8735 .saturating_mul(320)
8736 .clamp(GRAPH_DB_OUTPUT_MIN_TOKEN_CAP, GRAPH_DB_OUTPUT_MAX_TOKEN_CAP),
8737 }
8738}
8739
8740fn graph_db_node_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8741 if matches!(limit, Some(0) | None) {
8742 return match kind {
8743 "source_handle" => 10,
8744 "worker_context" | "worker_result" => 8,
8745 "semantic_concept" | "semantic_entity" => 10,
8746 "file" | "symbol" | "route" => 12,
8747 _ => 8,
8748 };
8749 }
8750 let base = limit.unwrap_or(0).max(1);
8751 match kind {
8752 "source_handle" => base.saturating_add(4),
8753 "worker_context" | "worker_result" => base.saturating_add(2),
8754 "semantic_concept" | "semantic_entity" => base.saturating_add(4),
8755 "file" | "symbol" | "route" => base.saturating_add(4),
8756 _ => base.saturating_add(1),
8757 }
8758}
8759
8760fn graph_db_edge_kind_quota(kind: &str, limit: Option<usize>) -> usize {
8761 if matches!(limit, Some(0) | None) {
8762 return match kind {
8763 "mentions" | "mentions_concept" | "mentions_entity" => 24,
8764 "semantic_relation" | "calls" | "defines" => 20,
8765 _ => 16,
8766 };
8767 }
8768 let base = limit.unwrap_or(0).max(1);
8769 match kind {
8770 "mentions" | "mentions_concept" | "mentions_entity" => base.saturating_mul(3),
8771 "semantic_relation" | "calls" | "defines" => base.saturating_mul(2),
8772 _ => base.saturating_add(2),
8773 }
8774}
8775
8776fn graph_db_estimated_tokens<T: Serialize>(value: &T) -> usize {
8777 serde_json::to_vec(value)
8778 .map(|bytes| bytes.len().div_ceil(4).max(1))
8779 .unwrap_or(1)
8780}
8781
8782fn graph_db_node_search_text(node: &SubstrateGraphNode) -> String {
8783 let mut parts = vec![node.kind.clone(), node.label.clone()];
8784 for key in [
8785 "detail",
8786 "description",
8787 "source_ref",
8788 "path",
8789 "source_file",
8790 "source_symbol",
8791 "text_preview",
8792 ] {
8793 if let Some(value) = node.properties.get(key) {
8794 parts.push(value.clone());
8795 }
8796 }
8797 parts.join(" ")
8798}
8799
8800fn graph_db_semantic_scores_for_query(
8801 query: Option<&str>,
8802 nodes: &[SubstrateGraphNode],
8803) -> BTreeMap<String, f64> {
8804 let Some(query) = query.filter(|value| !value.trim().is_empty()) else {
8805 return BTreeMap::new();
8806 };
8807 let query_embedding = semantic_embedding(query);
8808 nodes
8809 .iter()
8810 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
8811 .filter_map(|node| {
8812 let embedding = node
8813 .properties
8814 .get("embedding")
8815 .and_then(|value| parse_semantic_embedding_property(value))?;
8816 Some((
8817 node.id.clone(),
8818 semantic_cosine(&query_embedding, &embedding),
8819 ))
8820 })
8821 .collect()
8822}
8823
8824fn graph_db_depth_by_id(
8825 origin_ids: &[String],
8826 edges: &[SubstrateGraphEdge],
8827) -> BTreeMap<String, usize> {
8828 let mut adjacency = BTreeMap::<String, Vec<String>>::new();
8829 for edge in edges {
8830 adjacency
8831 .entry(edge.from_id.clone())
8832 .or_default()
8833 .push(edge.to_id.clone());
8834 adjacency
8835 .entry(edge.to_id.clone())
8836 .or_default()
8837 .push(edge.from_id.clone());
8838 }
8839
8840 let mut depth_by_id = BTreeMap::<String, usize>::new();
8841 let mut queue = VecDeque::<String>::new();
8842 for origin in origin_ids {
8843 if depth_by_id.insert(origin.clone(), 0).is_none() {
8844 queue.push_back(origin.clone());
8845 }
8846 }
8847 while let Some(current) = queue.pop_front() {
8848 let depth = depth_by_id.get(¤t).copied().unwrap_or(0);
8849 for next in adjacency.get(¤t).into_iter().flatten() {
8850 if depth_by_id.contains_key(next) {
8851 continue;
8852 }
8853 depth_by_id.insert(next.clone(), depth.saturating_add(1));
8854 queue.push_back(next.clone());
8855 }
8856 }
8857 depth_by_id
8858}
8859
8860fn graph_db_source_covered_ids(
8861 nodes: &[SubstrateGraphNode],
8862 edges: &[SubstrateGraphEdge],
8863) -> BTreeSet<String> {
8864 let source_ids = nodes
8865 .iter()
8866 .filter(|node| node.kind == "source_handle")
8867 .map(|node| node.id.as_str())
8868 .collect::<BTreeSet<_>>();
8869 let mut covered = source_ids
8870 .iter()
8871 .map(|id| (*id).to_string())
8872 .collect::<BTreeSet<_>>();
8873 for edge in edges {
8874 if source_ids.contains(edge.from_id.as_str()) {
8875 covered.insert(edge.to_id.clone());
8876 }
8877 if source_ids.contains(edge.to_id.as_str()) {
8878 covered.insert(edge.from_id.clone());
8879 }
8880 }
8881 covered
8882}
8883
8884fn graph_db_recency_score(node: &SubstrateGraphNode) -> i64 {
8885 for key in [
8886 "observed_at_unix",
8887 "completed_at_unix",
8888 "created_at_unix",
8889 "started_at_unix",
8890 ] {
8891 if let Some(value) = node.properties.get(key)
8892 && let Ok(epoch) = value.parse::<i64>()
8893 {
8894 return epoch.div_euclid(86_400).clamp(0, 40_000);
8895 }
8896 }
8897 0
8898}
8899
8900fn graph_db_node_kind_score(kind: &str) -> i64 {
8901 match kind {
8902 "source_handle" => 180,
8903 "worker_context" => 170,
8904 "worker_result" => 160,
8905 "semantic_concept" | "semantic_entity" => 150,
8906 "backlog" | "job_packet" => 130,
8907 "symbol" => 120,
8908 "file" => 110,
8909 "route" => 105,
8910 "session" => 90,
8911 _ => 40,
8912 }
8913}
8914
8915fn graph_db_edge_kind_score(kind: &str) -> i64 {
8916 match kind {
8917 "mentions_concept" | "mentions_entity" => 180,
8918 "semantic_relation" => 170,
8919 "mentions" => 165,
8920 "requests_context" | "scopes_context" | "scopes_source" => 155,
8921 "explains_result" => 150,
8922 "calls" => 145,
8923 "defines" | "handled_by" | "defines_route" => 130,
8924 "contains" | "targets" => 120,
8925 "records_memory_source" | "has_vector_handle" => 115,
8926 _ => 40,
8927 }
8928}
8929
8930fn graph_db_node_usefulness_score(
8931 node: &SubstrateGraphNode,
8932 depth_by_id: &BTreeMap<String, usize>,
8933 semantic_scores: &BTreeMap<String, f64>,
8934 source_covered_ids: &BTreeSet<String>,
8935 origin_ids: &[String],
8936) -> i64 {
8937 if origin_ids.iter().any(|origin| origin == &node.id) {
8938 return 1_000_000;
8939 }
8940 let semantic = semantic_scores
8941 .get(&node.id)
8942 .map(|score| (score.max(0.0) * 1_000.0) as i64)
8943 .unwrap_or(0);
8944 let depth_penalty = depth_by_id
8945 .get(&node.id)
8946 .map(|depth| (*depth as i64).saturating_mul(55))
8947 .unwrap_or(180);
8948 let source_coverage = if source_covered_ids.contains(&node.id)
8949 || node.properties.contains_key("source_ref")
8950 || node.properties.contains_key("path")
8951 {
8952 120
8953 } else {
8954 0
8955 };
8956 graph_db_node_kind_score(&node.kind)
8957 + semantic
8958 + source_coverage
8959 + graph_db_recency_score(node).min(80)
8960 - depth_penalty
8961}
8962
8963fn graph_db_edge_usefulness_score(
8964 edge: &SubstrateGraphEdge,
8965 node_score_by_id: &BTreeMap<String, i64>,
8966 depth_by_id: &BTreeMap<String, usize>,
8967) -> i64 {
8968 let endpoint_score = node_score_by_id
8969 .get(&edge.from_id)
8970 .copied()
8971 .unwrap_or_default()
8972 .max(
8973 node_score_by_id
8974 .get(&edge.to_id)
8975 .copied()
8976 .unwrap_or_default(),
8977 );
8978 let depth_penalty = depth_by_id
8979 .get(&edge.from_id)
8980 .into_iter()
8981 .chain(depth_by_id.get(&edge.to_id))
8982 .min()
8983 .map(|depth| (*depth as i64).saturating_mul(35))
8984 .unwrap_or(140);
8985 graph_db_edge_kind_score(&edge.kind) + (endpoint_score / 8) - depth_penalty
8986}
8987
8988fn graph_db_push_drop(
8989 drops: &mut BTreeMap<(String, String, String), usize>,
8990 item: &str,
8991 kind: &str,
8992 reason: &str,
8993) {
8994 *drops
8995 .entry((item.to_string(), kind.to_string(), reason.to_string()))
8996 .or_default() += 1;
8997}
8998
8999fn graph_db_budget_drop_report(
9000 drops: BTreeMap<(String, String, String), usize>,
9001) -> Vec<GraphDbDroppedByBudget> {
9002 drops
9003 .into_iter()
9004 .map(|((item, kind, reason), dropped)| GraphDbDroppedByBudget {
9005 item,
9006 kind,
9007 reason,
9008 dropped,
9009 })
9010 .collect()
9011}
9012
9013fn graph_db_apply_output_budget(
9014 origin_ids: &[String],
9015 semantic_scores: &BTreeMap<String, f64>,
9016 nodes: Vec<SubstrateGraphNode>,
9017 edges: Vec<SubstrateGraphEdge>,
9018 limit: Option<usize>,
9019) -> GraphDbBudgetedSubgraph {
9020 graph_db_apply_output_budget_with_depths_and_cursor(
9021 origin_ids,
9022 semantic_scores,
9023 nodes,
9024 edges,
9025 limit,
9026 None,
9027 None,
9028 )
9029}
9030
9031fn graph_db_apply_output_budget_with_depths_and_cursor(
9032 origin_ids: &[String],
9033 semantic_scores: &BTreeMap<String, f64>,
9034 nodes: Vec<SubstrateGraphNode>,
9035 edges: Vec<SubstrateGraphEdge>,
9036 limit: Option<usize>,
9037 depth_overrides: Option<&BTreeMap<String, usize>>,
9038 cursor: Option<&str>,
9039) -> GraphDbBudgetedSubgraph {
9040 let max_tokens = graph_db_output_token_cap(limit);
9041 let candidate_nodes = nodes.len();
9042 let candidate_edges = edges.len();
9043 let mut depth_by_id = graph_db_depth_by_id(origin_ids, &edges);
9044 if let Some(depth_overrides) = depth_overrides {
9045 for (id, depth) in depth_overrides {
9046 depth_by_id
9047 .entry(id.clone())
9048 .and_modify(|current| *current = (*current).min(*depth))
9049 .or_insert(*depth);
9050 }
9051 }
9052 let source_covered_ids = graph_db_source_covered_ids(&nodes, &edges);
9053 let node_score_by_id = nodes
9054 .iter()
9055 .map(|node| {
9056 (
9057 node.id.clone(),
9058 graph_db_node_usefulness_score(
9059 node,
9060 &depth_by_id,
9061 semantic_scores,
9062 &source_covered_ids,
9063 origin_ids,
9064 ),
9065 )
9066 })
9067 .collect::<BTreeMap<_, _>>();
9068
9069 let mut node_candidates = nodes.iter().collect::<Vec<_>>();
9070 node_candidates.sort_by(|left, right| {
9071 node_score_by_id
9072 .get(&right.id)
9073 .cmp(&node_score_by_id.get(&left.id))
9074 .then_with(|| left.kind.cmp(&right.kind))
9075 .then_with(|| left.label.cmp(&right.label))
9076 .then_with(|| left.id.cmp(&right.id))
9077 });
9078
9079 let cursor_skip = if let Some(cursor) = cursor {
9080 node_candidates
9081 .iter()
9082 .position(|node| node.id == cursor)
9083 .map(|pos| pos.saturating_add(1))
9084 .unwrap_or(0)
9085 } else {
9086 0
9087 };
9088 if cursor_skip > 0 {
9089 node_candidates = node_candidates.into_iter().skip(cursor_skip).collect();
9090 }
9091
9092 let mut selected_node_ids = BTreeSet::new();
9093 let mut selected_node_counts = BTreeMap::<String, usize>::new();
9094 let mut estimated_tokens = 0usize;
9095 let mut drops = BTreeMap::<(String, String, String), usize>::new();
9096 for node in &node_candidates {
9097 let kind_count = selected_node_counts
9098 .get(&node.kind)
9099 .copied()
9100 .unwrap_or_default();
9101 if !origin_ids.iter().any(|origin| origin == &node.id)
9102 && kind_count >= graph_db_node_kind_quota(&node.kind, limit)
9103 {
9104 graph_db_push_drop(&mut drops, "node", &node.kind, "per_kind_quota");
9105 continue;
9106 }
9107 let tokens = graph_db_estimated_tokens(node);
9108 if !origin_ids.iter().any(|origin| origin == &node.id)
9109 && estimated_tokens.saturating_add(tokens) > max_tokens
9110 {
9111 graph_db_push_drop(&mut drops, "node", &node.kind, "estimated_token_cap");
9112 continue;
9113 }
9114 selected_node_ids.insert(node.id.clone());
9115 *selected_node_counts.entry(node.kind.clone()).or_default() += 1;
9116 estimated_tokens = estimated_tokens.saturating_add(tokens);
9117 }
9118
9119 let has_remaining_candidates = node_candidates
9120 .iter()
9121 .any(|node| !selected_node_ids.contains(&node.id));
9122
9123 let mut selected_nodes = nodes
9124 .into_iter()
9125 .filter(|node| selected_node_ids.contains(&node.id))
9126 .collect::<Vec<_>>();
9127
9128 let mut edge_candidates = edges
9129 .iter()
9130 .filter(|edge| {
9131 selected_node_ids.contains(&edge.from_id) && selected_node_ids.contains(&edge.to_id)
9132 })
9133 .collect::<Vec<_>>();
9134 let edge_score_by_key = edge_candidates
9135 .iter()
9136 .map(|edge| {
9137 (
9138 graph_db_edge_key(edge),
9139 graph_db_edge_usefulness_score(edge, &node_score_by_id, &depth_by_id),
9140 )
9141 })
9142 .collect::<BTreeMap<_, _>>();
9143 edge_candidates.sort_by(|left, right| {
9144 edge_score_by_key
9145 .get(&graph_db_edge_key(right))
9146 .cmp(&edge_score_by_key.get(&graph_db_edge_key(left)))
9147 .then_with(|| left.kind.cmp(&right.kind))
9148 .then_with(|| left.from_id.cmp(&right.from_id))
9149 .then_with(|| left.to_id.cmp(&right.to_id))
9150 });
9151
9152 let endpoint_dropped_edges = edges
9153 .iter()
9154 .filter(|edge| {
9155 !selected_node_ids.contains(&edge.from_id) || !selected_node_ids.contains(&edge.to_id)
9156 })
9157 .count();
9158 if endpoint_dropped_edges > 0 {
9159 drops.insert(
9160 (
9161 "edge".to_string(),
9162 "*".to_string(),
9163 "endpoint_node_dropped".to_string(),
9164 ),
9165 endpoint_dropped_edges,
9166 );
9167 }
9168
9169 let mut selected_edge_ids = BTreeSet::new();
9170 let mut selected_edge_counts = BTreeMap::<String, usize>::new();
9171 for edge in edge_candidates {
9172 let kind_count = selected_edge_counts
9173 .get(&edge.kind)
9174 .copied()
9175 .unwrap_or_default();
9176 if kind_count >= graph_db_edge_kind_quota(&edge.kind, limit) {
9177 graph_db_push_drop(&mut drops, "edge", &edge.kind, "per_kind_quota");
9178 continue;
9179 }
9180 let tokens = graph_db_estimated_tokens(edge);
9181 if estimated_tokens.saturating_add(tokens) > max_tokens {
9182 graph_db_push_drop(&mut drops, "edge", &edge.kind, "estimated_token_cap");
9183 continue;
9184 }
9185 selected_edge_ids.insert(graph_db_edge_key(edge));
9186 *selected_edge_counts.entry(edge.kind.clone()).or_default() += 1;
9187 estimated_tokens = estimated_tokens.saturating_add(tokens);
9188 }
9189
9190 let selected_edges = edges
9191 .into_iter()
9192 .filter(|edge| selected_edge_ids.contains(&graph_db_edge_key(edge)))
9193 .collect::<Vec<_>>();
9194 let dropped_by_budget = graph_db_budget_drop_report(drops);
9195 let truncated = has_remaining_candidates;
9196 let next_cursor = if truncated {
9197 selected_nodes.last().map(|node| node.id.clone())
9198 } else {
9199 None
9200 };
9201 let mut diagnostics = vec![
9202 "budget ranking signals: semantic_match, edge_kind, depth, recency, source_handle_coverage"
9203 .to_string(),
9204 format!(
9205 "selected {} of {} candidate node(s) and {} of {} candidate edge(s) within estimated token cap {}",
9206 selected_nodes.len(),
9207 candidate_nodes,
9208 selected_edges.len(),
9209 candidate_edges,
9210 max_tokens
9211 ),
9212 ];
9213 if cursor.is_some() {
9214 diagnostics.push(format!(
9215 "cursor skipped {} previously returned candidate(s)",
9216 cursor_skip
9217 ));
9218 }
9219 if next_cursor.is_some() {
9220 diagnostics.push(
9221 "result was truncated; pass next_cursor as --cursor for the next page".to_string(),
9222 );
9223 }
9224 selected_nodes.shrink_to_fit();
9225
9226 GraphDbBudgetedSubgraph {
9227 nodes: selected_nodes,
9228 edges: selected_edges,
9229 report: GraphDbOutputBudgetReport {
9230 max_tokens,
9231 estimated_tokens,
9232 selected_nodes: selected_node_ids.len(),
9233 selected_edges: selected_edge_ids.len(),
9234 candidate_nodes,
9235 candidate_edges,
9236 dropped_by_budget,
9237 diagnostics,
9238 },
9239 truncated,
9240 next_cursor,
9241 }
9242}
9243
9244fn graph_db_edge_key(edge: &SubstrateGraphEdge) -> String {
9245 if edge.id.is_empty() {
9246 substrate::ConvexEdgeRow::stable_key(&edge.from_id, &edge.to_id, &edge.kind)
9247 } else {
9248 edge.id.clone()
9249 }
9250}
9251
9252fn graph_db_schema() -> GraphDbSchema {
9253 GraphDbSchema {
9254 contract_versions: vec![
9255 GraphDbSchemaContract {
9256 name: "graph_db_evidence",
9257 version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9258 description: "graph-db evidence JSON packet including packet_id, projection hash, worker context, source handles, worker results, semantic rows, replay commands, and repair commands",
9259 },
9260 GraphDbSchemaContract {
9261 name: "worker_prompt_packet",
9262 version: WORKER_PROMPT_PACKET_CONTRACT_VERSION,
9263 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",
9264 },
9265 GraphDbSchemaContract {
9266 name: "conflict_matrix",
9267 version: CONFLICT_MATRIX_CONTRACT_VERSION,
9268 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",
9269 },
9270 GraphDbSchemaContract {
9271 name: "context_pack_graph_orchestration",
9272 version: CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION,
9273 description: "context-pack graph orchestration summary with projection freshness, evidence packet ids, ownership blocks, and follow-up graph commands",
9274 },
9275 GraphDbSchemaContract {
9276 name: "session_review_follow_up",
9277 version: SESSION_REVIEW_FOLLOW_UP_CONTRACT_VERSION,
9278 description: "session-review next-context follow-up command contract for resumable digest/context-pack commands",
9279 },
9280 GraphDbSchemaContract {
9281 name: "dispatch_trace",
9282 version: DISPATCH_TRACE_CONTRACT_VERSION,
9283 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",
9284 },
9285 GraphDbSchemaContract {
9286 name: "dependency_dag",
9287 version: DEPENDENCY_DAG_CONTRACT_VERSION,
9288 description: "topological planning DAG for agent-doc backlog targets with replayable dependency edges, topo batches, and cycle diagnostics",
9289 },
9290 ],
9291 node_fields: vec![
9292 GraphDbSchemaField {
9293 name: "id",
9294 value_type: "string",
9295 description: "Stable provider-neutral node id",
9296 },
9297 GraphDbSchemaField {
9298 name: "kind",
9299 value_type: "string",
9300 description: "Application-defined node family such as file, symbol, or backlog",
9301 },
9302 GraphDbSchemaField {
9303 name: "label",
9304 value_type: "string",
9305 description: "Human-readable label",
9306 },
9307 GraphDbSchemaField {
9308 name: "properties",
9309 value_type: "object<string,string>",
9310 description: "Adapter-specific string properties",
9311 },
9312 GraphDbSchemaField {
9313 name: "provenance",
9314 value_type: "array",
9315 description: "Source system and source reference metadata",
9316 },
9317 GraphDbSchemaField {
9318 name: "freshness",
9319 value_type: "object|null",
9320 description: "Optional content hash and observed timestamp",
9321 },
9322 ],
9323 edge_fields: vec![
9324 GraphDbSchemaField {
9325 name: "id",
9326 value_type: "string",
9327 description: "Stable provider-neutral edge id derived from from_id, kind, and to_id",
9328 },
9329 GraphDbSchemaField {
9330 name: "from_id",
9331 value_type: "string",
9332 description: "Source node id",
9333 },
9334 GraphDbSchemaField {
9335 name: "to_id",
9336 value_type: "string",
9337 description: "Target node id",
9338 },
9339 GraphDbSchemaField {
9340 name: "kind",
9341 value_type: "string",
9342 description: "Application-defined edge relation",
9343 },
9344 GraphDbSchemaField {
9345 name: "properties",
9346 value_type: "object<string,string>",
9347 description: "Adapter-specific string properties",
9348 },
9349 GraphDbSchemaField {
9350 name: "provenance",
9351 value_type: "array",
9352 description: "Source system and source reference metadata",
9353 },
9354 GraphDbSchemaField {
9355 name: "freshness",
9356 value_type: "object|null",
9357 description: "Optional content hash and observed timestamp",
9358 },
9359 ],
9360 operations: vec![
9361 GraphDbSchemaOperation {
9362 command: "refresh",
9363 description: "Materialize .tsift/graph.db explicitly with delta upserts/deletes, row hash watermarks, tombstone pruning, projection metadata, row counts, and operator next commands",
9364 },
9365 GraphDbSchemaOperation {
9366 command: "status",
9367 description: "Inspect .tsift/graph.db freshness, projection metadata, row counts, tombstone counts, file-size impact, and operator next commands without refreshing",
9368 },
9369 GraphDbSchemaOperation {
9370 command: "doctor",
9371 description: "Validate graph.db or Convex snapshot health and return fail-closed repair diagnostics plus non-fatal SQLite tombstone-retention warnings",
9372 },
9373 GraphDbSchemaOperation {
9374 command: "drift",
9375 description: "Compare local SQLite projection rows with a Convex snapshot and return upsert, tombstone, metadata, duplicate, orphan, and next-command diagnostics",
9376 },
9377 GraphDbSchemaOperation {
9378 command: "compact [--apply] [--prune-tombstones --confirmed-convex-reconciled]",
9379 description: "Return or apply the post-reconciliation SQLite graph compaction policy, including WAL checkpoint/VACUUM proof and guarded tombstone pruning",
9380 },
9381 GraphDbSchemaOperation {
9382 command: "snapshot-export <output.db.gz> [--force]",
9383 description: "Export the current SQLite graph.db as a gzip-compressed shareable artifact only after freshness, doctor, WAL, and sidecar checks pass",
9384 },
9385 GraphDbSchemaOperation {
9386 command: "snapshot-import <artifact.db.gz> [--replace]",
9387 description: "Stage and validate a compressed SQLite graph.db artifact through doctor and freshness checks before replacing the local graph.db",
9388 },
9389 GraphDbSchemaOperation {
9390 command: "backend-eval [--candidate duckdb-duckpgq|falkordb|ladybug|kuzu|surrealdb] [--target ID] [--full-projection]",
9391 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",
9392 },
9393 GraphDbSchemaOperation {
9394 command: "evidence <target> [--depth N] [--limit N]",
9395 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",
9396 },
9397 GraphDbSchemaOperation {
9398 command: "related <phrase> [--kind concept|entity|all] [--depth N] [--seed-limit N] [--limit N]",
9399 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",
9400 },
9401 GraphDbSchemaOperation {
9402 command: "dispatch-trace [target...] --path <session> [--format json|html]",
9403 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",
9404 },
9405 GraphDbSchemaOperation {
9406 command: "dependency-dag [target...] --path <session>",
9407 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",
9408 },
9409 GraphDbSchemaOperation {
9410 command: "schema",
9411 description: "Return record and operation schemas",
9412 },
9413 GraphDbSchemaOperation {
9414 command: "node <id>",
9415 description: "Return one node by stable id",
9416 },
9417 GraphDbSchemaOperation {
9418 command: "edge <id>",
9419 description: "Return one edge by stable edge id",
9420 },
9421 GraphDbSchemaOperation {
9422 command: "edges [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9423 description: "Return edge records ordered by stable edge id with SQLite-pushed edge-property filtering and cursor pagination",
9424 },
9425 GraphDbSchemaOperation {
9426 command: "incident <id> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor EDGE_ID] [--limit N]",
9427 description: "Return incoming and outgoing edges incident to one node, ordered by stable edge id with optional kind and edge-property filters",
9428 },
9429 GraphDbSchemaOperation {
9430 command: "kind <kind> [--property KEY=VALUE] [--cursor ID] [--limit N]",
9431 description: "Return nodes of one kind ordered by id with SQLite-pushed property filtering/cursor pagination and query-plan diagnostics",
9432 },
9433 GraphDbSchemaOperation {
9434 command: "neighborhood <id> --depth <n> [--edge-kind <kind>] [--property KEY=VALUE] [--cursor ID] [--limit N]",
9435 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",
9436 },
9437 GraphDbSchemaOperation {
9438 command: "path <from> <to> [--edge-kind <kind>] [--max-hops N]",
9439 description: "Return the shortest directed path by node id, optionally bounded by hop count",
9440 },
9441 ],
9442 }
9443}
9444
9445pub(crate) fn sqlite_graph_freshness(
9446 store: &SqliteGraphStore,
9447 scope: &str,
9448) -> Result<GraphDbFreshnessReport> {
9449 let version = store.projection_version(scope)?;
9450 let Some(version) = version else {
9451 return Ok(GraphDbFreshnessReport {
9452 status: "missing".to_string(),
9453 fail_closed: true,
9454 projection_version: None,
9455 content_hash: None,
9456 source_watermark: None,
9457 diagnostics: vec![
9458 "graph projection metadata is missing; rebuild the graph before trusting reads"
9459 .to_string(),
9460 ],
9461 });
9462 };
9463 let mut diagnostics = Vec::new();
9464 let fail_closed =
9465 version.projection_version != GRAPH_PROJECTION_VERSION || version.content_hash.is_none();
9466 if version.projection_version != GRAPH_PROJECTION_VERSION {
9467 diagnostics.push(format!(
9468 "projection version mismatch: expected {} got {}",
9469 GRAPH_PROJECTION_VERSION, version.projection_version
9470 ));
9471 }
9472 if version.content_hash.is_none() {
9473 diagnostics.push("projection content hash is missing".to_string());
9474 }
9475 Ok(GraphDbFreshnessReport {
9476 status: if fail_closed { "stale" } else { "current" }.to_string(),
9477 fail_closed,
9478 projection_version: Some(version.projection_version),
9479 content_hash: version.content_hash,
9480 source_watermark: version.source_watermark,
9481 diagnostics,
9482 })
9483}
9484
9485pub(crate) fn convex_graph_freshness(
9486 local: &ConvexProjectionRows,
9487 snapshot: &ConvexProjectionRows,
9488 scope: Option<&str>,
9489) -> GraphDbFreshnessReport {
9490 let freshness = convex_projection_freshness(local, Some(snapshot), scope);
9491 GraphDbFreshnessReport {
9492 status: freshness.status,
9493 fail_closed: freshness.fail_closed,
9494 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
9495 content_hash: freshness.snapshot_hash,
9496 source_watermark: None,
9497 diagnostics: freshness.diagnostics,
9498 }
9499}
9500
9501pub(crate) fn tokensave_graph_freshness(store: &TokensaveDb) -> Result<GraphDbFreshnessReport> {
9502 let (nodes, edges) = store.graph_counts()?;
9503 let files = store.file_count()?;
9504 Ok(GraphDbFreshnessReport {
9505 status: "current".to_string(),
9506 fail_closed: false,
9507 projection_version: Some("tokensave-readonly".to_string()),
9508 content_hash: None,
9509 source_watermark: Some(store.db_path().to_string_lossy().to_string()),
9510 diagnostics: vec![format!(
9511 "tokensave read-only adapter opened {} node(s), {} edge(s), {} file(s)",
9512 nodes, edges, files
9513 )],
9514 })
9515}
9516
9517pub(crate) fn append_tokensave_graph_doctor_checks(report: &mut GraphDbDoctorReport, root: &Path) {
9518 match TokensaveDb::discover(root) {
9519 Ok(Some(store)) => {
9520 report.push_check(GraphDbDoctorCheck {
9521 name: "tokensave_db_open".to_string(),
9522 status: "ok".to_string(),
9523 fail_closed: false,
9524 diagnostics: vec![format!(
9525 "opened tokensave database at {}",
9526 store.db_path().display()
9527 )],
9528 repair_commands: Vec::new(),
9529 });
9530 match (store.node_count(), store.edge_count(), store.file_count()) {
9531 (Ok(nodes), Ok(edges), Ok(files)) => {
9532 report.push_check(GraphDbDoctorCheck {
9533 name: "tokensave_counts".to_string(),
9534 status: "ok".to_string(),
9535 fail_closed: false,
9536 diagnostics: vec![format!(
9537 "tokensave contains {} node(s), {} edge(s), {} file(s)",
9538 nodes, edges, files
9539 )],
9540 repair_commands: Vec::new(),
9541 });
9542 }
9543 (nodes, edges, files) => {
9544 report.push_check(graph_db_doctor_check(
9545 "tokensave_counts",
9546 vec![format!(
9547 "tokensave count inspection failed: nodes={:?} edges={:?} files={:?}",
9548 nodes.err(),
9549 edges.err(),
9550 files.err()
9551 )],
9552 Vec::new(),
9553 ));
9554 }
9555 }
9556 }
9557 Ok(None) => report.push_check(graph_db_doctor_check(
9558 "tokensave_db_exists",
9559 vec![format!(
9560 "tokensave database is missing at {}",
9561 root.join(".tokensave").join("tokensave.db").display()
9562 )],
9563 Vec::new(),
9564 )),
9565 Err(err) => report.push_check(graph_db_doctor_check(
9566 "tokensave_db_open",
9567 vec![err.to_string()],
9568 Vec::new(),
9569 )),
9570 }
9571}
9572
9573const GRAPH_DB_EVIDENCE_TARGET_KINDS: &[&str] = &[
9574 "backlog",
9575 "job_packet",
9576 "worker_result",
9577 "worker_context",
9578 "source_handle",
9579];
9580
9581pub(crate) fn graph_db_evidence_preferred_path(root: &Path, path_hint: &Path) -> Option<String> {
9582 hinted_markdown_file(root, path_hint).map(|path| {
9583 relativize_pathbuf(&path, root)
9584 .to_string_lossy()
9585 .replace('\\', "/")
9586 })
9587}
9588
9589fn graph_db_ambiguous_target_message(
9590 target: &str,
9591 kind: &str,
9592 candidates: &[SubstrateGraphNode],
9593) -> String {
9594 let mut by_path = BTreeMap::<String, String>::new();
9595 for candidate in candidates.iter().filter(|node| node.kind == kind) {
9596 let path = candidate
9597 .properties
9598 .get("path")
9599 .cloned()
9600 .unwrap_or_else(|| "<no path>".to_string());
9601 by_path.entry(path).or_insert_with(|| candidate.id.clone());
9602 }
9603 let examples = by_path
9604 .iter()
9605 .take(5)
9606 .map(|(path, node_id)| format!("{node_id} path={path}"))
9607 .collect::<Vec<_>>()
9608 .join(", ");
9609 format!(
9610 "graph-db evidence target {target} is ambiguous across {} {kind} node paths: {examples}; rerun with --path <agent-doc.md> or use an exact graph node id",
9611 by_path.len()
9612 )
9613}
9614
9615pub(crate) fn graph_db_resolve_evidence_target_with_path(
9616 store: &impl GraphStore,
9617 target: &str,
9618 preferred_path: Option<&str>,
9619) -> Result<Option<SubstrateGraphNode>> {
9620 if let Some(node) = store.node(target)? {
9621 return Ok(Some(node));
9622 }
9623 let candidates =
9624 store.evidence_target_candidates(target, GRAPH_DB_EVIDENCE_TARGET_KINDS, preferred_path)?;
9625 if candidates.is_empty() {
9626 return Ok(None);
9627 }
9628 if preferred_path.is_none() {
9629 let first_kind = candidates[0].kind.as_str();
9630 let distinct_paths = candidates
9631 .iter()
9632 .filter(|node| node.kind == first_kind)
9633 .map(|node| {
9634 node.properties
9635 .get("path")
9636 .map(String::as_str)
9637 .unwrap_or("")
9638 })
9639 .collect::<BTreeSet<_>>();
9640 if distinct_paths.len() > 1 {
9641 bail!(
9642 "{}",
9643 graph_db_ambiguous_target_message(target, first_kind, &candidates)
9644 );
9645 }
9646 }
9647 Ok(candidates.into_iter().next())
9648}
9649
9650pub(crate) fn graph_db_resolve_evidence_target(
9651 store: &impl GraphStore,
9652 target: &str,
9653) -> Result<Option<SubstrateGraphNode>> {
9654 graph_db_resolve_evidence_target_with_path(store, target, None)
9655}
9656
9657fn graph_db_reachable_nodes_by_kind(
9658 store: &impl GraphStore,
9659 from_id: &str,
9660 kind: &str,
9661 depth: usize,
9662 limit: usize,
9663) -> Result<Vec<(SubstrateGraphNode, substrate::GraphPath)>> {
9664 store.reachable_nodes_by_kind(from_id, kind, depth, limit)
9665}
9666
9667fn graph_db_evidence_completed_queue_drift_warnings(
9668 store: &impl GraphStore,
9669 target: &SubstrateGraphNode,
9670 worker_results: &[SubstrateGraphNode],
9671) -> Result<Vec<String>> {
9672 let ref_id = target.properties.get("ref_id").map(String::as_str);
9673 let has_completed_result = worker_results.iter().any(|node| {
9674 node.properties.get("status").map(String::as_str) == Some("completed")
9675 && node.properties.get("ref_id").map(String::as_str) == ref_id
9676 });
9677 if !has_completed_result {
9678 return Ok(Vec::new());
9679 }
9680 let active_jobs = store
9681 .nodes_by_kind("job_packet")?
9682 .into_iter()
9683 .filter(|node| {
9684 node.properties.get("ref_id").map(String::as_str) == ref_id
9685 && node.label.starts_with("do #")
9686 })
9687 .collect::<Vec<_>>();
9688 if active_jobs.is_empty() {
9689 return Ok(Vec::new());
9690 }
9691 let repair = match (target.properties.get("path"), ref_id) {
9692 (Some(path), Some(id)) => format!(
9693 "repair with `agent-doc write --commit {} --done {}` or the next `agent-doc finalize --done {}` closeout",
9694 shell_quote(path),
9695 shell_quote(id),
9696 shell_quote(id)
9697 ),
9698 _ => {
9699 "repair by marking the queue item done/reaping it in the agent-doc session".to_string()
9700 }
9701 };
9702 Ok(vec![format!(
9703 "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",
9704 target.label,
9705 active_jobs.len()
9706 )])
9707}
9708
9709fn graph_db_evidence_next_commands(
9710 root: &Path,
9711 scope: Option<&str>,
9712 target: &SubstrateGraphNode,
9713 worker_context: &[SubstrateGraphNode],
9714 source_handles: &[SubstrateGraphNode],
9715 worker_results: &[SubstrateGraphNode],
9716 semantic_related: &[SubstrateGraphNode],
9717) -> Vec<String> {
9718 let mut commands = BTreeSet::new();
9719 if let Some(expand) = target.properties.get("expand") {
9720 commands.insert(expand.clone());
9721 }
9722 for worker in worker_context {
9723 if let Some(expand) = worker.properties.get("expand") {
9724 commands.insert(expand.clone());
9725 }
9726 }
9727 for source in source_handles {
9728 if let Some(expand) = source.properties.get("expand") {
9729 commands.insert(expand.clone());
9730 }
9731 }
9732 for result in worker_results {
9733 if let Some(expand) = result.properties.get("expand") {
9734 commands.insert(expand.clone());
9735 }
9736 }
9737 for semantic in semantic_related {
9738 if let Some(expand) = semantic.properties.get("expand") {
9739 commands.insert(expand.clone());
9740 }
9741 }
9742 commands.insert(format!(
9743 "tsift graph-db --path {}{} status --json",
9744 shell_quote(root.to_string_lossy().as_ref()),
9745 graph_db_scope_arg(scope)
9746 ));
9747 commands.insert(format!(
9748 "tsift graph-db --path {}{} doctor --json",
9749 shell_quote(root.to_string_lossy().as_ref()),
9750 graph_db_scope_arg(scope)
9751 ));
9752 commands.into_iter().collect()
9753}
9754
9755fn graph_db_repair_commands(root: &Path, scope: Option<&str>) -> Vec<String> {
9756 vec![
9757 format!(
9758 "tsift graph-db --path {}{} refresh --json",
9759 shell_quote(root.to_string_lossy().as_ref()),
9760 graph_db_scope_arg(scope)
9761 ),
9762 format!(
9763 "tsift graph-db --path {}{} doctor --json",
9764 shell_quote(root.to_string_lossy().as_ref()),
9765 graph_db_scope_arg(scope)
9766 ),
9767 ]
9768}
9769
9770fn graph_db_evidence_replay_commands(
9771 root: &Path,
9772 scope: Option<&str>,
9773 target: &str,
9774 depth: usize,
9775 limit: usize,
9776) -> Vec<String> {
9777 vec![
9778 format!(
9779 "tsift graph-db --path {}{} evidence {} --depth {} --limit {} --json",
9780 shell_quote(root.to_string_lossy().as_ref()),
9781 graph_db_scope_arg(scope),
9782 shell_quote(target),
9783 depth,
9784 limit
9785 ),
9786 format!(
9787 "tsift conflict-matrix --path {} {} --json",
9788 shell_quote(root.to_string_lossy().as_ref()),
9789 shell_quote(target)
9790 ),
9791 ]
9792}
9793
9794fn graph_db_evidence_packet_id(
9795 target: &str,
9796 target_node: &SubstrateGraphNode,
9797 freshness: &GraphDbFreshnessReport,
9798) -> String {
9799 stable_handle(
9800 "gevd",
9801 &format!(
9802 "{}:{}:{}:{}",
9803 GRAPH_DB_EVIDENCE_CONTRACT_VERSION,
9804 target,
9805 target_node.id,
9806 freshness.content_hash.as_deref().unwrap_or("no-hash")
9807 ),
9808 )
9809}
9810
9811pub(crate) fn graph_db_evidence_report_from_store<S: GraphStore>(
9812 input: GraphDbEvidenceInput<'_, S>,
9813) -> Result<GraphDbEvidenceReport> {
9814 let GraphDbEvidenceInput {
9815 root,
9816 scope,
9817 backend,
9818 target,
9819 preferred_path,
9820 depth,
9821 limit,
9822 cursor,
9823 store,
9824 freshness,
9825 mut warnings,
9826 } = input;
9827 let repair_commands = graph_db_repair_commands(root, scope);
9828 if freshness.fail_closed {
9829 bail!(
9830 "graph database evidence failed closed for {} backend: {}; repair: {}",
9831 backend,
9832 freshness.diagnostics.join("; "),
9833 repair_commands.join("; ")
9834 );
9835 }
9836 let semantic_readiness =
9837 graph_db_semantic_readiness(root, scope, graph_store_semantic_node_count(store).ok());
9838 if semantic_readiness.fail_closed {
9839 warnings.push(format!(
9840 "graph evidence semantic readiness blocked: {} — {}",
9841 semantic_readiness.reason,
9842 semantic_readiness.diagnostics.join("; ")
9843 ));
9844 warnings.push(format!(
9845 "repair: {}",
9846 semantic_readiness.next_commands.join("; then ")
9847 ));
9848 }
9849 let target_node = graph_db_resolve_evidence_target_with_path(store, target, preferred_path)?
9850 .with_context(|| format!("graph-db evidence target not found: {target}"))?;
9851 let max_rows = if limit == 0 { usize::MAX } else { limit };
9852 let mut reachable = store.reachable_nodes_by_kinds(
9853 &target_node.id,
9854 &[
9855 "worker_context",
9856 "source_handle",
9857 "worker_result",
9858 "semantic_concept",
9859 "semantic_entity",
9860 ],
9861 depth,
9862 max_rows,
9863 )?;
9864 let worker_paths = reachable.remove("worker_context").unwrap_or_default();
9865 let source_paths = reachable.remove("source_handle").unwrap_or_default();
9866 let worker_result_paths = reachable.remove("worker_result").unwrap_or_default();
9867 let mut semantic_paths = reachable.remove("semantic_concept").unwrap_or_default();
9868 semantic_paths.extend(reachable.remove("semantic_entity").unwrap_or_default());
9869 semantic_paths.sort_by(|(left_node, left_path), (right_node, right_path)| {
9870 left_path
9871 .hops
9872 .cmp(&right_path.hops)
9873 .then(left_node.kind.cmp(&right_node.kind))
9874 .then(left_node.label.cmp(&right_node.label))
9875 .then(left_node.id.cmp(&right_node.id))
9876 });
9877 if max_rows != usize::MAX && semantic_paths.len() > max_rows {
9878 semantic_paths.truncate(max_rows);
9879 }
9880
9881 let evidence_nodes = worker_paths
9882 .iter()
9883 .chain(source_paths.iter())
9884 .chain(worker_result_paths.iter())
9885 .chain(semantic_paths.iter())
9886 .map(|(node, _)| node.clone())
9887 .collect::<Vec<_>>();
9888 let evidence_depth_by_id = worker_paths
9889 .iter()
9890 .chain(source_paths.iter())
9891 .chain(worker_result_paths.iter())
9892 .chain(semantic_paths.iter())
9893 .map(|(node, path)| (node.id.clone(), path.hops))
9894 .collect::<BTreeMap<_, _>>();
9895 let target_query = graph_db_node_search_text(&target_node);
9896 let semantic_scores = graph_db_semantic_scores_for_query(Some(&target_query), &evidence_nodes);
9897 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
9898 std::slice::from_ref(&target_node.id),
9899 &semantic_scores,
9900 evidence_nodes,
9901 Vec::new(),
9902 Some(limit),
9903 Some(&evidence_depth_by_id),
9904 cursor,
9905 );
9906 let output_budget = budgeted.report;
9907 let truncated = budgeted.truncated;
9908 let next_cursor = budgeted.next_cursor;
9909 let retained_evidence_ids = budgeted
9910 .nodes
9911 .iter()
9912 .map(|node| node.id.as_str())
9913 .collect::<BTreeSet<_>>();
9914 let worker_context = worker_paths
9915 .iter()
9916 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9917 .map(|(node, _)| node.clone())
9918 .collect::<Vec<_>>();
9919 let source_handles = source_paths
9920 .iter()
9921 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9922 .map(|(node, _)| node.clone())
9923 .collect::<Vec<_>>();
9924 let worker_results = worker_result_paths
9925 .iter()
9926 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9927 .map(|(node, _)| node.clone())
9928 .collect::<Vec<_>>();
9929 let semantic_related = semantic_paths
9930 .iter()
9931 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9932 .map(|(node, _)| node.clone())
9933 .collect::<Vec<_>>();
9934 warnings.extend(graph_db_evidence_completed_queue_drift_warnings(
9935 store,
9936 &target_node,
9937 &worker_results,
9938 )?);
9939 if worker_context.is_empty()
9940 && source_handles.is_empty()
9941 && worker_results.is_empty()
9942 && semantic_related.is_empty()
9943 {
9944 warnings.push(format!(
9945 "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",
9946 target, target_node.kind
9947 ));
9948 }
9949 let shortest_paths = worker_paths
9950 .iter()
9951 .chain(source_paths.iter())
9952 .chain(worker_result_paths.iter())
9953 .chain(semantic_paths.iter())
9954 .filter(|(node, _)| retained_evidence_ids.contains(node.id.as_str()))
9955 .map(|(node, path)| GraphDbEvidencePath {
9956 to: node.id.clone(),
9957 kind: node.kind.clone(),
9958 label: node.label.clone(),
9959 path: Some(path.clone()),
9960 expand: node.properties.get("expand").cloned(),
9961 })
9962 .collect::<Vec<_>>();
9963 let next_commands = graph_db_evidence_next_commands(
9964 root,
9965 scope,
9966 &target_node,
9967 &worker_context,
9968 &source_handles,
9969 &worker_results,
9970 &semantic_related,
9971 );
9972 let replay_commands = graph_db_evidence_replay_commands(root, scope, target, depth, limit);
9973 let packet_id = graph_db_evidence_packet_id(target, &target_node, &freshness);
9974 let projection_hash = freshness.content_hash.clone();
9975
9976 Ok(GraphDbEvidenceReport {
9977 root: root.to_string_lossy().to_string(),
9978 scope: scope.map(str::to_string),
9979 backend: backend.to_string(),
9980 contract_version: GRAPH_DB_EVIDENCE_CONTRACT_VERSION.to_string(),
9981 target: target.to_string(),
9982 packet_id,
9983 projection_hash,
9984 freshness,
9985 target_node: target_node.into(),
9986 worker_context: worker_context.into_iter().map(Into::into).collect(),
9987 source_handles: source_handles.into_iter().map(Into::into).collect(),
9988 worker_results: worker_results.into_iter().map(Into::into).collect(),
9989 semantic_related: semantic_related.into_iter().map(Into::into).collect(),
9990 shortest_paths,
9991 output_budget: Some(output_budget),
9992 truncated,
9993 next_cursor,
9994 next_commands,
9995 replay_commands,
9996 repair_commands,
9997 fixture_coverage: GraphDbFixtureCoverage {
9998 test: "graph_db_evidence_packet_covers_backlog_job_worker_context_and_source_handles"
9999 .to_string(),
10000 fixture: "tests/graph_db_conformance.rs::graph_db_project".to_string(),
10001 assertions: vec![
10002 "backlog id and job packet handle resolve to graph nodes".to_string(),
10003 "worker_context rows are reachable from queued work".to_string(),
10004 "source_handle rows are reachable through bounded shortest paths".to_string(),
10005 "worker_result rows are reachable from completed or blocked work".to_string(),
10006 ],
10007 },
10008 warnings,
10009 })
10010}
10011
10012fn print_graph_db_evidence_human(report: &GraphDbEvidenceReport) {
10013 println!(
10014 "graph-db evidence backend: {} target: {} [{}] packet:{}",
10015 report.backend, report.target_node.id, report.target_node.kind, report.packet_id
10016 );
10017 let page_info = if report.truncated {
10018 let cursor = report.next_cursor.as_deref().unwrap_or("?");
10019 format!(" (truncated, next_cursor: {cursor})")
10020 } else {
10021 String::new()
10022 };
10023 println!(
10024 "evidence: {} worker_context row(s), {} source_handle row(s), {} worker_result row(s), {} semantic row(s), {} path(s){page_info}",
10025 report.worker_context.len(),
10026 report.source_handles.len(),
10027 report.worker_results.len(),
10028 report.semantic_related.len(),
10029 report.shortest_paths.len()
10030 );
10031 for path in &report.shortest_paths {
10032 if let Some(graph_path) = &path.path {
10033 println!(
10034 "path: {} hop(s) {}",
10035 graph_path.hops,
10036 graph_path.nodes.join(" -> ")
10037 );
10038 }
10039 }
10040 for command in &report.next_commands {
10041 println!("next: {command}");
10042 }
10043 for warning in &report.warnings {
10044 println!("warning: {warning}");
10045 }
10046}
10047
10048pub(crate) fn print_graph_db_evidence_report(
10049 report: &GraphDbEvidenceReport,
10050 format: OutputFormat,
10051) -> Result<()> {
10052 if format.json_output {
10053 let page_info = if report.truncated {
10054 let cursor = report.next_cursor.as_deref().unwrap_or("?");
10055 format!(" (truncated, next_cursor: {cursor})")
10056 } else {
10057 String::new()
10058 };
10059 print_json_or_envelope(
10060 report,
10061 &format,
10062 "graph-db",
10063 "evidence",
10064 ToolEnvelopeSummary {
10065 text: format!(
10066 "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}",
10067 report.target,
10068 report.worker_context.len(),
10069 report.source_handles.len(),
10070 report.worker_results.len(),
10071 report.semantic_related.len(),
10072 report.shortest_paths.len()
10073 ),
10074 metrics: vec![
10075 envelope_metric("backend", &report.backend),
10076 envelope_metric("worker_context", report.worker_context.len()),
10077 envelope_metric("source_handles", report.source_handles.len()),
10078 envelope_metric("worker_results", report.worker_results.len()),
10079 envelope_metric("semantic_related", report.semantic_related.len()),
10080 envelope_metric("paths", report.shortest_paths.len()),
10081 ],
10082 },
10083 report.truncated,
10084 report.next_commands.clone(),
10085 )
10086 } else {
10087 print_graph_db_evidence_human(report);
10088 Ok(())
10089 }
10090}
10091
10092pub(crate) fn graph_db_report_from_store(
10093 root: &Path,
10094 scope: Option<&str>,
10095 backend: &str,
10096 query: GraphDbQuery,
10097 store: &impl GraphStore,
10098 freshness: GraphDbFreshnessReport,
10099 warnings: Vec<String>,
10100) -> Result<GraphDbReport> {
10101 if freshness.fail_closed {
10102 bail!(
10103 "graph database read failed closed for {} backend: {}",
10104 backend,
10105 freshness.diagnostics.join("; ")
10106 );
10107 }
10108 let mut report = GraphDbReport {
10109 root: root.to_string_lossy().to_string(),
10110 scope: scope.map(str::to_string),
10111 backend: backend.to_string(),
10112 query: format!("{query:?}"),
10113 freshness,
10114 readiness: None,
10115 schema: None,
10116 node: None,
10117 edge: None,
10118 nodes: Vec::new(),
10119 edges: Vec::new(),
10120 ranked_neighbors: Vec::new(),
10121 semantic_related: Vec::new(),
10122 neighborhood_ranking_gate: None,
10123 ranked_neighborhood_comparison: None,
10124 knowledge_retrieval: None,
10125 output_budget: None,
10126 path: None,
10127 page: None,
10128 warnings,
10129 };
10130
10131 match query {
10132 GraphDbQuery::Refresh => {
10133 bail!("graph-db refresh must be handled by the refresh command path");
10134 }
10135 GraphDbQuery::Status => {
10136 bail!("graph-db status must be handled by the status command path");
10137 }
10138 GraphDbQuery::Doctor => {
10139 bail!("graph-db doctor must be handled by the doctor command path");
10140 }
10141 GraphDbQuery::Drift => {
10142 bail!("graph-db drift must be handled by the drift command path");
10143 }
10144 GraphDbQuery::Compact { .. } => {
10145 bail!("graph-db compact must be handled by the compact command path");
10146 }
10147 GraphDbQuery::SnapshotExport { .. } => {
10148 bail!("graph-db snapshot-export must be handled by the snapshot command path");
10149 }
10150 GraphDbQuery::SnapshotImport { .. } => {
10151 bail!("graph-db snapshot-import must be handled by the snapshot command path");
10152 }
10153 GraphDbQuery::BackendEval { .. } => {
10154 bail!("graph-db backend-eval must be handled by the benchmark command path");
10155 }
10156 GraphDbQuery::Evidence { .. } => {
10157 bail!("graph-db evidence must be handled by the evidence command path");
10158 }
10159 GraphDbQuery::Related {
10160 query,
10161 kind,
10162 depth,
10163 seed_limit,
10164 limit,
10165 } => {
10166 let semantic =
10167 semantic_related_report_from_store(root, scope, &query, seed_limit, kind, store)?;
10168 let SemanticRelatedReport {
10169 items,
10170 warnings: semantic_warnings,
10171 ..
10172 } = semantic;
10173 let readiness = graph_db_semantic_readiness(
10174 root,
10175 scope,
10176 (!items.is_empty()).then_some(items.len()),
10177 );
10178 report.warnings.extend(semantic_warnings);
10179 let seed_ids = items
10180 .iter()
10181 .map(|item| item.handle.clone())
10182 .collect::<Vec<_>>();
10183 let semantic_scores = items
10184 .iter()
10185 .map(|item| (item.handle.clone(), item.score))
10186 .collect::<BTreeMap<_, _>>();
10187 let subgraph = graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit)?;
10188 let seed_count = seed_ids.len();
10189 let mut diagnostics = subgraph.diagnostics;
10190 let budgeted = graph_db_apply_output_budget(
10191 &seed_ids,
10192 &semantic_scores,
10193 subgraph.nodes,
10194 subgraph.edges,
10195 Some(limit),
10196 );
10197 let budget_report = budgeted.report;
10198 let dropped_by_budget = !budget_report.dropped_by_budget.is_empty();
10199 diagnostics.extend(budget_report.diagnostics.clone());
10200 diagnostics.extend(readiness.diagnostics.clone());
10201
10202 report.readiness = Some(readiness);
10203 report.semantic_related = items;
10204 if let Some(seed_id) = seed_ids.first() {
10205 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(Some(limit));
10206 report.ranked_neighbors = graph_db_ranked_neighbors(
10207 seed_id,
10208 &budgeted.nodes,
10209 &budgeted.edges,
10210 ranked_neighbor_cap,
10211 );
10212 report.neighborhood_ranking_gate =
10213 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10214 }
10215 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10216 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10217 report.knowledge_retrieval = Some(GraphDbKnowledgeRetrieval {
10218 mode: "semantic_seeded_neighborhood".to_string(),
10219 query,
10220 seed_kind: semantic_related_kind_name(kind).to_string(),
10221 seed_limit,
10222 seed_count,
10223 depth,
10224 limit,
10225 node_count: report.nodes.len(),
10226 edge_count: report.edges.len(),
10227 truncated: subgraph.truncated || dropped_by_budget,
10228 traversal: "incident_plus_outgoing_edges".to_string(),
10229 freshness_boundary:
10230 "semantic rows must come from refreshed summary or tsift-memory graph records"
10231 .to_string(),
10232 privacy_boundary:
10233 "GraphStore stores substrate records only; user consent, deletion policy, persona policy, and LiveKit session state stay in the avatar/agent adapter"
10234 .to_string(),
10235 diagnostics,
10236 });
10237 report.output_budget = Some(budget_report);
10238 }
10239 GraphDbQuery::Schema => {
10240 report.schema = Some(graph_db_schema());
10241 }
10242 GraphDbQuery::Node { id } => {
10243 report.node = store.node(&id)?.map(Into::into);
10244 }
10245 GraphDbQuery::Edge { id } => {
10246 report.edge = store.edge(&id)?.map(Into::into);
10247 }
10248 GraphDbQuery::Edges {
10249 edge_kind,
10250 cursor,
10251 limit,
10252 property_filters,
10253 } => {
10254 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10255 let paged = store.paged_edges(
10256 edge_kind.as_deref(),
10257 graph_db_query_options_for_store(&options),
10258 )?;
10259 report.edges = paged.edges.into_iter().map(Into::into).collect();
10260 report.page = Some(graph_db_page_report_from_store(
10261 paged.page,
10262 options.property_filters,
10263 ));
10264 }
10265 GraphDbQuery::Incident {
10266 id,
10267 edge_kind,
10268 cursor,
10269 limit,
10270 property_filters,
10271 } => {
10272 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10273 let paged = store.paged_incident_edges(
10274 &id,
10275 edge_kind.as_deref(),
10276 graph_db_query_options_for_store(&options),
10277 )?;
10278 report.edges = paged.edges.into_iter().map(Into::into).collect();
10279 report.page = Some(graph_db_page_report_from_store(
10280 paged.page,
10281 options.property_filters,
10282 ));
10283 }
10284 GraphDbQuery::Kind {
10285 kind,
10286 cursor,
10287 limit,
10288 property_filters,
10289 } => {
10290 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10291 let paged =
10292 store.paged_nodes_by_kind(&kind, graph_db_query_options_for_store(&options))?;
10293 report.nodes = paged.nodes.into_iter().map(Into::into).collect();
10294 report.edges = paged.edges.into_iter().map(Into::into).collect();
10295 report.page = Some(graph_db_page_report_from_store(
10296 paged.page,
10297 options.property_filters,
10298 ));
10299 }
10300 GraphDbQuery::Neighborhood {
10301 id,
10302 depth,
10303 edge_kind,
10304 cursor,
10305 limit,
10306 property_filters,
10307 } => {
10308 let options = graph_db_query_options(cursor, limit, &property_filters)?;
10309 if let Some(paged) = store.paged_neighborhood(
10310 &id,
10311 depth,
10312 edge_kind.as_deref(),
10313 graph_db_query_options_for_store(&options),
10314 )? {
10315 let budgeted = graph_db_apply_output_budget(
10316 std::slice::from_ref(&id),
10317 &BTreeMap::new(),
10318 paged.nodes,
10319 paged.edges,
10320 options.limit,
10321 );
10322 let budget_report = budgeted.report;
10323 let ranked_neighbor_cap = graph_db_ranked_neighbor_cap(options.limit);
10324 let ranked_neighbors = graph_db_ranked_neighbors(
10325 &id,
10326 &budgeted.nodes,
10327 &budgeted.edges,
10328 ranked_neighbor_cap,
10329 );
10330 let comparison = graph_db_ranked_neighborhood_comparison(
10331 &id,
10332 depth,
10333 edge_kind.as_deref(),
10334 options.limit,
10335 &budgeted.nodes,
10336 &budgeted.edges,
10337 store,
10338 )?;
10339 report.nodes = budgeted.nodes.into_iter().map(Into::into).collect();
10340 report.edges = budgeted.edges.into_iter().map(Into::into).collect();
10341 report.ranked_neighbors = ranked_neighbors;
10342 report.neighborhood_ranking_gate =
10343 Some(graph_db_neighborhood_ranking_gate(ranked_neighbor_cap));
10344 let mut page =
10345 graph_db_page_report_from_store(paged.page, options.property_filters);
10346 page.returned_nodes = report.nodes.len();
10347 page.returned_edges = report.edges.len();
10348 page.truncated |= !budget_report.dropped_by_budget.is_empty();
10349 page.diagnostics.extend(budget_report.diagnostics.clone());
10350 report.page = Some(page);
10351 report.output_budget = Some(budget_report);
10352 if let Some(comparison) = comparison {
10353 report.ranked_neighborhood_comparison = Some(comparison);
10354 }
10355 }
10356 }
10357 GraphDbQuery::Path {
10358 from,
10359 to,
10360 edge_kind,
10361 max_hops,
10362 } => {
10363 report.path =
10364 store.shortest_path_with_max_hops(&from, &to, edge_kind.as_deref(), max_hops)?;
10365 if let Some(max_hops) = max_hops
10366 && report.path.is_none()
10367 {
10368 report.warnings.push(format!(
10369 "no directed path found within --max-hops {}",
10370 max_hops
10371 ));
10372 }
10373 }
10374 GraphDbQuery::Map { .. } => {
10375 bail!("graph-db map must be handled by the map command path");
10376 }
10377 }
10378 Ok(report)
10379}
10380
10381pub(crate) fn print_graph_db_human(report: &GraphDbReport, compact: bool) {
10382 if compact {
10383 println!(
10384 "graph-db backend:{} query:{} nodes:{} edges:{} freshness:{}",
10385 report.backend,
10386 report.query,
10387 report.nodes.len() + usize::from(report.node.is_some()),
10388 report.edges.len() + usize::from(report.edge.is_some()),
10389 report.freshness.status
10390 );
10391 return;
10392 }
10393 println!("graph-db backend: {}", report.backend);
10394 println!("freshness: {}", report.freshness.status);
10395 if let Some(readiness) = &report.readiness {
10396 println!(
10397 "readiness: {} reason: {} fail_closed: {}",
10398 readiness.status, readiness.reason, readiness.fail_closed
10399 );
10400 for diagnostic in &readiness.diagnostics {
10401 println!("readiness diagnostic: {diagnostic}");
10402 }
10403 for command in &readiness.next_commands {
10404 println!("readiness next: {command}");
10405 }
10406 }
10407 if let Some(schema) = &report.schema {
10408 println!(
10409 "schema: {} node fields, {} edge fields, {} operations",
10410 schema.node_fields.len(),
10411 schema.edge_fields.len(),
10412 schema.operations.len()
10413 );
10414 }
10415 if let Some(node) = &report.node {
10416 println!("node: {} [{}] {}", node.id, node.kind, node.label);
10417 }
10418 if let Some(edge) = &report.edge {
10419 let edge_full: SubstrateGraphEdge = edge.into();
10420 println!(
10421 "edge: {} {} -{}-> {}",
10422 graph_db_edge_key(&edge_full),
10423 edge.from_id,
10424 edge.kind,
10425 edge.to_id
10426 );
10427 }
10428 if let Some(knowledge) = &report.knowledge_retrieval {
10429 println!(
10430 "knowledge_retrieval: {} seeds:{} depth:{} traversal:{}",
10431 knowledge.mode, knowledge.seed_count, knowledge.depth, knowledge.traversal
10432 );
10433 }
10434 for item in &report.semantic_related {
10435 println!(
10436 "semantic_seed: {:.3} [{}] {} ({})",
10437 item.score, item.kind, item.label, item.handle
10438 );
10439 }
10440 for node in &report.nodes {
10441 println!("node: {} [{}] {}", node.id, node.kind, node.label);
10442 }
10443 for edge in &report.edges {
10444 let edge_full: SubstrateGraphEdge = edge.into();
10445 println!(
10446 "edge: {} {} -{}-> {}",
10447 graph_db_edge_key(&edge_full),
10448 edge.from_id,
10449 edge.kind,
10450 edge.to_id
10451 );
10452 }
10453 for neighbor in &report.ranked_neighbors {
10454 println!(
10455 "ranked_neighbor: #{} score:{} depth:{} {} [{}] {}",
10456 neighbor.rank,
10457 neighbor.score,
10458 neighbor
10459 .depth
10460 .map(|depth| depth.to_string())
10461 .unwrap_or_else(|| "unknown".to_string()),
10462 neighbor.node_id,
10463 neighbor.kind,
10464 neighbor.label
10465 );
10466 }
10467 if let Some(gate) = &report.neighborhood_ranking_gate {
10468 println!(
10469 "neighborhood_ranking_gate: {} default_order:{} ranked_output_default:{}",
10470 gate.status, gate.default_order, gate.ranked_output_default
10471 );
10472 }
10473 if let Some(path) = &report.path {
10474 println!("path: {} hop(s) {}", path.hops, path.nodes.join(" -> "));
10475 }
10476 if let Some(page) = &report.page {
10477 if let Some(next_cursor) = &page.next_cursor {
10478 println!("next_cursor: {next_cursor}");
10479 }
10480 for diagnostic in &page.diagnostics {
10481 println!("page: {diagnostic}");
10482 }
10483 }
10484 for warning in &report.warnings {
10485 println!("warning: {warning}");
10486 }
10487}
10488
10489pub(crate) fn graph_db_backend_eval_phase_timing(
10490 name: &str,
10491 duration_micros: u128,
10492 detail: &str,
10493) -> GraphDbBackendEvalPhaseTiming {
10494 GraphDbBackendEvalPhaseTiming {
10495 name: name.to_string(),
10496 duration_micros,
10497 detail: detail.to_string(),
10498 }
10499}
10500
10501pub(crate) fn graph_db_backend_eval_timed_phase<T>(
10502 phases: &mut Vec<GraphDbBackendEvalPhaseTiming>,
10503 name: &str,
10504 detail: &str,
10505 run: impl FnOnce() -> Result<T>,
10506) -> Result<T> {
10507 let started = Instant::now();
10508 let result = run();
10509 phases.push(graph_db_backend_eval_phase_timing(
10510 name,
10511 started.elapsed().as_micros(),
10512 detail,
10513 ));
10514 result
10515}
10516
10517pub(crate) fn graph_db_backend_eval_refresh_total_micros(
10518 phases: &[GraphDbBackendEvalPhaseTiming],
10519) -> u128 {
10520 phases
10521 .iter()
10522 .filter(|phase| phase.name != "conflict_matrix_preparation")
10523 .map(|phase| phase.duration_micros)
10524 .sum()
10525}
10526
10527pub(crate) fn graph_db_backend_eval_cached_refresh(
10528 root: &Path,
10529 scope: Option<&str>,
10530 source_watermark: Option<&str>,
10531) -> Result<
10532 Option<(
10533 TraversalGraphBuild,
10534 SqliteProjectionRefresh,
10535 Vec<GraphDbBackendEvalPhaseTiming>,
10536 )>,
10537> {
10538 let Some(source_watermark) = source_watermark else {
10539 return Ok(None);
10540 };
10541 let graph_db = graph_substrate_db_path(root, scope);
10542 if !graph_db.exists() {
10543 return Ok(None);
10544 }
10545
10546 let started = Instant::now();
10547 let store = match SqliteGraphStore::open_read_only_resilient(&graph_db) {
10548 Ok(store) => store,
10549 Err(_) => return Ok(None),
10550 };
10551 if store.has_user_triggers().unwrap_or(true) {
10552 return Ok(None);
10553 }
10554 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
10555 if freshness.fail_closed || freshness.source_watermark.as_deref() != Some(source_watermark) {
10556 return Ok(None);
10557 }
10558
10559 let phases = vec![
10560 graph_db_backend_eval_phase_timing(
10561 "source_graph_build",
10562 started.elapsed().as_micros(),
10563 "reused current graph.db projection because the source watermark matched; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
10564 ),
10565 graph_db_backend_eval_phase_timing(
10566 "projection_rows",
10567 0,
10568 "reused cached provider-neutral projection rows from graph.db",
10569 ),
10570 graph_db_backend_eval_phase_timing(
10571 "sqlite_open",
10572 0,
10573 "reused existing graph.db projection without opening a write transaction",
10574 ),
10575 ];
10576 let refresh = SqliteProjectionRefresh {
10577 scope: scope.unwrap_or("root").to_string(),
10578 projection_version: freshness
10579 .projection_version
10580 .unwrap_or_else(|| GRAPH_PROJECTION_VERSION.to_string()),
10581 source_watermark: Some(source_watermark.to_string()),
10582 tombstoned_nodes: Vec::new(),
10583 tombstoned_edges: Vec::new(),
10584 upserted_nodes: 0,
10585 upserted_edges: 0,
10586 unchanged_nodes: 0,
10587 unchanged_edges: 0,
10588 upserted_properties: 0,
10589 unchanged_properties: 0,
10590 deleted_properties: 0,
10591 deleted_nodes: 0,
10592 deleted_edges: 0,
10593 pruned_tombstones: 0,
10594 file_size_bytes_before: None,
10595 file_size_bytes_after: None,
10596 phase_timings: Vec::new(),
10597 };
10598 Ok(Some((TraversalGraphBuild::default(), refresh, phases)))
10599}
10600
10601pub(crate) fn graph_db_backend_eval_reused_cached_projection(
10602 phases: &[GraphDbBackendEvalPhaseTiming],
10603) -> bool {
10604 phases.iter().any(|phase| {
10605 phase.name == "source_graph_build"
10606 && phase.detail.contains("reused current graph.db projection")
10607 })
10608}
10609
10610pub(crate) fn graph_db_backend_eval_update_source_watermark(
10611 root: &Path,
10612 path_hint: &Path,
10613 scope: Option<&str>,
10614) -> Result<()> {
10615 let Some(source_watermark) = traversal_source_watermark(root, path_hint, scope, false)? else {
10616 return Ok(());
10617 };
10618 let graph_db = graph_substrate_db_path(root, scope);
10619 let mut store = SqliteGraphStore::open(&graph_db)?;
10620 store.update_projection_source_watermark(scope.unwrap_or("root"), Some(source_watermark))?;
10621 Ok(())
10622}
10623
10624pub(crate) fn graph_db_backend_eval_refresh_with_profile(
10625 root: &Path,
10626 path_hint: &Path,
10627 scope: Option<&str>,
10628) -> Result<(
10629 TraversalGraphBuild,
10630 SqliteProjectionRefresh,
10631 Vec<GraphDbBackendEvalPhaseTiming>,
10632)> {
10633 let source_watermark = traversal_source_watermark(root, path_hint, scope, false)?;
10634 if let Some(cached) =
10635 graph_db_backend_eval_cached_refresh(root, scope, source_watermark.as_deref())?
10636 {
10637 return Ok(cached);
10638 }
10639
10640 let mut phases = Vec::new();
10641 let source_graph_detail = if hinted_markdown_file(root, path_hint).is_some() {
10642 "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"
10643 } else {
10644 "index/source loading plus agent-doc session markdown scan, source-handle construction, and semantic summary reads when summaries are cached"
10645 };
10646 let source_graph = graph_db_backend_eval_timed_phase(
10647 &mut phases,
10648 "source_graph_build",
10649 source_graph_detail,
10650 || build_traversal_graph_source_with_options(root, path_hint, scope, false),
10651 )?;
10652 let projection = graph_db_backend_eval_timed_phase(
10653 &mut phases,
10654 "projection_rows",
10655 "provider-neutral GraphStore node/edge row construction before SQLite persistence",
10656 || traversal_projection_from_graph(root, scope, &source_graph),
10657 )?;
10658 let graph_db = graph_substrate_db_path(root, scope);
10659 let mut store = graph_db_backend_eval_timed_phase(
10660 &mut phases,
10661 "sqlite_open",
10662 "open the local SQLite graph.db with WAL and busy-timeout settings",
10663 || SqliteGraphStore::open(&graph_db),
10664 )?;
10665 let refreshed_source_watermark = traversal_source_watermark(root, path_hint, scope, false)
10666 .ok()
10667 .flatten();
10668 let refresh = store.replace_projection_with_version(
10669 scope.unwrap_or("root"),
10670 &projection,
10671 Some(GRAPH_PROJECTION_VERSION),
10672 refreshed_source_watermark
10673 .or(source_watermark)
10674 .or_else(|| graph_projection_content_hash(&projection)),
10675 )?;
10676 phases.extend(
10677 refresh
10678 .phase_timings
10679 .iter()
10680 .map(|phase| GraphDbBackendEvalPhaseTiming {
10681 name: phase.name.clone(),
10682 duration_micros: phase.duration_micros,
10683 detail: phase.detail.clone(),
10684 }),
10685 );
10686 Ok((source_graph, refresh, phases))
10687}
10688
10689fn graph_db_backend_eval_disk_cache_dir(root: &Path) -> PathBuf {
10690 root.join(".tsift/backend-eval-cache")
10691}
10692
10693fn graph_db_backend_eval_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10694 graph_db_backend_eval_disk_cache_dir(root)
10695 .join(kind)
10696 .join(format!("{key}.json.gz"))
10697}
10698
10699fn graph_db_backend_eval_legacy_disk_cache_path(root: &Path, kind: &str, key: &str) -> PathBuf {
10700 graph_db_backend_eval_disk_cache_dir(root)
10701 .join(kind)
10702 .join(format!("{key}.json"))
10703}
10704
10705#[derive(Default, Clone)]
10706struct GraphDbBackendEvalDiskCacheReadProfile {
10707 file_read_micros: u128,
10708 gzip_decode_micros: u128,
10709 serde_decode_micros: u128,
10710 legacy: bool,
10711}
10712
10713fn graph_db_backend_eval_read_disk_cache<T: for<'de> Deserialize<'de>>(
10714 root: &Path,
10715 kind: &str,
10716 key: &str,
10717) -> Option<(T, u64, u64, GraphDbBackendEvalDiskCacheReadProfile)> {
10718 let mut profile = GraphDbBackendEvalDiskCacheReadProfile::default();
10719 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10720 let read_started = Instant::now();
10721 let read_result = fs::read(&path);
10722 profile.file_read_micros = read_started.elapsed().as_micros();
10723 if let Ok(bytes) = read_result {
10724 let decode_started = Instant::now();
10725 let mut decoder = GzDecoder::new(bytes.as_slice());
10726 let mut decoded = Vec::new();
10727 let decode_ok = decoder.read_to_end(&mut decoded).is_ok();
10728 profile.gzip_decode_micros = decode_started.elapsed().as_micros();
10729 if decode_ok {
10730 let serde_started = Instant::now();
10731 let parsed: Option<T> = serde_json::from_slice(&decoded).ok();
10732 profile.serde_decode_micros = serde_started.elapsed().as_micros();
10733 if let Some(value) = parsed {
10734 return Some((value, bytes.len() as u64, decoded.len() as u64, profile));
10735 }
10736 }
10737 }
10738
10739 let legacy_path = graph_db_backend_eval_legacy_disk_cache_path(root, kind, key);
10740 let legacy_started = Instant::now();
10741 let bytes = fs::read(legacy_path).ok()?;
10742 profile.file_read_micros = profile
10743 .file_read_micros
10744 .saturating_add(legacy_started.elapsed().as_micros());
10745 let serde_started = Instant::now();
10746 let value = serde_json::from_slice(&bytes).ok()?;
10747 profile.serde_decode_micros = profile
10748 .serde_decode_micros
10749 .saturating_add(serde_started.elapsed().as_micros());
10750 profile.legacy = true;
10751 Some((value, bytes.len() as u64, bytes.len() as u64, profile))
10752}
10753
10754#[derive(Default, Clone)]
10755struct GraphDbBackendEvalDiskCacheWriteProfile {
10756 serde_encode_micros: u128,
10757 gzip_encode_micros: u128,
10758 file_write_micros: u128,
10759}
10760
10761fn graph_db_backend_eval_write_disk_cache<T: Serialize>(
10762 root: &Path,
10763 kind: &str,
10764 key: &str,
10765 value: &T,
10766) -> Option<(u64, u64, GraphDbBackendEvalDiskCacheWriteProfile)> {
10767 let mut profile = GraphDbBackendEvalDiskCacheWriteProfile::default();
10768 let path = graph_db_backend_eval_disk_cache_path(root, kind, key);
10769 let parent = path.parent()?;
10770 if fs::create_dir_all(parent).is_err() {
10771 return None;
10772 }
10773 let serde_started = Instant::now();
10774 let bytes = serde_json::to_vec(value).ok()?;
10775 profile.serde_encode_micros = serde_started.elapsed().as_micros();
10776 let gzip_started = Instant::now();
10777 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
10778 if encoder.write_all(&bytes).is_err() {
10779 return None;
10780 }
10781 let encoded = encoder.finish().ok()?;
10782 profile.gzip_encode_micros = gzip_started.elapsed().as_micros();
10783 let write_started = Instant::now();
10784 if fs::write(&path, &encoded).is_err() {
10785 return None;
10786 }
10787 profile.file_write_micros = write_started.elapsed().as_micros();
10788 Some((encoded.len() as u64, bytes.len() as u64, profile))
10789}
10790
10791fn graph_db_backend_eval_prune_disk_cache(root: &Path, kind: &str, keep_key: &str) -> (usize, u64) {
10792 let dir = graph_db_backend_eval_disk_cache_dir(root).join(kind);
10793 let Ok(entries) = fs::read_dir(dir) else {
10794 return (0, 0);
10795 };
10796 let keep_name = format!("{keep_key}.json.gz");
10797 let mut pruned_files = 0usize;
10798 let mut pruned_bytes = 0u64;
10799 for entry in entries.flatten() {
10800 let path = entry.path();
10801 if !path.is_file() {
10802 continue;
10803 }
10804 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
10805 continue;
10806 };
10807 if name == keep_name {
10808 continue;
10809 }
10810 let is_backend_eval_cache = name.ends_with(".json") || name.ends_with(".json.gz");
10811 if !is_backend_eval_cache {
10812 continue;
10813 }
10814 let bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
10815 if fs::remove_file(&path).is_ok() {
10816 pruned_files += 1;
10817 pruned_bytes += bytes;
10818 }
10819 }
10820 (pruned_files, pruned_bytes)
10821}
10822
10823fn graph_db_backend_eval_full_projection_raw_watermark_rows(
10824 root: &Path,
10825 source_root: &Path,
10826) -> Result<Vec<GraphDbBackendEvalRawSourceWatermarkRow>> {
10827 let mut rows = Vec::new();
10828 let mut entries = walk::walk_files(source_root)?;
10829 entries.sort_by(|left, right| left.path.cmp(&right.path));
10830 for entry in entries {
10831 if traversal_path_is_generated_artifact(root, source_root, &entry.path) {
10832 continue;
10833 }
10834 if traversal_path_is_session_markdown(root, source_root, &entry.path) {
10835 continue;
10836 }
10837 let bytes = fs::read(&entry.path)
10838 .with_context(|| format!("reading source input {}", entry.path.display()))?;
10839 rows.push(GraphDbBackendEvalRawSourceWatermarkRow {
10840 path: traversal_watermark_path(root, &entry.path),
10841 bytes: bytes.len() as u64,
10842 content_hash: content_hash(&bytes)?,
10843 });
10844 }
10845 Ok(rows)
10846}
10847
10848fn graph_db_backend_eval_full_projection_source_watermark(
10849 root: &Path,
10850 scope: Option<&str>,
10851) -> Result<GraphDbBackendEvalFullProjectionSourceWatermark> {
10852 let path_hint = root;
10853 let mut detail_parts = Vec::new();
10854 let mut parts = vec![
10855 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
10856 format!("cache_version:{GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION}"),
10857 "watermark_kind:stable_full_projection_inputs".to_string(),
10858 format!("scope:{}", scope.unwrap_or("root")),
10859 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
10860 ];
10861
10862 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, "full-projection cache key");
10863 match gate.db_path.as_ref().filter(|db_path| db_path.exists()) {
10864 Some(db_path) => {
10865 let db = index::IndexDb::open_read_only_resilient(db_path)?;
10866 parts.push("index_mode:indexed".to_string());
10867 detail_parts.push("mode=indexed".to_string());
10868 parts.push(format!(
10869 "index_source_root:{}",
10870 traversal_watermark_path(root, &gate.source_root)
10871 ));
10872
10873 let symbols = db
10874 .all_symbols()?
10875 .into_iter()
10876 .filter(|symbol| {
10877 !traversal_path_is_generated_artifact(
10878 root,
10879 &gate.source_root,
10880 Path::new(&symbol.file),
10881 ) && !traversal_path_is_session_markdown(
10882 root,
10883 &gate.source_root,
10884 Path::new(&symbol.file),
10885 )
10886 })
10887 .collect::<Vec<_>>();
10888 let symbols_hash = content_hash(&symbols)?;
10889 detail_parts.push(format!("symbols={symbols_hash}"));
10890 parts.push(format!("index_symbols:{symbols_hash}"));
10891
10892 let edges = db
10893 .all_stored_edges()?
10894 .into_iter()
10895 .filter(|edge| {
10896 !traversal_path_is_generated_artifact(
10897 root,
10898 &gate.source_root,
10899 Path::new(&edge.caller_file),
10900 ) && !traversal_path_is_session_markdown(
10901 root,
10902 &gate.source_root,
10903 Path::new(&edge.caller_file),
10904 )
10905 })
10906 .collect::<Vec<_>>();
10907 let edges_hash = content_hash(&edges)?;
10908 detail_parts.push(format!("call_edges={edges_hash}"));
10909 parts.push(format!("index_call_edges:{edges_hash}"));
10910
10911 let routes = db
10912 .all_routes()?
10913 .into_iter()
10914 .filter(|route| {
10915 !traversal_path_is_generated_artifact(
10916 root,
10917 &gate.source_root,
10918 Path::new(&route.file),
10919 ) && !traversal_path_is_session_markdown(
10920 root,
10921 &gate.source_root,
10922 Path::new(&route.file),
10923 )
10924 })
10925 .collect::<Vec<_>>();
10926 let routes_hash = content_hash(&routes)?;
10927 detail_parts.push(format!("routes={routes_hash}"));
10928 parts.push(format!("index_routes:{routes_hash}"));
10929 }
10930 None => {
10931 parts.push("index_mode:raw_fallback".to_string());
10932 detail_parts.push("mode=raw_fallback".to_string());
10933 parts.push(format!(
10934 "raw_source_root:{}",
10935 traversal_watermark_path(root, &gate.source_root)
10936 ));
10937 let raw_rows =
10938 graph_db_backend_eval_full_projection_raw_watermark_rows(root, &gate.source_root)?;
10939 let raw_hash = content_hash(&raw_rows)?;
10940 detail_parts.push(format!("raw_source_files={raw_hash}"));
10941 parts.push(format!("raw_source_files:{raw_hash}"));
10942 }
10943 }
10944
10945 parts.push("agent_doc_session_markdown:bounded_real_dataset_only".to_string());
10946 detail_parts.push("session_markdown=bounded_real_dataset_only".to_string());
10947 let summaries_start = parts.len();
10948 push_traversal_summaries_watermark_part(root, &mut parts)?;
10949 let summaries_hash = content_hash(&parts[summaries_start..].to_vec())?;
10950 detail_parts.push(format!("summaries={summaries_hash}"));
10951 let value = content_hash(&parts)?;
10952 detail_parts.push(format!("watermark={value}"));
10953 Ok(GraphDbBackendEvalFullProjectionSourceWatermark {
10954 value,
10955 detail: detail_parts.join(" "),
10956 })
10957}
10958
10959fn graph_db_backend_eval_full_projection_cache_key(
10960 root: &Path,
10961 scope: Option<&str>,
10962) -> Result<(String, String, String)> {
10963 let source_watermark = graph_db_backend_eval_full_projection_source_watermark(root, scope)?;
10964 let key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
10965 root,
10966 scope,
10967 &source_watermark.value,
10968 )?;
10969 Ok((source_watermark.value, key, source_watermark.detail))
10970}
10971
10972fn graph_db_backend_eval_full_projection_cache_key_for_watermark(
10973 root: &Path,
10974 scope: Option<&str>,
10975 source_watermark: &str,
10976) -> Result<String> {
10977 content_hash(&serde_json::json!({
10978 "version": GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION,
10979 "root": root.display().to_string(),
10980 "scope": scope.unwrap_or("root"),
10981 "source_watermark": source_watermark,
10982 }))
10983}
10984
10985pub(crate) fn graph_db_backend_eval_full_projection_with_profile(
10986 root: &Path,
10987 scope: Option<&str>,
10988) -> Result<(
10989 GraphProjection,
10990 Vec<String>,
10991 Vec<GraphDbBackendEvalPhaseTiming>,
10992 GraphDbBackendEvalFullProjectionCacheStats,
10993)> {
10994 let (source_watermark, key, source_watermark_detail) =
10995 graph_db_backend_eval_full_projection_cache_key(root, scope)?;
10996 let lookup_started = Instant::now();
10997 if let Some((cached, disk_bytes, json_bytes, read_profile)) =
10998 graph_db_backend_eval_read_disk_cache::<GraphDbBackendEvalFullProjectionCache>(
10999 root,
11000 "full_projection",
11001 &key,
11002 )
11003 && cached.version == GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION
11004 && cached.key == key
11005 && cached.source_watermark == source_watermark
11006 {
11007 let lookup_overhead_micros = lookup_started
11008 .elapsed()
11009 .as_micros()
11010 .saturating_sub(read_profile.file_read_micros)
11011 .saturating_sub(read_profile.gzip_decode_micros)
11012 .saturating_sub(read_profile.serde_decode_micros);
11013 let prune_started = Instant::now();
11014 let (pruned_files, pruned_bytes) =
11015 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &key);
11016 let prune_micros = prune_started.elapsed().as_micros();
11017 let cache_stats = GraphDbBackendEvalFullProjectionCacheStats {
11018 hit: true,
11019 disk_bytes,
11020 json_bytes,
11021 pruned_files,
11022 pruned_bytes,
11023 };
11024 let read_detail_suffix = if read_profile.legacy {
11025 " (legacy uncompressed cache path)"
11026 } else {
11027 ""
11028 };
11029 return Ok((
11030 cached.projection,
11031 cached.warnings,
11032 vec![
11033 graph_db_backend_eval_phase_timing(
11034 "full_projection.cache_lookup",
11035 lookup_overhead_micros,
11036 &format!(
11037 "watermark/version check overhead around the cache load phases; {source_watermark_detail}"
11038 ),
11039 ),
11040 graph_db_backend_eval_phase_timing(
11041 "full_projection.cache.file_read",
11042 read_profile.file_read_micros,
11043 &format!(
11044 "read compressed cache bytes from .tsift/backend-eval-cache{read_detail_suffix}"
11045 ),
11046 ),
11047 graph_db_backend_eval_phase_timing(
11048 "full_projection.cache.gzip_decode",
11049 read_profile.gzip_decode_micros,
11050 "gunzip the compressed projection cache bytes",
11051 ),
11052 graph_db_backend_eval_phase_timing(
11053 "full_projection.cache.serde_decode",
11054 read_profile.serde_decode_micros,
11055 "serde_json deserialize the decoded projection cache payload",
11056 ),
11057 graph_db_backend_eval_phase_timing(
11058 "full_projection.cache.prune",
11059 prune_micros,
11060 "prune sibling cache files older than the current key",
11061 ),
11062 graph_db_backend_eval_phase_timing(
11063 "full_projection.source_graph_build",
11064 0,
11065 "reused cached full-project source graph; skipped code-index loading, session markdown scanning, source-handle construction, and semantic summary reads",
11066 ),
11067 graph_db_backend_eval_phase_timing(
11068 "full_projection.projection_rows",
11069 0,
11070 "reused cached provider-neutral full-project projection rows",
11071 ),
11072 ],
11073 cache_stats,
11074 ));
11075 }
11076
11077 let mut cache_stats = GraphDbBackendEvalFullProjectionCacheStats::default();
11078 let mut phases = vec![graph_db_backend_eval_phase_timing(
11079 "full_projection.cache_lookup",
11080 lookup_started.elapsed().as_micros(),
11081 &format!(
11082 "no full-project projection cache entry matched the source watermark; {source_watermark_detail}"
11083 ),
11084 )];
11085 let full_source = graph_db_backend_eval_timed_phase(
11086 &mut phases,
11087 "full_projection.source_graph_build",
11088 "opt-in full-project source graph build; uses the project root as the path hint so bounded session projections cannot hide full-graph regressions",
11089 || build_traversal_graph_source_with_options(root, root, scope, false),
11090 )?;
11091 let projection = graph_db_backend_eval_timed_phase(
11092 &mut phases,
11093 "full_projection.projection_rows",
11094 "provider-neutral row construction for the opt-in full-project projection dataset",
11095 || traversal_projection_from_graph(root, scope, &full_source),
11096 )?;
11097 let warnings = full_source.warnings;
11098 let refreshed_source_watermark =
11099 graph_db_backend_eval_full_projection_source_watermark(root, scope)
11100 .map(|watermark| watermark.value)
11101 .unwrap_or_else(|_| source_watermark.clone());
11102 let write_key = graph_db_backend_eval_full_projection_cache_key_for_watermark(
11103 root,
11104 scope,
11105 &refreshed_source_watermark,
11106 )?;
11107 let cache = GraphDbBackendEvalFullProjectionCache {
11108 version: GRAPH_DB_BACKEND_EVAL_FULL_PROJECTION_CACHE_VERSION.to_string(),
11109 key: write_key.clone(),
11110 source_watermark: refreshed_source_watermark,
11111 projection: projection.clone(),
11112 warnings: warnings.clone(),
11113 };
11114 if let Some((disk_bytes, json_bytes, write_profile)) =
11115 graph_db_backend_eval_write_disk_cache(root, "full_projection", &write_key, &cache)
11116 {
11117 cache_stats.disk_bytes = disk_bytes;
11118 cache_stats.json_bytes = json_bytes;
11119 phases.push(graph_db_backend_eval_phase_timing(
11120 "full_projection.cache.serde_encode",
11121 write_profile.serde_encode_micros,
11122 "serde_json serialize the projection cache payload before compression",
11123 ));
11124 phases.push(graph_db_backend_eval_phase_timing(
11125 "full_projection.cache.gzip_encode",
11126 write_profile.gzip_encode_micros,
11127 "gzip-compress the serialized projection cache payload",
11128 ));
11129 phases.push(graph_db_backend_eval_phase_timing(
11130 "full_projection.cache.file_write",
11131 write_profile.file_write_micros,
11132 "write the compressed projection cache bytes to .tsift/backend-eval-cache",
11133 ));
11134 }
11135 let prune_started = Instant::now();
11136 let (pruned_files, pruned_bytes) =
11137 graph_db_backend_eval_prune_disk_cache(root, "full_projection", &write_key);
11138 phases.push(graph_db_backend_eval_phase_timing(
11139 "full_projection.cache.prune",
11140 prune_started.elapsed().as_micros(),
11141 "prune sibling cache files older than the current key",
11142 ));
11143 cache_stats.pruned_files = pruned_files;
11144 cache_stats.pruned_bytes = pruned_bytes;
11145 Ok((projection, warnings, phases, cache_stats))
11146}
11147
11148fn graph_db_backend_eval_timed(
11149 name: &str,
11150 run: impl FnOnce() -> Result<(Option<usize>, serde_json::Value)>,
11151) -> (
11152 GraphDbBackendEvalOperation,
11153 Option<GraphDbBackendEvalSignature>,
11154) {
11155 let started = Instant::now();
11156 match run() {
11157 Ok((rows, value)) => (
11158 GraphDbBackendEvalOperation {
11159 name: name.to_string(),
11160 supported: true,
11161 status: "ok".to_string(),
11162 duration_micros: started.elapsed().as_micros(),
11163 rows,
11164 error: None,
11165 },
11166 Some(GraphDbBackendEvalSignature {
11167 operation: name.to_string(),
11168 value,
11169 }),
11170 ),
11171 Err(err) => (
11172 GraphDbBackendEvalOperation {
11173 name: name.to_string(),
11174 supported: false,
11175 status: "error".to_string(),
11176 duration_micros: started.elapsed().as_micros(),
11177 rows: None,
11178 error: Some(format!("{err:#}")),
11179 },
11180 None,
11181 ),
11182 }
11183}
11184
11185fn graph_db_backend_eval_parity(
11186 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11187 candidate_signatures: &[GraphDbBackendEvalSignature],
11188) -> GraphDbBackendEvalParity {
11189 let Some(sqlite_signatures) = sqlite_signatures else {
11190 return GraphDbBackendEvalParity {
11191 matches_sqlite: true,
11192 diagnostics: Vec::new(),
11193 };
11194 };
11195 let sqlite = sqlite_signatures
11196 .iter()
11197 .map(|signature| (signature.operation.as_str(), &signature.value))
11198 .collect::<BTreeMap<_, _>>();
11199 let candidate = candidate_signatures
11200 .iter()
11201 .map(|signature| (signature.operation.as_str(), &signature.value))
11202 .collect::<BTreeMap<_, _>>();
11203 let mut diagnostics = Vec::new();
11204 for (operation, sqlite_value) in sqlite {
11205 match candidate.get(operation) {
11206 Some(candidate_value) if *candidate_value == sqlite_value => {}
11207 Some(_) => diagnostics.push(format!("{operation} output differed from SQLite")),
11208 None => diagnostics.push(format!(
11209 "{operation} did not complete for candidate backend"
11210 )),
11211 }
11212 }
11213 GraphDbBackendEvalParity {
11214 matches_sqlite: diagnostics.is_empty(),
11215 diagnostics,
11216 }
11217}
11218
11219pub(crate) fn graph_db_backend_eval_targets(
11220 store: &impl GraphStore,
11221 requested: &[String],
11222) -> Result<Vec<String>> {
11223 let requested = requested
11224 .iter()
11225 .filter_map(|target| normalize_conflict_target(target))
11226 .collect::<Vec<_>>();
11227 if !requested.is_empty() {
11228 return Ok(requested);
11229 }
11230
11231 for kind in ["backlog", "job_packet"] {
11232 let nodes = store.nodes_by_kind(kind)?;
11233 if let Some(node) = nodes.first() {
11234 if let Some(ref_id) = node.properties.get("ref_id") {
11235 return Ok(vec![ref_id.clone()]);
11236 }
11237 return Ok(vec![node.id.clone()]);
11238 }
11239 }
11240 Ok(Vec::new())
11241}
11242
11243fn graph_db_backend_eval_path_targets(
11244 store: &impl GraphStore,
11245 max_hops: usize,
11246) -> Result<Option<(String, String, usize)>> {
11247 let synthetic_from = "gsym-synthetic-0000";
11248 let synthetic_to = format!("gsym-synthetic-{max_hops:04}");
11249 if store.node(synthetic_from)?.is_some() && store.node(&synthetic_to)?.is_some() {
11250 let outgoing = store.outgoing_edges(synthetic_from, None)?;
11251 if outgoing.len() > 1
11252 && let Some(edge) = outgoing.first()
11253 {
11254 return Ok(Some((
11255 edge.from_id.clone(),
11256 edge.to_id.clone(),
11257 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11258 )));
11259 }
11260 return Ok(Some((synthetic_from.to_string(), synthetic_to, max_hops)));
11261 }
11262
11263 Ok(store.sample_edge(None)?.map(|edge| {
11264 (
11265 edge.from_id,
11266 edge.to_id,
11267 GRAPH_DB_BACKEND_EVAL_DIRECT_PATH_HOPS,
11268 )
11269 }))
11270}
11271
11272fn graph_db_backend_eval_path_operation<S: GraphStore>(
11273 store: &S,
11274 configured_max_hops: usize,
11275) -> (
11276 GraphDbBackendEvalOperation,
11277 Option<GraphDbBackendEvalSignature>,
11278) {
11279 let operation_name = if configured_max_hops == GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11280 "path_max_hops".to_string()
11281 } else {
11282 format!("path_max_hops_{configured_max_hops}")
11283 };
11284 graph_db_backend_eval_timed(&operation_name, || {
11285 let (from, to, effective_max_hops) =
11286 graph_db_backend_eval_path_targets(store, configured_max_hops)?
11287 .context("backend-eval path probe requires at least one traversable edge")?;
11288 let path = store.shortest_path_with_max_hops(&from, &to, None, Some(effective_max_hops))?;
11289 let warning = if configured_max_hops > GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS {
11290 Some(format!(
11291 "{configured_max_hops}-hop tier is measured only; keep user-facing defaults at {} until repeated samples and SQLite query-plan checks pass",
11292 GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS
11293 ))
11294 } else if path.is_none() && effective_max_hops == configured_max_hops {
11295 Some(format!(
11296 "path probe truncated at {configured_max_hops} hops before a route was found"
11297 ))
11298 } else {
11299 None
11300 };
11301 Ok((
11302 path.as_ref().map(|path| path.nodes.len()),
11303 serde_json::json!({
11304 "from": from,
11305 "to": to,
11306 "configured_max_hops": configured_max_hops,
11307 "effective_max_hops": effective_max_hops,
11308 "hops": path.as_ref().map(|path| path.hops),
11309 "nodes": path.as_ref().map(|path| &path.nodes),
11310 "found": path.is_some(),
11311 "warning": warning,
11312 }),
11313 ))
11314 })
11315}
11316
11317fn graph_db_backend_eval_neighborhood_operation<S: GraphStore>(
11318 store: &S,
11319 depth: usize,
11320 limit: usize,
11321) -> (
11322 GraphDbBackendEvalOperation,
11323 Option<GraphDbBackendEvalSignature>,
11324) {
11325 graph_db_backend_eval_timed("neighborhood", || {
11326 let edge = match store.sample_edge(Some("calls"))? {
11327 Some(edge) => edge,
11328 None => store.sample_edge(None)?.context(
11329 "backend-eval neighborhood probe requires at least one traversable edge",
11330 )?,
11331 };
11332 let page = store
11333 .paged_neighborhood(
11334 &edge.from_id,
11335 depth,
11336 Some(&edge.kind),
11337 GraphQueryOptions {
11338 limit: Some(limit.max(1)),
11339 ..GraphQueryOptions::default()
11340 },
11341 )?
11342 .with_context(|| {
11343 format!(
11344 "backend-eval neighborhood target not found: {}",
11345 edge.from_id
11346 )
11347 })?;
11348 Ok((
11349 Some(page.nodes.len() + page.edges.len()),
11350 serde_json::json!({
11351 "center": edge.from_id,
11352 "kind": edge.kind,
11353 "depth": depth,
11354 "limit": limit.max(1),
11355 "node_ids": page.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11356 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11357 "truncated": page.page.truncated,
11358 }),
11359 ))
11360 })
11361}
11362
11363fn graph_db_backend_eval_related_operation<S: GraphStore>(
11364 root: &Path,
11365 scope: Option<&str>,
11366 store: &S,
11367 depth: usize,
11368 limit: usize,
11369) -> (
11370 GraphDbBackendEvalOperation,
11371 Option<GraphDbBackendEvalSignature>,
11372) {
11373 graph_db_backend_eval_timed("related", || {
11374 let query = "backend evaluation";
11375 let semantic = semantic_related_report_from_store(
11376 root,
11377 scope,
11378 query,
11379 3,
11380 SemanticRelatedKind::All,
11381 store,
11382 )?;
11383 let seed_ids = semantic
11384 .items
11385 .iter()
11386 .map(|item| item.handle.clone())
11387 .collect::<Vec<_>>();
11388 let subgraph =
11389 graph_db_semantic_seeded_neighborhood(store, &seed_ids, depth, limit.max(1))?;
11390 Ok((
11391 Some(subgraph.nodes.len() + subgraph.edges.len()),
11392 serde_json::json!({
11393 "query": query,
11394 "seed_ids": seed_ids,
11395 "node_ids": subgraph.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11396 "edge_ids": subgraph.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11397 "truncated": subgraph.truncated,
11398 "warnings": semantic.warnings,
11399 "diagnostics": subgraph.diagnostics,
11400 }),
11401 ))
11402 })
11403}
11404
11405fn graph_db_backend_eval_evidence_signature(report: &GraphDbEvidenceReport) -> serde_json::Value {
11406 serde_json::json!({
11407 "target": report.target,
11408 "target_node_id": report.target_node.id,
11409 "target_kind": report.target_node.kind,
11410 "worker_context": report.worker_context.iter().map(|node| &node.id).collect::<Vec<_>>(),
11411 "source_handles": report.source_handles.iter().map(|node| &node.id).collect::<Vec<_>>(),
11412 "worker_results": report.worker_results.iter().map(|node| &node.id).collect::<Vec<_>>(),
11413 "semantic_related": report.semantic_related.iter().map(|node| &node.id).collect::<Vec<_>>(),
11414 "path_count": report.shortest_paths.len(),
11415 })
11416}
11417
11418fn graph_db_backend_eval_target_resolution_signature(
11419 resolved: &[(String, SubstrateGraphNode)],
11420) -> serde_json::Value {
11421 serde_json::json!({
11422 "targets": resolved.iter().map(|(target, node)| {
11423 serde_json::json!({
11424 "target": target,
11425 "target_node_id": node.id,
11426 "target_kind": node.kind,
11427 "target_label": node.label,
11428 })
11429 }).collect::<Vec<_>>(),
11430 })
11431}
11432
11433fn graph_db_backend_eval_conflict_signature(report: &ConflictMatrixReport) -> serde_json::Value {
11434 serde_json::json!({
11435 "targets": report.targets,
11436 "can_parallel": report.can_parallel,
11437 "fail_closed": report.fail_closed,
11438 "cross_target_parallel_safe": report.cross_target_parallel_safe,
11439 "per_target_fail_closed": report.per_target_fail_closed.iter().map(|target| &target.target).collect::<Vec<_>>(),
11440 "candidates": report.candidates.iter().map(|candidate| {
11441 serde_json::json!({
11442 "target": candidate.target,
11443 "risk": conflict_risk_label(candidate.risk),
11444 "owned_files": candidate.owned_files,
11445 "owned_symbols": candidate.owned_symbols,
11446 "source_handles": candidate.source_handles.iter().map(|handle| &handle.handle).collect::<Vec<_>>(),
11447 "previously_completed": candidate.previously_completed,
11448 "parallel_safe": candidate.parallel_safe,
11449 })
11450 }).collect::<Vec<_>>(),
11451 "conflicts": report.conflicts.iter().map(|pair| {
11452 serde_json::json!({
11453 "left": pair.left,
11454 "right": pair.right,
11455 "risk": conflict_risk_label(pair.risk),
11456 })
11457 }).collect::<Vec<_>>(),
11458 })
11459}
11460
11461fn graph_db_backend_eval_dispatch_signature(report: &DispatchTraceReport) -> serde_json::Value {
11462 serde_json::json!({
11463 "targets": report.targets,
11464 "node_ids": report.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
11465 "edge_keys": report.edges.iter().map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))).collect::<Vec<_>>(),
11466 "evidence_packet_ids": report.evidence_packet_ids,
11467 "worker_prompt_targets": report.worker_prompt_packets.iter().map(|packet| &packet.target).collect::<Vec<_>>(),
11468 "truncated": report.truncated,
11469 })
11470}
11471
11472fn graph_db_backend_eval_edge_scan_probe(
11473 store: &impl GraphStore,
11474) -> Result<(SubstrateGraphEdge, Vec<GraphPropertyFilter>)> {
11475 if let Some((edge, filter)) = store.sample_edge_with_property()? {
11476 return Ok((edge, vec![filter]));
11477 }
11478 let edge = store
11479 .sample_edge(None)?
11480 .context("backend-eval edge scan requires at least one edge")?;
11481 Ok((edge, Vec::new()))
11482}
11483
11484#[allow(clippy::too_many_arguments)]
11485fn graph_db_backend_eval_report_for_store<S: GraphStore>(
11486 backend: &str,
11487 adapter: &str,
11488 read_only: bool,
11489 root: &Path,
11490 path: &Path,
11491 scope: Option<&str>,
11492 targets: &[String],
11493 depth: usize,
11494 limit: usize,
11495 impact_limit: usize,
11496 store: &S,
11497 freshness: GraphDbFreshnessReport,
11498 refresh_operation: GraphDbBackendEvalOperation,
11499 refresh_signature: Option<GraphDbBackendEvalSignature>,
11500 sqlite_signatures: Option<&[GraphDbBackendEvalSignature]>,
11501 extra_warnings: Vec<String>,
11502 prepared: &ConflictMatrixPreparedInputs,
11503 projection_load: &str,
11504 lock_behavior: &str,
11505 install_portability: &str,
11506) -> (
11507 GraphDbBackendEvalBackendReport,
11508 Vec<GraphDbBackendEvalSignature>,
11509) {
11510 let mut operations = vec![refresh_operation];
11511 let mut signatures = refresh_signature.into_iter().collect::<Vec<_>>();
11512
11513 let (operation, signature) = graph_db_backend_eval_timed("status", || {
11514 let (nodes, edges) = store.graph_counts()?;
11515 Ok((
11516 Some(nodes + edges),
11517 serde_json::json!({
11518 "freshness": freshness.status,
11519 "nodes": nodes,
11520 "edges": edges,
11521 }),
11522 ))
11523 });
11524 operations.push(operation);
11525 signatures.extend(signature);
11526
11527 let (operation, signature) = graph_db_backend_eval_timed("edge_lookup", || {
11528 let edge = store
11529 .sample_edge(None)?
11530 .context("backend-eval edge lookup requires at least one edge")?;
11531 let edge_id = graph_db_edge_key(&edge);
11532 let found = store
11533 .edge(&edge_id)?
11534 .with_context(|| format!("backend-eval edge lookup missed {edge_id}"))?;
11535 Ok((
11536 Some(1),
11537 serde_json::json!({
11538 "edge_id": edge_id,
11539 "from_id": found.from_id,
11540 "to_id": found.to_id,
11541 "kind": found.kind,
11542 }),
11543 ))
11544 });
11545 operations.push(operation);
11546 signatures.extend(signature);
11547
11548 let (operation, signature) = graph_db_backend_eval_timed("edge_property_scan", || {
11549 let (edge, filters) = graph_db_backend_eval_edge_scan_probe(store)?;
11550 let page = store.paged_edges(
11551 Some(&edge.kind),
11552 GraphQueryOptions {
11553 limit: Some(limit.max(1)),
11554 property_filters: filters.clone(),
11555 ..GraphQueryOptions::default()
11556 },
11557 )?;
11558 Ok((
11559 Some(page.edges.len()),
11560 serde_json::json!({
11561 "kind": edge.kind,
11562 "filters": filters.iter().map(|filter| format!("{}={}", filter.key, filter.value)).collect::<Vec<_>>(),
11563 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11564 "truncated": page.page.truncated,
11565 }),
11566 ))
11567 });
11568 operations.push(operation);
11569 signatures.extend(signature);
11570
11571 let (operation, signature) = graph_db_backend_eval_timed("incident_edges", || {
11572 let edge = store
11573 .sample_edge(None)?
11574 .context("backend-eval incident edge scan requires at least one edge")?;
11575 let page = store.paged_incident_edges(
11576 &edge.from_id,
11577 Some(&edge.kind),
11578 GraphQueryOptions {
11579 limit: Some(limit.max(1)),
11580 ..GraphQueryOptions::default()
11581 },
11582 )?;
11583 Ok((
11584 Some(page.edges.len()),
11585 serde_json::json!({
11586 "node_id": edge.from_id,
11587 "kind": edge.kind,
11588 "edge_ids": page.edges.iter().map(graph_db_edge_key).collect::<Vec<_>>(),
11589 "truncated": page.page.truncated,
11590 }),
11591 ))
11592 });
11593 operations.push(operation);
11594 signatures.extend(signature);
11595
11596 let (operation, signature) = graph_db_backend_eval_neighborhood_operation(store, depth, limit);
11597 operations.push(operation);
11598 signatures.extend(signature);
11599
11600 let (operation, signature) =
11601 graph_db_backend_eval_related_operation(root, scope, store, depth, limit);
11602 operations.push(operation);
11603 signatures.extend(signature);
11604
11605 for configured_max_hops in std::iter::once(GRAPH_DB_BACKEND_EVAL_PATH_MAX_HOPS)
11606 .chain(GRAPH_DB_BACKEND_EVAL_EXTENDED_PATH_HOPS)
11607 {
11608 let (operation, signature) =
11609 graph_db_backend_eval_path_operation(store, configured_max_hops);
11610 operations.push(operation);
11611 signatures.extend(signature);
11612 }
11613
11614 let (operation, signature) = graph_db_backend_eval_timed("evidence_target_resolution", || {
11615 let resolved = targets
11616 .iter()
11617 .map(|target| {
11618 let node = graph_db_resolve_evidence_target(store, target)?
11619 .with_context(|| format!("backend-eval target not found: {target}"))?;
11620 Ok((target.clone(), node))
11621 })
11622 .collect::<Result<Vec<_>>>()?;
11623 let signature = graph_db_backend_eval_target_resolution_signature(&resolved);
11624 Ok((Some(resolved.len()), signature))
11625 });
11626 operations.push(operation);
11627 signatures.extend(signature);
11628
11629 let mut evidence_for_report = None;
11630 let mut graph_snapshot_for_trace = None;
11631 let (operation, signature) = graph_db_backend_eval_timed("evidence", || {
11632 let resolved_targets =
11633 resolve_conflict_matrix_targets(store, targets, &prepared.context_pack)?;
11634 let evidence = collect_conflict_matrix_evidence_packets(
11635 root,
11636 scope,
11637 backend,
11638 &resolved_targets,
11639 depth,
11640 limit,
11641 store,
11642 freshness.clone(),
11643 )?;
11644 let report = &evidence
11645 .first()
11646 .context("backend-eval evidence requires at least one target")?
11647 .report;
11648 let rows = evidence
11649 .iter()
11650 .map(|entry| {
11651 entry.report.worker_context.len()
11652 + entry.report.source_handles.len()
11653 + entry.report.worker_results.len()
11654 + entry.report.semantic_related.len()
11655 })
11656 .sum();
11657 let signature = graph_db_backend_eval_evidence_signature(report);
11658 evidence_for_report = Some((resolved_targets, evidence));
11659 Ok((Some(rows), signature))
11660 });
11661 operations.push(operation);
11662 signatures.extend(signature);
11663
11664 let mut conflict_for_trace = None;
11665 let (operation, signature) = graph_db_backend_eval_timed("conflict_matrix", || {
11666 let graph_prepared = if let Some((targets, evidence)) = evidence_for_report.take() {
11667 let graph =
11668 conflict_matrix_target_scoped_graph_snapshot(store, &evidence, depth, limit)?;
11669 let shared_preparation =
11670 conflict_matrix_shared_preparation_summary(&graph, &evidence, "memory_reuse");
11671 ConflictMatrixGraphPreparedInputs {
11672 targets,
11673 graph,
11674 evidence,
11675 shared_preparation,
11676 }
11677 } else {
11678 prepare_conflict_matrix_graph_orchestration(
11679 root,
11680 scope,
11681 backend,
11682 targets,
11683 prepared,
11684 depth,
11685 limit,
11686 store,
11687 freshness.clone(),
11688 )?
11689 };
11690 let report = build_conflict_matrix_report_from_prepared_graph(
11691 root,
11692 path,
11693 scope,
11694 depth,
11695 limit,
11696 impact_limit,
11697 freshness.clone(),
11698 extra_warnings.clone(),
11699 prepared,
11700 &graph_prepared,
11701 )?;
11702 let signature = graph_db_backend_eval_conflict_signature(&report);
11703 let rows = report.candidates.len() + report.conflicts.len();
11704 conflict_for_trace = Some(report);
11705 graph_snapshot_for_trace = Some(graph_prepared.graph);
11706 Ok((Some(rows), signature))
11707 });
11708 operations.push(operation);
11709 signatures.extend(signature);
11710
11711 let (operation, signature) = graph_db_backend_eval_timed("dispatch_trace", || {
11712 let conflict = conflict_for_trace
11713 .take()
11714 .context("backend-eval dispatch-trace requires a completed conflict-matrix report")?;
11715 let graph = graph_snapshot_for_trace
11716 .take()
11717 .context("backend-eval dispatch-trace requires conflict-matrix graph preparation")?;
11718 let report = build_dispatch_trace_report_from_conflict_snapshot(
11719 root,
11720 scope,
11721 conflict,
11722 graph.nodes,
11723 graph.edges,
11724 depth,
11725 limit,
11726 Vec::new(),
11727 )?;
11728 Ok((
11729 Some(report.nodes.len() + report.edges.len()),
11730 graph_db_backend_eval_dispatch_signature(&report),
11731 ))
11732 });
11733 operations.push(operation);
11734 signatures.extend(signature);
11735
11736 let total_micros = operations
11737 .iter()
11738 .map(|operation| operation.duration_micros)
11739 .sum();
11740 let parity = graph_db_backend_eval_parity(sqlite_signatures, &signatures);
11741 (
11742 GraphDbBackendEvalBackendReport {
11743 backend: backend.to_string(),
11744 adapter: adapter.to_string(),
11745 read_only,
11746 projection_load: projection_load.to_string(),
11747 operations,
11748 total_micros,
11749 parity,
11750 lock_behavior: lock_behavior.to_string(),
11751 install_portability: install_portability.to_string(),
11752 },
11753 signatures,
11754 )
11755}
11756
11757pub(crate) fn graph_db_backend_eval_refresh_operation(
11758 duration_micros: u128,
11759 rows: usize,
11760 value: serde_json::Value,
11761) -> (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature) {
11762 (
11763 GraphDbBackendEvalOperation {
11764 name: "refresh".to_string(),
11765 supported: true,
11766 status: "ok".to_string(),
11767 duration_micros,
11768 rows: Some(rows),
11769 error: None,
11770 },
11771 GraphDbBackendEvalSignature {
11772 operation: "refresh".to_string(),
11773 value,
11774 },
11775 )
11776}
11777
11778pub(crate) fn graph_db_backend_eval_synthetic_projection(
11779 nodes: usize,
11780 fanout: usize,
11781) -> GraphProjection {
11782 let nodes = nodes.max(12);
11783 let symbol_count = nodes.saturating_sub(9).max(1);
11784 let source = GraphProvenance::new("backend-eval", "synthetic");
11785 let mut projection_nodes = vec![
11786 SubstrateGraphNode::new(
11787 "projection:tsift-traversal:synthetic",
11788 GRAPH_PROJECTION_META_KIND,
11789 "synthetic projection",
11790 )
11791 .with_property("projection_version", GRAPH_PROJECTION_VERSION)
11792 .with_property(
11793 "content_hash",
11794 format!("synthetic-{nodes}-{fanout}-{symbol_count}"),
11795 )
11796 .with_provenance(source.clone()),
11797 SubstrateGraphNode::new("gses-synthetic", "session", "synthetic session")
11798 .with_property("ref_id", "synthetic-session"),
11799 SubstrateGraphNode::new("gbak-synthetic", "backlog", "#synthetic")
11800 .with_property("ref_id", "synthetic")
11801 .with_property("path", "tasks/software/synthetic.md")
11802 .with_property("line", "1")
11803 .with_property(
11804 "expand",
11805 "tsift --envelope source-read tasks/software/synthetic.md --style window --start 1 --lines 40 --budget normal",
11806 ),
11807 SubstrateGraphNode::new("gjob-synthetic", "job_packet", "do #synthetic")
11808 .with_property("ref_id", "synthetic"),
11809 SubstrateGraphNode::new("gwctx-synthetic", "worker_context", "synthetic context")
11810 .with_property("target", "synthetic")
11811 .with_property("summary", "Synthetic worker owns synthetic.rs")
11812 .with_property(
11813 "expand",
11814 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11815 ),
11816 SubstrateGraphNode::new("gsrc-synthetic", "source_handle", "synthetic.rs:1-80")
11817 .with_property("file", "synthetic.rs")
11818 .with_property("start", "1")
11819 .with_property("end", "80")
11820 .with_property(
11821 "expand",
11822 "tsift --envelope source-read synthetic.rs --style window --start 1 --lines 80 --budget normal",
11823 ),
11824 SubstrateGraphNode::new("gfil-synthetic", "file", "synthetic.rs")
11825 .with_property("path", "synthetic.rs"),
11826 SubstrateGraphNode::new("gsem-synthetic", "semantic_concept", "backend evaluation")
11827 .with_property("handle", "gsem-synthetic")
11828 .with_property("label", "backend evaluation")
11829 .with_property("embedding_model", SEMANTIC_EMBEDDING_MODEL)
11830 .with_property(
11831 "embedding",
11832 semantic_embedding_property("backend evaluation"),
11833 ),
11834 SubstrateGraphNode::new("gwres-synthetic", "worker_result", "completed #synthetic")
11835 .with_property("ref_id", "synthetic")
11836 .with_property("status", "completed")
11837 .with_property("touched_files", "synthetic.rs")
11838 .with_property("expected_tests", "cargo test --test graph_db_conformance"),
11839 ];
11840 for idx in 0..symbol_count {
11841 projection_nodes.push(
11842 SubstrateGraphNode::new(
11843 format!("gsym-synthetic-{idx:04}"),
11844 "symbol",
11845 format!("synthetic_symbol_{idx:04}"),
11846 )
11847 .with_property("ref_id", format!("synthetic_symbol_{idx:04}"))
11848 .with_property("path", "synthetic.rs")
11849 .with_property("line", (idx + 1).to_string()),
11850 );
11851 }
11852
11853 let mut projection_edges = vec![
11854 SubstrateGraphEdge::new("gses-synthetic", "gbak-synthetic", "contains"),
11855 SubstrateGraphEdge::new("gses-synthetic", "gjob-synthetic", "queues"),
11856 SubstrateGraphEdge::new("gbak-synthetic", "gwctx-synthetic", "has_context"),
11857 SubstrateGraphEdge::new("gjob-synthetic", "gwctx-synthetic", "has_context"),
11858 SubstrateGraphEdge::new("gwctx-synthetic", "gsrc-synthetic", "uses_source"),
11859 SubstrateGraphEdge::new("gbak-synthetic", "gwres-synthetic", "has_worker_result"),
11860 SubstrateGraphEdge::new("gbak-synthetic", "gsem-synthetic", "mentions_concept"),
11861 SubstrateGraphEdge::new("gsrc-synthetic", "gfil-synthetic", "reads_file"),
11862 SubstrateGraphEdge::new("gfil-synthetic", "gsym-synthetic-0000", "defines"),
11863 ];
11864 for idx in 0..symbol_count {
11865 let from = format!("gsym-synthetic-{idx:04}");
11866 for offset in 1..=fanout.max(1).min(symbol_count) {
11867 let to_idx = (idx + offset) % symbol_count;
11868 if to_idx != idx {
11869 projection_edges.push(SubstrateGraphEdge::new(
11870 from.clone(),
11871 format!("gsym-synthetic-{to_idx:04}"),
11872 "calls",
11873 ));
11874 }
11875 }
11876 }
11877
11878 GraphProjection {
11879 nodes: projection_nodes,
11880 edges: projection_edges
11881 .into_iter()
11882 .map(|edge| {
11883 edge.with_property("dataset", "synthetic")
11884 .with_provenance(source.clone())
11885 })
11886 .collect(),
11887 }
11888}
11889
11890pub(crate) fn graph_db_backend_eval_promotion(
11891 datasets: &[GraphDbBackendEvalDataset],
11892 candidates: &[GraphDbExperimentalBackend],
11893) -> Vec<GraphDbBackendPromotionDecision> {
11894 let mut decisions = Vec::new();
11895 for candidate in candidates {
11896 let mut reasons = Vec::new();
11897 let mut faster_everywhere = true;
11898 let mut parity_everywhere = true;
11899 for dataset in datasets {
11900 let Some(sqlite_report) = dataset
11901 .backends
11902 .iter()
11903 .find(|backend| backend.backend == "sqlite")
11904 else {
11905 parity_everywhere = false;
11906 faster_everywhere = false;
11907 reasons.push(format!(
11908 "{} dataset is missing SQLite baseline",
11909 dataset.name
11910 ));
11911 continue;
11912 };
11913 let sqlite_total = sqlite_report.total_micros;
11914 let Some(candidate_report) = dataset
11915 .backends
11916 .iter()
11917 .find(|backend| backend.backend == candidate.name())
11918 else {
11919 parity_everywhere = false;
11920 reasons.push(format!("{} dataset did not run", dataset.name));
11921 continue;
11922 };
11923 if !candidate_report.parity.matches_sqlite {
11924 parity_everywhere = false;
11925 reasons.push(format!("{} parity differed from SQLite", dataset.name));
11926 }
11927 if candidate_report.total_micros >= sqlite_total {
11928 faster_everywhere = false;
11929 reasons.push(format!(
11930 "{} total {}us did not beat SQLite {}us",
11931 dataset.name, candidate_report.total_micros, sqlite_total
11932 ));
11933 }
11934 let sqlite_operations = sqlite_report
11935 .operations
11936 .iter()
11937 .map(|operation| (operation.name.as_str(), operation.duration_micros))
11938 .collect::<BTreeMap<_, _>>();
11939 for operation in &candidate_report.operations {
11940 if let Some(sqlite_duration) = sqlite_operations.get(operation.name.as_str())
11941 && operation.duration_micros >= *sqlite_duration
11942 {
11943 faster_everywhere = false;
11944 reasons.push(format!(
11945 "{} {} operation {}us did not beat SQLite {}us",
11946 dataset.name, operation.name, operation.duration_micros, sqlite_duration
11947 ));
11948 }
11949 }
11950 if candidate_report
11951 .operations
11952 .iter()
11953 .any(|operation| operation.status != "ok")
11954 {
11955 parity_everywhere = false;
11956 reasons.push(format!("{} has failed benchmark operations", dataset.name));
11957 }
11958 }
11959 let decision = if let Some(reason) = candidate.prototype_hold_reason() {
11960 reasons.push(reason.to_string());
11961 reasons.push(
11962 "current bounded prototype timings are benchmark evidence, not a backend switch approval"
11963 .to_string(),
11964 );
11965 "hold"
11966 } else if parity_everywhere && faster_everywhere {
11967 reasons.push(
11968 "prototype gate passed; production promotion still requires the real engine adapter to preserve SQLite's bundled install and multi-process lock behavior"
11969 .to_string(),
11970 );
11971 "eligible"
11972 } else {
11973 reasons.push(
11974 "production promotion requires SQLite parity plus lower total time for every measured operation on every dataset without worse lock behavior or install portability"
11975 .to_string(),
11976 );
11977 "hold"
11978 };
11979 decisions.push(GraphDbBackendPromotionDecision {
11980 backend: candidate.name().to_string(),
11981 decision: decision.to_string(),
11982 reasons: dedupe_preserve_order(reasons),
11983 gate: candidate.promotion_gate(),
11984 });
11985 }
11986 decisions
11987}
11988
11989pub(crate) fn graph_db_backend_eval_metrics(
11990 datasets: &[GraphDbBackendEvalDataset],
11991) -> BTreeMap<String, f64> {
11992 let mut metrics = BTreeMap::new();
11993 for dataset in datasets {
11994 let graph_rows = graph_db_backend_eval_graph_rows(dataset);
11995 metrics.insert(format!("{}.nodes", dataset.name), dataset.nodes as f64);
11996 metrics.insert(format!("{}.edges", dataset.name), dataset.edges as f64);
11997 metrics.insert(format!("{}.graph_rows", dataset.name), graph_rows as f64);
11998 for backend in &dataset.backends {
11999 let prefix = format!("{}.{}", dataset.name, backend.backend.replace('-', "_"));
12000 metrics.insert(
12001 format!("{prefix}.total_duration_micros"),
12002 backend.total_micros as f64,
12003 );
12004 append_graph_db_backend_eval_normalized_duration_metric(
12005 &mut metrics,
12006 &format!("{prefix}.total_duration_micros_per_1k_graph_rows"),
12007 backend.total_micros,
12008 graph_rows,
12009 );
12010 for operation in &backend.operations {
12011 metrics.insert(
12012 format!("{prefix}.{}.duration_micros", operation.name),
12013 operation.duration_micros as f64,
12014 );
12015 append_graph_db_backend_eval_normalized_duration_metric(
12016 &mut metrics,
12017 &format!(
12018 "{prefix}.{}.duration_micros_per_1k_graph_rows",
12019 operation.name
12020 ),
12021 operation.duration_micros,
12022 graph_rows,
12023 );
12024 if let Some(rows) = operation.rows {
12025 metrics.insert(format!("{prefix}.{}.rows", operation.name), rows as f64);
12026 }
12027 }
12028 }
12029 }
12030 metrics
12031}
12032
12033pub(crate) fn graph_db_backend_eval_graph_rows(dataset: &GraphDbBackendEvalDataset) -> usize {
12034 dataset.nodes + dataset.edges
12035}
12036
12037pub(crate) fn append_graph_db_backend_eval_normalized_duration_metric(
12038 metrics: &mut BTreeMap<String, f64>,
12039 key: &str,
12040 duration_micros: u128,
12041 graph_rows: usize,
12042) {
12043 if graph_rows == 0 {
12044 return;
12045 }
12046 metrics.insert(
12047 key.to_string(),
12048 duration_micros as f64 / graph_rows as f64 * GRAPH_DB_BACKEND_EVAL_NORMALIZATION_ROW_UNIT,
12049 );
12050}
12051
12052pub(crate) fn append_graph_db_backend_eval_phase_metrics(
12053 metrics: &mut BTreeMap<String, f64>,
12054 dataset: &str,
12055 graph_rows: usize,
12056 phases: &[GraphDbBackendEvalPhaseTiming],
12057) {
12058 for phase in phases {
12059 metrics.insert(
12060 format!("{dataset}.refresh_phase.{}.duration_micros", phase.name),
12061 phase.duration_micros as f64,
12062 );
12063 append_graph_db_backend_eval_normalized_duration_metric(
12064 metrics,
12065 &format!(
12066 "{dataset}.refresh_phase.{}.duration_micros_per_1k_graph_rows",
12067 phase.name
12068 ),
12069 phase.duration_micros,
12070 graph_rows,
12071 );
12072 }
12073}
12074
12075fn graph_db_backend_eval_base_command(
12076 root: &Path,
12077 scope: Option<&str>,
12078 full_projection: bool,
12079) -> String {
12080 let full_projection_arg = if full_projection {
12081 " --full-projection"
12082 } else {
12083 ""
12084 };
12085 format!(
12086 "tsift graph-db --path {}{} --json backend-eval{}",
12087 shell_quote(root.to_string_lossy().as_ref()),
12088 graph_db_scope_arg(scope),
12089 full_projection_arg
12090 )
12091}
12092
12093pub(crate) fn graph_db_backend_eval_metric_digest_command(
12094 root: &Path,
12095 scope: Option<&str>,
12096 full_projection: bool,
12097) -> String {
12098 format!(
12099 "{} | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12100 graph_db_backend_eval_base_command(root, scope, full_projection)
12101 )
12102}
12103
12104fn graph_db_backend_eval_repeated_sample_command(
12105 root: &Path,
12106 scope: Option<&str>,
12107 full_projection: bool,
12108) -> String {
12109 format!(
12110 "for sample in 1 2 3; do {}; done | tsift metric-digest --baseline fixtures/graph-db-performance-history.json",
12111 graph_db_backend_eval_base_command(root, scope, full_projection)
12112 )
12113}
12114
12115fn graph_db_backend_eval_hop_cap_promotion_gate() -> GraphDbHopCapPromotionGate {
12116 let mut required_metrics = Vec::new();
12117 for workload in perf_gate::HOP_CAP_REQUIRED_WORKLOADS {
12118 required_metrics.push(format!("{workload}.sqlite.path_max_hops.duration_micros"));
12119 required_metrics.push(format!("{workload}.sqlite.path_max_hops.rows"));
12120 for hops in perf_gate::HOP_CAP_CANDIDATE_TIERS {
12121 required_metrics.push(format!(
12122 "{workload}.sqlite.path_max_hops_{hops}.duration_micros"
12123 ));
12124 required_metrics.push(format!("{workload}.sqlite.path_max_hops_{hops}.rows"));
12125 }
12126 }
12127 GraphDbHopCapPromotionGate {
12128 status: "hold_64_default_until_gate_passes".to_string(),
12129 current_default_hops: perf_gate::HOP_CAP_CURRENT_DEFAULT,
12130 candidate_hop_tiers: perf_gate::HOP_CAP_CANDIDATE_TIERS.to_vec(),
12131 required_backend: perf_gate::BASELINE_BACKEND.to_string(),
12132 required_workloads: perf_gate::HOP_CAP_REQUIRED_WORKLOADS
12133 .iter()
12134 .map(|workload| (*workload).to_string())
12135 .collect(),
12136 required_metrics,
12137 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12138 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12139 decision_rule:
12140 "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"
12141 .to_string(),
12142 }
12143}
12144
12145fn graph_db_backend_eval_backend_adapter_spike_gate() -> GraphDbBackendAdapterSpikeGate {
12146 let candidate_backends = [
12147 GraphDbExperimentalBackend::Falkordb,
12148 GraphDbExperimentalBackend::Kuzu,
12149 GraphDbExperimentalBackend::Surrealdb,
12150 ]
12151 .into_iter()
12152 .map(|backend| GraphDbBackendAdapterSpikeCandidate {
12153 backend: backend.name().to_string(),
12154 adapter_label: backend.adapter_label().to_string(),
12155 projection_load: backend.projection_load().to_string(),
12156 lock_behavior: backend.lock_behavior().to_string(),
12157 install_portability: backend.install_portability().to_string(),
12158 })
12159 .collect();
12160
12161 GraphDbBackendAdapterSpikeGate {
12162 status: "hold_real_optional_adapter_required".to_string(),
12163 candidate_backends,
12164 required_workloads: perf_gate::GATE_WORKLOAD_PREFIXES
12165 .iter()
12166 .map(|workload| (*workload).to_string())
12167 .collect(),
12168 required_checks: vec![
12169 "real_optional_adapter_behind_graphstore_without_default_build_dependency".to_string(),
12170 "projection_load_writes_provider_neutral_rows_without_sqlite_row_replay".to_string(),
12171 "freshness_and_full_parity_match_sqlite_on_every_graphstore_operation".to_string(),
12172 "lock_semantics_match_or_beat_sqlite_for_writer_and_read_only_workflows".to_string(),
12173 "install_portability_preserves_cargo_build_install_without_external_service_or_native_toolchain"
12174 .to_string(),
12175 "full_projection_cache_hit_sample_before_backend_or_hop_cap_changes".to_string(),
12176 "beats_sqlite_on_every_required_workload_and_metric_in_backend_eval".to_string(),
12177 ],
12178 decision_rule:
12179 "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"
12180 .to_string(),
12181 evidence_plan: "plans/gback-evidence.md".to_string(),
12182 }
12183}
12184
12185pub(crate) fn graph_db_backend_eval_performance_gate(
12186 root: &Path,
12187 scope: Option<&str>,
12188 full_projection: bool,
12189) -> GraphDbBackendEvalPerformanceGate {
12190 let mut required_metrics = vec![
12191 "real.sqlite.refresh.duration_micros".to_string(),
12192 "real.sqlite.refresh.duration_micros_per_1k_graph_rows".to_string(),
12193 "real.sqlite.edge_lookup.duration_micros_per_1k_graph_rows".to_string(),
12194 "real.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows".to_string(),
12195 "real.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12196 "real.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12197 "real.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows".to_string(),
12198 "real.sqlite.evidence.duration_micros_per_1k_graph_rows".to_string(),
12199 "real.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12200 "real.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows".to_string(),
12201 "real.refresh_phase.sqlite_delta_write.duration_micros".to_string(),
12202 "real.refresh_phase.sqlite_property_row_staging.duration_micros".to_string(),
12203 "real.refresh_phase.sqlite_edge_property_row_staging.duration_micros".to_string(),
12204 "real.sqlite.conflict_matrix.duration_micros".to_string(),
12205 "real.sqlite.dispatch_trace.duration_micros".to_string(),
12206 "real.sqlite.path_max_hops.duration_micros".to_string(),
12207 "real.sqlite.path_max_hops_128.duration_micros".to_string(),
12208 "real.sqlite.path_max_hops_256.duration_micros".to_string(),
12209 "real.sqlite.path_max_hops_512.duration_micros".to_string(),
12210 "real.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows".to_string(),
12211 "real.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows".to_string(),
12212 "real.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows".to_string(),
12213 "synthetic_high_degree.sqlite.total_duration_micros".to_string(),
12214 "synthetic_high_degree.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12215 "synthetic_high_degree.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12216 "synthetic_high_degree.sqlite.edge_property_scan.duration_micros_per_1k_graph_rows"
12217 .to_string(),
12218 "synthetic_high_degree.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12219 .to_string(),
12220 "synthetic_deep_chain.sqlite.incident_edges.duration_micros_per_1k_graph_rows".to_string(),
12221 "synthetic_deep_chain.sqlite.neighborhood.duration_micros_per_1k_graph_rows".to_string(),
12222 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros".to_string(),
12223 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros".to_string(),
12224 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros".to_string(),
12225 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros".to_string(),
12226 "synthetic_deep_chain.sqlite.evidence_target_resolution.duration_micros_per_1k_graph_rows"
12227 .to_string(),
12228 "synthetic_deep_chain.sqlite.path_max_hops.duration_micros_per_1k_graph_rows".to_string(),
12229 "synthetic_deep_chain.sqlite.path_max_hops_128.duration_micros_per_1k_graph_rows"
12230 .to_string(),
12231 "synthetic_deep_chain.sqlite.path_max_hops_256.duration_micros_per_1k_graph_rows"
12232 .to_string(),
12233 "synthetic_deep_chain.sqlite.path_max_hops_512.duration_micros_per_1k_graph_rows"
12234 .to_string(),
12235 ];
12236 if full_projection {
12237 required_metrics.extend([
12238 "full_projection.cache.hit".to_string(),
12239 "full_projection.cache.disk_bytes".to_string(),
12240 "full_projection.cache.compression_ratio".to_string(),
12241 "full_projection.refresh_phase.cache_lookup.duration_micros".to_string(),
12242 "full_projection.sqlite.total_duration_micros_per_1k_graph_rows".to_string(),
12243 "full_projection.refresh_phase.source_graph_build.duration_micros_per_1k_graph_rows"
12244 .to_string(),
12245 "full_projection.refresh_phase.projection_rows.duration_micros_per_1k_graph_rows"
12246 .to_string(),
12247 "full_projection.sqlite.sqlite_delta_write.duration_micros".to_string(),
12248 "full_projection.sqlite.sqlite_node_staging.duration_micros".to_string(),
12249 "full_projection.sqlite.post_write_reads.duration_micros".to_string(),
12250 "full_projection.sqlite.neighborhood.duration_micros".to_string(),
12251 "full_projection.sqlite.evidence_target_resolution.duration_micros".to_string(),
12252 "full_projection.sqlite.evidence.duration_micros".to_string(),
12253 "full_projection.sqlite.path_max_hops.duration_micros".to_string(),
12254 "full_projection.sqlite.path_max_hops_128.duration_micros".to_string(),
12255 "full_projection.sqlite.path_max_hops_256.duration_micros".to_string(),
12256 "full_projection.sqlite.path_max_hops_512.duration_micros".to_string(),
12257 "full_projection.sqlite.conflict_matrix.duration_micros".to_string(),
12258 "full_projection.sqlite.dispatch_trace.duration_micros".to_string(),
12259 ]);
12260 }
12261 GraphDbBackendEvalPerformanceGate {
12262 baseline_fixture: "fixtures/graph-db-performance-history.json".to_string(),
12263 ci_profile: "synthetic_high_degree + synthetic_deep_chain metrics are CI-safe and bounded"
12264 .to_string(),
12265 opt_in_real_profile:
12266 "pass --full-projection to add the full-project dataset when checking for large projection regressions"
12267 .to_string(),
12268 full_projection_cache_hit_gate: if full_projection {
12269 "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"
12270 .to_string()
12271 } else {
12272 "not evaluated until --full-projection is enabled".to_string()
12273 },
12274 allowed_regression_percent: GRAPH_DB_BACKEND_EVAL_ALLOWED_REGRESSION_PERCENT,
12275 minimum_sample_runs: GRAPH_DB_BACKEND_EVAL_MIN_SAMPLE_RUNS,
12276 normalized_metric_unit: "duration_micros_per_1k_graph_rows".to_string(),
12277 required_metrics,
12278 digest_command: graph_db_backend_eval_metric_digest_command(root, scope, full_projection),
12279 repeated_sample_command: graph_db_backend_eval_repeated_sample_command(
12280 root,
12281 scope,
12282 full_projection,
12283 ),
12284 hop_cap_promotion: graph_db_backend_eval_hop_cap_promotion_gate(),
12285 backend_adapter_spike: graph_db_backend_eval_backend_adapter_spike_gate(),
12286 }
12287}
12288
12289#[cfg(feature = "backend-surrealdb")]
12290fn graph_db_backend_eval_path_segment(value: &str) -> String {
12291 value
12292 .chars()
12293 .map(|ch| {
12294 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
12295 ch
12296 } else {
12297 '_'
12298 }
12299 })
12300 .collect()
12301}
12302
12303#[cfg(feature = "backend-surrealdb")]
12304fn graph_db_backend_eval_surrealdb_store_path(
12305 root: &Path,
12306 scope: Option<&str>,
12307 dataset: &str,
12308) -> PathBuf {
12309 root.join(".tsift/backend-eval-cache/surrealdb")
12310 .join(graph_db_backend_eval_path_segment(scope.unwrap_or("root")))
12311 .join(graph_db_backend_eval_path_segment(dataset))
12312 .join("surrealkv")
12313}
12314
12315pub(crate) struct GraphDbBackendEvalOptions<'a> {
12316 path: &'a Path,
12317 scope: Option<&'a str>,
12318 candidates: &'a [String],
12319 targets: &'a [String],
12320 full_projection: bool,
12321}
12322
12323#[allow(clippy::too_many_arguments)]
12324pub(crate) fn graph_db_backend_eval_dataset(
12325 name: &str,
12326 root: &Path,
12327 path: &Path,
12328 scope: Option<&str>,
12329 targets: &[String],
12330 depth: usize,
12331 limit: usize,
12332 impact_limit: usize,
12333 candidates: &[GraphDbExperimentalBackend],
12334 sqlite_store: &SqliteGraphStore,
12335 sqlite_freshness: GraphDbFreshnessReport,
12336 sqlite_refresh: (GraphDbBackendEvalOperation, GraphDbBackendEvalSignature),
12337 sqlite_rows: ConvexProjectionRows,
12338 extra_warnings: Vec<String>,
12339 prepared: &ConflictMatrixPreparedInputs,
12340) -> Result<GraphDbBackendEvalDataset> {
12341 let (nodes, edges) = sqlite_store.graph_counts()?;
12342 let (sqlite_operation, sqlite_signature) = sqlite_refresh;
12343 let (sqlite_report, sqlite_signatures) = graph_db_backend_eval_report_for_store(
12344 "sqlite",
12345 "SQLite GraphStore correctness baseline",
12346 false,
12347 root,
12348 path,
12349 scope,
12350 targets,
12351 depth,
12352 limit,
12353 impact_limit,
12354 sqlite_store,
12355 sqlite_freshness,
12356 sqlite_operation,
12357 Some(sqlite_signature),
12358 None,
12359 extra_warnings.clone(),
12360 prepared,
12361 "SQLite refresh writes provider-neutral projection rows into graph.db transactionally",
12362 "SQLite WAL correctness store; refresh uses one transactional writer and read-only queries use snapshot recovery",
12363 "bundled rusqlite baseline; no external service or runtime required",
12364 );
12365
12366 let mut backends = vec![sqlite_report];
12367 for candidate in candidates {
12368 #[cfg(feature = "backend-surrealdb")]
12369 if *candidate == GraphDbExperimentalBackend::Surrealdb {
12370 let started = Instant::now();
12371 let store_path = graph_db_backend_eval_surrealdb_store_path(root, scope, name);
12372 let (store, warm_start) =
12373 SurrealdbGraphStore::open_or_refresh(&store_path, &sqlite_rows)?;
12374 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12375 let rows = candidate_nodes + candidate_edges;
12376 let mut refresh_meta = serde_json::json!({
12377 "nodes": candidate_nodes,
12378 "edges": candidate_edges,
12379 });
12380 if warm_start == tsift_surrealdb::WarmStartOutcome::CacheHit {
12381 refresh_meta["warm_start"] = serde_json::json!("cache_hit");
12382 }
12383 let refresh = graph_db_backend_eval_refresh_operation(
12384 started.elapsed().as_micros(),
12385 rows,
12386 refresh_meta,
12387 );
12388 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12389 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12390 candidate.name(),
12391 "SurrealDB SurrealKV optional adapter spike",
12392 false,
12393 root,
12394 path,
12395 scope,
12396 targets,
12397 depth,
12398 limit,
12399 impact_limit,
12400 &store,
12401 freshness,
12402 refresh.0,
12403 Some(refresh.1),
12404 Some(&sqlite_signatures),
12405 extra_warnings.clone(),
12406 prepared,
12407 "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",
12408 "embedded/file-backed writer through SurrealDB SurrealKV rewrites backend-eval rows before read-only measurements; promotion still requires multi-process/read-only contention samples",
12409 "feature-gated optional tsift-surrealdb crate; default cargo build/install does not pull SurrealDB into the dependency graph",
12410 );
12411 backends.push(candidate_report);
12412 continue;
12413 }
12414 let started = Instant::now();
12415 let store = ExperimentalReadOnlyGraphStore::from_rows(*candidate, &sqlite_rows)?;
12416 let (candidate_nodes, candidate_edges) = store.graph_counts()?;
12417 let rows = candidate_nodes + candidate_edges;
12418 let refresh = graph_db_backend_eval_refresh_operation(
12419 started.elapsed().as_micros(),
12420 rows,
12421 serde_json::json!({
12422 "nodes": candidate_nodes,
12423 "edges": candidate_edges,
12424 }),
12425 );
12426 let freshness = sqlite_graph_freshness(sqlite_store, scope.unwrap_or("root"))?;
12427 let (candidate_report, _signatures) = graph_db_backend_eval_report_for_store(
12428 candidate.name(),
12429 candidate.adapter_label(),
12430 true,
12431 root,
12432 path,
12433 scope,
12434 targets,
12435 depth,
12436 limit,
12437 impact_limit,
12438 &store,
12439 freshness,
12440 refresh.0,
12441 Some(refresh.1),
12442 Some(&sqlite_signatures),
12443 extra_warnings.clone(),
12444 prepared,
12445 candidate.projection_load(),
12446 candidate.lock_behavior(),
12447 candidate.install_portability(),
12448 );
12449 backends.push(candidate_report);
12450 }
12451
12452 Ok(GraphDbBackendEvalDataset {
12453 name: name.to_string(),
12454 target_count: targets.len(),
12455 nodes,
12456 edges,
12457 backends,
12458 })
12459}
12460
12461pub(crate) fn print_graph_db_backend_eval_human(report: &GraphDbBackendEvalReport) {
12462 println!(
12463 "graph-db backend-eval baseline:{} candidates:{}",
12464 report.baseline_backend,
12465 report.candidates.join(", ")
12466 );
12467 for phase in &report.phase_timings {
12468 println!(
12469 "phase:{} {}us {}",
12470 phase.name, phase.duration_micros, phase.detail
12471 );
12472 }
12473 for dataset in &report.datasets {
12474 println!(
12475 "dataset:{} targets:{} rows:{}",
12476 dataset.name,
12477 dataset.target_count,
12478 dataset.nodes + dataset.edges
12479 );
12480 for backend in &dataset.backends {
12481 println!(
12482 " backend:{} total:{}us parity:{}",
12483 backend.backend, backend.total_micros, backend.parity.matches_sqlite
12484 );
12485 println!(" projection-load: {}", backend.projection_load);
12486 println!(" lock-behavior: {}", backend.lock_behavior);
12487 println!(" install-portability: {}", backend.install_portability);
12488 for operation in &backend.operations {
12489 println!(
12490 " {} {} {}us",
12491 operation.name, operation.status, operation.duration_micros
12492 );
12493 }
12494 for diagnostic in &backend.parity.diagnostics {
12495 println!(" parity: {diagnostic}");
12496 }
12497 }
12498 }
12499 for decision in &report.promotion {
12500 println!("promotion {}: {}", decision.backend, decision.decision);
12501 println!(" gate: {}", decision.gate.status);
12502 for reason in &decision.reasons {
12503 println!(" reason: {reason}");
12504 }
12505 for check in &decision.gate.required_checks {
12506 println!(" check: {check}");
12507 }
12508 }
12509 println!("metric-digest: {}", report.metric_digest_command);
12510 println!(
12511 "repeat-samples: {}",
12512 report.performance_gate.repeated_sample_command
12513 );
12514}
12515
12516fn traversal_expand_command(root: &Path, handle: &str) -> String {
12517 format!(
12518 "tsift traverse {} --path {} --depth 1 --limit 50",
12519 shell_quote(handle),
12520 shell_quote(root.to_string_lossy().as_ref())
12521 )
12522}
12523
12524fn traversal_file_node(root: &Path, file: &str) -> TraversalNode {
12525 let display = relativize(file, root);
12526 let handle = stable_handle("gfil", &format!("file:{display}"));
12527 TraversalNode {
12528 handle: handle.clone(),
12529 kind: "file".to_string(),
12530 label: display.clone(),
12531 ref_id: Some(display.clone()),
12532 path: Some(display),
12533 line: None,
12534 detail: None,
12535 properties: BTreeMap::new(),
12536 expand: traversal_expand_command(root, &handle),
12537 }
12538}
12539
12540fn traversal_raw_source_file_node(root: &Path, file: &str) -> TraversalNode {
12541 let mut node = traversal_file_node(root, file);
12542 if let Some(path) = node.path.clone() {
12543 node.detail = Some("raw source fallback; graph evidence unavailable".to_string());
12544 node.expand = source_read_command(root, &path, 1, 80);
12545 }
12546 node
12547}
12548
12549fn traversal_symbol_node(root: &Path, symbol: &index::StoredSymbol) -> TraversalNode {
12550 let file = relativize(&symbol.file, root);
12551 let key = format!("symbol:{file}:{}:{}", symbol.line, symbol.name);
12552 let handle = stable_handle("gsym", &key);
12553 TraversalNode {
12554 handle: handle.clone(),
12555 kind: "symbol".to_string(),
12556 label: symbol.name.clone(),
12557 ref_id: Some(symbol.name.clone()),
12558 path: Some(file),
12559 line: Some(symbol.line),
12560 detail: Some(format!("{} {}", symbol.language, symbol.kind)),
12561 properties: BTreeMap::new(),
12562 expand: traversal_expand_command(root, &handle),
12563 }
12564}
12565
12566fn traversal_ast_span_expand_command(
12567 root: &Path,
12568 file: &str,
12569 symbol: &index::StoredSymbol,
12570 span: &AstSpanPreview,
12571) -> String {
12572 if symbol.language == "markdown" {
12573 markdown_ast_command(root, file, Some(&span.handle))
12574 } else {
12575 let line_count = span
12576 .end_line
12577 .saturating_sub(span.start_line)
12578 .saturating_add(1)
12579 .max(1);
12580 source_read_command(root, file, span.start_line, line_count)
12581 }
12582}
12583
12584fn traversal_ast_span_node(
12585 root: &Path,
12586 symbol: &index::StoredSymbol,
12587 source: &[u8],
12588 symbols: &[index::StoredSymbol],
12589) -> Option<(TraversalNode, TraversalAstSpanIndexEntry)> {
12590 let span = stored_symbol_ast_span(symbol, source, symbols, usize::MAX)?;
12591 let file = relativize(&symbol.file, root);
12592 let mut properties = BTreeMap::new();
12593 properties.insert("layer".to_string(), "ast_navigation".to_string());
12594 properties.insert("language".to_string(), symbol.language.clone());
12595 properties.insert("symbol_kind".to_string(), symbol.kind.clone());
12596 properties.insert("node_kind".to_string(), span.node_kind.clone());
12597 properties.insert("start_byte".to_string(), span.start_byte.to_string());
12598 properties.insert("end_byte".to_string(), span.end_byte.to_string());
12599 properties.insert("end_line".to_string(), span.end_line.to_string());
12600 if let Some(body_start_byte) = span.body_start_byte {
12601 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
12602 }
12603 if let Some(body_end_byte) = span.body_end_byte {
12604 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
12605 }
12606 if let Some(body_start_line) = span.body_start_line {
12607 properties.insert("body_start_line".to_string(), body_start_line.to_string());
12608 }
12609 if let Some(body_end_line) = span.body_end_line {
12610 properties.insert("body_end_line".to_string(), body_end_line.to_string());
12611 }
12612 if let Some(parent_handle) = &span.parent_handle {
12613 properties.insert("parent_handle".to_string(), parent_handle.clone());
12614 }
12615 if !span.child_handles.is_empty() {
12616 properties.insert("child_handles".to_string(), span.child_handles.join(","));
12617 }
12618 if let Some(parent_module) = &symbol.parent_module {
12619 properties.insert("parent_module".to_string(), parent_module.clone());
12620 }
12621 if let Some(markdown) = &span.markdown {
12622 properties.insert(
12623 "markdown_block_kind".to_string(),
12624 markdown_ast_block_kind(&symbol.kind),
12625 );
12626 if let Some(heading_level) = markdown.heading_level {
12627 properties.insert("heading_level".to_string(), heading_level.to_string());
12628 }
12629 if !markdown.section_path.is_empty() {
12630 properties.insert(
12631 "section_path".to_string(),
12632 markdown.section_path.join(" > "),
12633 );
12634 }
12635 if let Some(section_handle) = &markdown.section_handle {
12636 properties.insert("section_handle".to_string(), section_handle.clone());
12637 }
12638 if let Some(list_depth) = markdown.list_depth {
12639 properties.insert("list_depth".to_string(), list_depth.to_string());
12640 }
12641 if let Some(fence_language) = &markdown.fence_language {
12642 properties.insert("fence_language".to_string(), fence_language.clone());
12643 }
12644 }
12645
12646 let line = i64::try_from(span.start_line).unwrap_or(i64::MAX);
12647 let node = TraversalNode {
12648 handle: span.handle.clone(),
12649 kind: "ast_span".to_string(),
12650 label: symbol.name.clone(),
12651 ref_id: Some(symbol.name.clone()),
12652 path: Some(file.clone()),
12653 line: Some(line),
12654 detail: Some(format!("{} {} AST span", symbol.language, symbol.kind)),
12655 properties,
12656 expand: traversal_ast_span_expand_command(root, &file, symbol, &span),
12657 };
12658 let entry = TraversalAstSpanIndexEntry {
12659 handle: span.handle,
12660 symbol_handle: String::new(),
12661 file_handle: None,
12662 file,
12663 name: symbol.name.clone(),
12664 kind: symbol.kind.clone(),
12665 language: symbol.language.clone(),
12666 node_kind: span.node_kind,
12667 start_byte: span.start_byte,
12668 end_byte: span.end_byte,
12669 parent_module: symbol.parent_module.clone(),
12670 markdown: span.markdown,
12671 };
12672 Some((node, entry))
12673}
12674
12675fn traversal_unresolved_symbol_node(root: &Path, name: &str) -> TraversalNode {
12676 let handle = stable_handle("gsym", &format!("symbol:{name}"));
12677 TraversalNode {
12678 handle: handle.clone(),
12679 kind: "symbol".to_string(),
12680 label: name.to_string(),
12681 ref_id: Some(name.to_string()),
12682 path: None,
12683 line: None,
12684 detail: Some("unresolved call target".to_string()),
12685 properties: BTreeMap::new(),
12686 expand: traversal_expand_command(root, &handle),
12687 }
12688}
12689
12690fn traversal_route_node(root: &Path, route: &index::StoredRoute) -> TraversalNode {
12691 let file = relativize(&route.file, root);
12692 let method = route.method.as_deref().unwrap_or("any");
12693 let key = format!(
12694 "route:{file}:{}:{}:{}",
12695 route.line, method, route.route_path
12696 );
12697 let handle = stable_handle("grte", &key);
12698 TraversalNode {
12699 handle: handle.clone(),
12700 kind: "route".to_string(),
12701 label: format!("{} {}", method.to_uppercase(), route.route_path),
12702 ref_id: Some(route.route_path.clone()),
12703 path: Some(file),
12704 line: Some(route.line),
12705 detail: Some(format!(
12706 "{} route handled by {}",
12707 route.framework, route.handler_name
12708 )),
12709 properties: BTreeMap::new(),
12710 expand: traversal_expand_command(root, &handle),
12711 }
12712}
12713
12714fn traversal_cargo_workspace_node(
12715 root: &Path,
12716 workspace: &multiplicity::CargoWorkspaceInfo,
12717) -> TraversalNode {
12718 let manifest = relativize_pathbuf(&workspace.manifest_path, root)
12719 .to_string_lossy()
12720 .replace('\\', "/");
12721 let workspace_root = relativize_pathbuf(&workspace.workspace_root, root)
12722 .to_string_lossy()
12723 .replace('\\', "/");
12724 let handle = stable_handle("gcwk", &format!("cargo-workspace:{manifest}"));
12725 let mut properties = BTreeMap::new();
12726 properties.insert("layer".to_string(), "cargo_workspace".to_string());
12727 properties.insert("workspace_root".to_string(), workspace_root.clone());
12728 properties.insert("members".to_string(), workspace.members.join(","));
12729 properties.insert(
12730 "default_members".to_string(),
12731 workspace.default_members.join(","),
12732 );
12733 TraversalNode {
12734 handle: handle.clone(),
12735 kind: "cargo_workspace".to_string(),
12736 label: if workspace_root.is_empty() {
12737 "root cargo workspace".to_string()
12738 } else {
12739 workspace_root
12740 },
12741 ref_id: Some(workspace.id.clone()),
12742 path: Some(manifest),
12743 line: None,
12744 detail: Some("Cargo workspace manifest".to_string()),
12745 properties,
12746 expand: traversal_expand_command(root, &handle),
12747 }
12748}
12749
12750fn traversal_cargo_package_node(
12751 root: &Path,
12752 package: &multiplicity::CargoPackageInfo,
12753) -> TraversalNode {
12754 let manifest = relativize_pathbuf(&package.manifest_path, root)
12755 .to_string_lossy()
12756 .replace('\\', "/");
12757 let package_root = relativize_pathbuf(&package.package_root, root)
12758 .to_string_lossy()
12759 .replace('\\', "/");
12760 let workspace_root = relativize_pathbuf(&package.workspace_root, root)
12761 .to_string_lossy()
12762 .replace('\\', "/");
12763 let handle = stable_handle(
12764 "gcpk",
12765 &format!("cargo-package:{manifest}:{}", package.name),
12766 );
12767 let mut properties = BTreeMap::new();
12768 properties.insert("layer".to_string(), "cargo_package".to_string());
12769 properties.insert("package_name".to_string(), package.name.clone());
12770 properties.insert(
12771 "normalized_name".to_string(),
12772 package.normalized_name.clone(),
12773 );
12774 properties.insert("package_root".to_string(), package_root.clone());
12775 properties.insert("workspace_root".to_string(), workspace_root);
12776 properties.insert("features".to_string(), package.features.join(","));
12777 properties.insert("targets".to_string(), package.targets.join(","));
12778 properties.insert(
12779 "dependencies".to_string(),
12780 package
12781 .dependencies
12782 .iter()
12783 .map(|dependency| format!("{}:{}", dependency.kind, dependency.name))
12784 .collect::<Vec<_>>()
12785 .join(","),
12786 );
12787 TraversalNode {
12788 handle: handle.clone(),
12789 kind: "cargo_package".to_string(),
12790 label: package.name.clone(),
12791 ref_id: Some(package.scope_id.clone()),
12792 path: Some(manifest),
12793 line: None,
12794 detail: Some(format!(
12795 "Cargo package in {}",
12796 if package_root.is_empty() {
12797 "."
12798 } else {
12799 package_root.as_str()
12800 }
12801 )),
12802 properties,
12803 expand: traversal_expand_command(root, &handle),
12804 }
12805}
12806
12807fn traversal_session_node(
12808 root: &Path,
12809 markdown_path: &Path,
12810 session_id: Option<&str>,
12811) -> TraversalNode {
12812 let display = relativize_pathbuf(markdown_path, root)
12813 .to_string_lossy()
12814 .replace('\\', "/");
12815 let handle = stable_handle("gses", &format!("session:{display}"));
12816 TraversalNode {
12817 handle: handle.clone(),
12818 kind: "session".to_string(),
12819 label: session_id.unwrap_or(&display).to_string(),
12820 ref_id: session_id.map(str::to_string),
12821 path: Some(display),
12822 line: None,
12823 detail: Some("agent-doc session artifact".to_string()),
12824 properties: BTreeMap::new(),
12825 expand: traversal_expand_command(root, &handle),
12826 }
12827}
12828
12829fn traversal_backlog_node(
12830 root: &Path,
12831 markdown_path: &Path,
12832 id: &str,
12833 text: &str,
12834 line: i64,
12835) -> TraversalNode {
12836 let display = relativize_pathbuf(markdown_path, root)
12837 .to_string_lossy()
12838 .replace('\\', "/");
12839 let handle = stable_handle("gbak", &format!("backlog:{display}:#{id}"));
12840 TraversalNode {
12841 handle: handle.clone(),
12842 kind: "backlog".to_string(),
12843 label: format!("#{id}"),
12844 ref_id: Some(id.to_string()),
12845 path: Some(display),
12846 line: Some(line),
12847 detail: Some(text.to_string()),
12848 properties: BTreeMap::new(),
12849 expand: traversal_expand_command(root, &handle),
12850 }
12851}
12852
12853fn traversal_job_packet_node(
12854 root: &Path,
12855 markdown_path: &Path,
12856 label: &str,
12857 ref_id: Option<&str>,
12858 detail: &str,
12859 line: i64,
12860) -> TraversalNode {
12861 let display = relativize_pathbuf(markdown_path, root)
12862 .to_string_lossy()
12863 .replace('\\', "/");
12864 let handle = stable_handle("gjob", &format!("job:{display}:{line}:{label}"));
12865 TraversalNode {
12866 handle: handle.clone(),
12867 kind: "job_packet".to_string(),
12868 label: label.to_string(),
12869 ref_id: ref_id.map(str::to_string),
12870 path: Some(display),
12871 line: Some(line),
12872 detail: Some(detail.to_string()),
12873 properties: BTreeMap::new(),
12874 expand: traversal_expand_command(root, &handle),
12875 }
12876}
12877
12878#[derive(Clone, Debug)]
12879struct ParsedWorkerResult {
12880 id: String,
12881 status: String,
12882 touched_files: Vec<String>,
12883 tests: Vec<String>,
12884 follow_up_ids: Vec<String>,
12885}
12886
12887fn traversal_worker_result_node(
12888 root: &Path,
12889 markdown_path: &Path,
12890 parsed: &ParsedWorkerResult,
12891 line_text: &str,
12892 line: i64,
12893) -> TraversalNode {
12894 let display = relativize_pathbuf(markdown_path, root)
12895 .to_string_lossy()
12896 .replace('\\', "/");
12897 let handle = stable_handle(
12898 "wres",
12899 &format!(
12900 "worker-result:{display}:{}:{}:{}",
12901 parsed.id, parsed.status, line
12902 ),
12903 );
12904 let mut properties = BTreeMap::new();
12905 properties.insert("status".to_string(), parsed.status.clone());
12906 if !parsed.touched_files.is_empty() {
12907 properties.insert("touched_files".to_string(), parsed.touched_files.join(","));
12908 }
12909 if !parsed.tests.is_empty() {
12910 properties.insert("expected_tests".to_string(), parsed.tests.join(" && "));
12911 }
12912 if !parsed.follow_up_ids.is_empty() {
12913 properties.insert("follow_up_ids".to_string(), parsed.follow_up_ids.join(","));
12914 }
12915 TraversalNode {
12916 handle: handle.clone(),
12917 kind: "worker_result".to_string(),
12918 label: format!("{} #{}", parsed.status, parsed.id),
12919 ref_id: Some(parsed.id.clone()),
12920 path: Some(display),
12921 line: Some(line),
12922 detail: Some(line_text.trim().to_string()),
12923 properties,
12924 expand: traversal_expand_command(root, &handle),
12925 }
12926}
12927
12928fn traversal_tokens(input: &str) -> BTreeSet<String> {
12929 input
12930 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
12931 .flat_map(|part| part.split(['_', '-']))
12932 .map(str::trim)
12933 .filter(|part| part.len() >= 3)
12934 .map(|part| part.to_ascii_lowercase())
12935 .collect()
12936}
12937
12938fn traversal_ast_span_contains(
12939 parent: &TraversalAstSpanIndexEntry,
12940 child: &TraversalAstSpanIndexEntry,
12941) -> bool {
12942 parent.handle != child.handle
12943 && parent.file == child.file
12944 && parent.start_byte <= child.start_byte
12945 && parent.end_byte >= child.end_byte
12946}
12947
12948fn traversal_ast_parent_handle<'a>(
12949 entry: &TraversalAstSpanIndexEntry,
12950 entries: &'a [TraversalAstSpanIndexEntry],
12951) -> Option<&'a str> {
12952 entries
12953 .iter()
12954 .filter(|candidate| traversal_ast_span_contains(candidate, entry))
12955 .min_by_key(|candidate| {
12956 (
12957 candidate.end_byte.saturating_sub(candidate.start_byte),
12958 candidate.start_byte,
12959 candidate.end_byte,
12960 candidate.kind.as_str(),
12961 candidate.name.as_str(),
12962 candidate.node_kind.as_str(),
12963 )
12964 })
12965 .map(|candidate| candidate.handle.as_str())
12966}
12967
12968fn traversal_ast_enclosing_module_handle<'a>(
12969 entry: &TraversalAstSpanIndexEntry,
12970 entries_by_handle: &'a BTreeMap<String, TraversalAstSpanIndexEntry>,
12971 parent_by_handle: &BTreeMap<String, String>,
12972) -> Option<&'a str> {
12973 let mut current = parent_by_handle.get(&entry.handle);
12974 while let Some(handle) = current {
12975 let Some(parent) = entries_by_handle.get(handle) else {
12976 break;
12977 };
12978 if matches!(parent.kind.as_str(), "module" | "mod")
12979 || entry
12980 .parent_module
12981 .as_deref()
12982 .is_some_and(|module| module == parent.name)
12983 {
12984 return Some(parent.handle.as_str());
12985 }
12986 current = parent_by_handle.get(&parent.handle);
12987 }
12988 None
12989}
12990
12991fn link_ast_navigation_edges(
12992 graph: &mut TraversalGraphBuild,
12993 entries: &[TraversalAstSpanIndexEntry],
12994) {
12995 let mut entries_by_file = BTreeMap::<String, Vec<TraversalAstSpanIndexEntry>>::new();
12996 let entries_by_handle = entries
12997 .iter()
12998 .map(|entry| (entry.handle.clone(), entry.clone()))
12999 .collect::<BTreeMap<_, _>>();
13000 let mut parent_by_handle = BTreeMap::<String, String>::new();
13001 let mut children_by_parent = BTreeMap::<Option<String>, Vec<TraversalAstSpanIndexEntry>>::new();
13002
13003 for entry in entries {
13004 entries_by_file
13005 .entry(entry.file.clone())
13006 .or_default()
13007 .push(entry.clone());
13008 }
13009
13010 for file_entries in entries_by_file.values() {
13011 for entry in file_entries {
13012 let parent = traversal_ast_parent_handle(entry, file_entries).map(str::to_string);
13013 if let Some(parent) = &parent {
13014 parent_by_handle.insert(entry.handle.clone(), parent.clone());
13015 }
13016 let sibling_key = parent.clone().or_else(|| entry.file_handle.clone());
13017 children_by_parent
13018 .entry(sibling_key)
13019 .or_default()
13020 .push(entry.clone());
13021 }
13022 }
13023
13024 for entry in entries {
13025 let parent = parent_by_handle.get(&entry.handle);
13026 if let Some(parent) = parent {
13027 graph.add_edge(
13028 parent,
13029 &entry.handle,
13030 "contains",
13031 Some("AST parent contains child span".to_string()),
13032 1,
13033 );
13034 graph.add_edge(
13035 parent,
13036 &entry.handle,
13037 "child",
13038 Some("AST child span".to_string()),
13039 1,
13040 );
13041 graph.add_edge(
13042 &entry.handle,
13043 parent,
13044 "parent",
13045 Some("AST parent span".to_string()),
13046 1,
13047 );
13048 } else if let Some(file_handle) = &entry.file_handle {
13049 graph.add_edge(
13050 file_handle,
13051 &entry.handle,
13052 "contains",
13053 Some("file contains top-level AST span".to_string()),
13054 1,
13055 );
13056 }
13057
13058 if let Some(module_handle) =
13059 traversal_ast_enclosing_module_handle(entry, &entries_by_handle, &parent_by_handle)
13060 {
13061 graph.add_edge(
13062 &entry.handle,
13063 module_handle,
13064 "enclosing_module",
13065 Some("nearest enclosing module AST span".to_string()),
13066 1,
13067 );
13068 }
13069
13070 if entry.language == "markdown"
13071 && let Some(markdown) = &entry.markdown
13072 && let Some(section_handle) = &markdown.section_handle
13073 && section_handle != &entry.handle
13074 {
13075 graph.add_edge(
13076 section_handle,
13077 &entry.handle,
13078 "contains_markdown_block",
13079 Some("Markdown section contains block".to_string()),
13080 1,
13081 );
13082 graph.add_edge(
13083 &entry.handle,
13084 section_handle,
13085 "enclosing_section",
13086 Some("Markdown enclosing section".to_string()),
13087 1,
13088 );
13089 }
13090 }
13091
13092 for siblings in children_by_parent.values_mut() {
13093 siblings.sort_by(|left, right| {
13094 left.start_byte
13095 .cmp(&right.start_byte)
13096 .then(left.end_byte.cmp(&right.end_byte))
13097 .then(left.kind.cmp(&right.kind))
13098 .then(left.name.cmp(&right.name))
13099 .then(left.node_kind.cmp(&right.node_kind))
13100 .then(left.handle.cmp(&right.handle))
13101 });
13102 for pair in siblings.windows(2) {
13103 let previous = &pair[0];
13104 let next = &pair[1];
13105 graph.add_edge(
13106 &previous.handle,
13107 &next.handle,
13108 "next_sibling",
13109 Some("next AST sibling span".to_string()),
13110 1,
13111 );
13112 graph.add_edge(
13113 &next.handle,
13114 &previous.handle,
13115 "previous_sibling",
13116 Some("previous AST sibling span".to_string()),
13117 1,
13118 );
13119 }
13120 }
13121}
13122
13123fn traversal_markdown_embedded_symbol_node(
13124 root: &Path,
13125 entry: &TraversalAstSpanIndexEntry,
13126 markdown: &MarkdownSpanMetadata,
13127 embedded: &MarkdownEmbeddedSymbol,
13128) -> TraversalNode {
13129 let mut properties = BTreeMap::new();
13130 properties.insert("layer".to_string(), "embedded_code".to_string());
13131 properties.insert("embedded".to_string(), "true".to_string());
13132 properties.insert("language".to_string(), embedded.language.clone());
13133 properties.insert("symbol_kind".to_string(), embedded.kind.clone());
13134 properties.insert("node_kind".to_string(), embedded.node_kind.clone());
13135 properties.insert("start_byte".to_string(), embedded.start_byte.to_string());
13136 properties.insert("end_byte".to_string(), embedded.end_byte.to_string());
13137 properties.insert("end_line".to_string(), embedded.end_line.to_string());
13138 properties.insert("markdown_block_handle".to_string(), entry.handle.clone());
13139 properties.insert(
13140 "markdown_block_kind".to_string(),
13141 markdown_ast_block_kind(&entry.kind),
13142 );
13143 if let Some(body_start_byte) = embedded.body_start_byte {
13144 properties.insert("body_start_byte".to_string(), body_start_byte.to_string());
13145 }
13146 if let Some(body_end_byte) = embedded.body_end_byte {
13147 properties.insert("body_end_byte".to_string(), body_end_byte.to_string());
13148 }
13149 if let Some(body_start_line) = embedded.body_start_line {
13150 properties.insert("body_start_line".to_string(), body_start_line.to_string());
13151 }
13152 if let Some(body_end_line) = embedded.body_end_line {
13153 properties.insert("body_end_line".to_string(), body_end_line.to_string());
13154 }
13155 if let Some(fence_language) = &markdown.fence_language {
13156 properties.insert("fence_language".to_string(), fence_language.clone());
13157 }
13158 if !markdown.section_path.is_empty() {
13159 properties.insert(
13160 "section_path".to_string(),
13161 markdown.section_path.join(" > "),
13162 );
13163 }
13164 if let Some(section_handle) = &markdown.section_handle {
13165 properties.insert("section_handle".to_string(), section_handle.clone());
13166 }
13167 let line_count = embedded
13168 .end_line
13169 .saturating_sub(embedded.start_line)
13170 .saturating_add(1)
13171 .max(1);
13172 TraversalNode {
13173 handle: embedded.handle.clone(),
13174 kind: "ast_span".to_string(),
13175 label: embedded.name.clone(),
13176 ref_id: Some(embedded.name.clone()),
13177 path: Some(entry.file.clone()),
13178 line: Some(i64::try_from(embedded.start_line).unwrap_or(i64::MAX)),
13179 detail: Some(format!(
13180 "{} {} embedded in Markdown fence",
13181 embedded.language, embedded.kind
13182 )),
13183 properties,
13184 expand: source_read_command(root, &entry.file, embedded.start_line, line_count),
13185 }
13186}
13187
13188fn link_markdown_embedded_code_edges(
13189 graph: &mut TraversalGraphBuild,
13190 root: &Path,
13191 entries: &[TraversalAstSpanIndexEntry],
13192) {
13193 for entry in entries {
13194 let Some(markdown) = &entry.markdown else {
13195 continue;
13196 };
13197 for embedded in &markdown.embedded_symbols {
13198 let node = traversal_markdown_embedded_symbol_node(root, entry, markdown, embedded);
13199 graph.add_node(node);
13200 graph.add_edge(
13201 &entry.handle,
13202 &embedded.handle,
13203 "contains",
13204 Some("Markdown fence contains embedded AST symbol".to_string()),
13205 1,
13206 );
13207 graph.add_edge(
13208 &entry.handle,
13209 &embedded.handle,
13210 "child",
13211 Some("embedded code symbol".to_string()),
13212 1,
13213 );
13214 graph.add_edge(
13215 &entry.handle,
13216 &embedded.handle,
13217 "contains_embedded_symbol",
13218 Some("Markdown fence contains embedded code symbol".to_string()),
13219 1,
13220 );
13221 graph.add_edge(
13222 &embedded.handle,
13223 &entry.handle,
13224 "parent",
13225 Some("Markdown fence parent span".to_string()),
13226 1,
13227 );
13228 graph.add_edge(
13229 &embedded.handle,
13230 &entry.handle,
13231 "embedded_in_fence",
13232 Some("embedded code symbol belongs to Markdown fence".to_string()),
13233 1,
13234 );
13235 if let Some(section_handle) = &markdown.section_handle
13236 && section_handle != &entry.handle
13237 {
13238 graph.add_edge(
13239 section_handle,
13240 &embedded.handle,
13241 "contains_embedded_code",
13242 Some("Markdown section contains embedded code symbol".to_string()),
13243 1,
13244 );
13245 graph.add_edge(
13246 &embedded.handle,
13247 section_handle,
13248 "enclosing_section",
13249 Some("Markdown enclosing section".to_string()),
13250 1,
13251 );
13252 }
13253 }
13254 }
13255}
13256
13257fn traversal_node_tokens(node: &TraversalNode) -> BTreeSet<String> {
13258 let mut tokens = traversal_tokens(&node.label);
13259 if let Some(ref_id) = &node.ref_id {
13260 tokens.extend(traversal_tokens(ref_id));
13261 }
13262 if let Some(path) = &node.path {
13263 tokens.extend(traversal_tokens(path));
13264 }
13265 if let Some(detail) = &node.detail {
13266 tokens.extend(traversal_tokens(detail));
13267 }
13268 tokens
13269}
13270
13271fn markdown_code_spans(input: &str) -> Vec<String> {
13272 input
13273 .split('`')
13274 .enumerate()
13275 .filter(|(idx, _)| idx % 2 == 1)
13276 .map(|(_, part)| part.trim().to_string())
13277 .filter(|part| !part.is_empty())
13278 .collect()
13279}
13280
13281fn push_traversal_token_index(
13282 index: &mut HashMap<String, Vec<usize>>,
13283 tokens: &BTreeSet<String>,
13284 entry_index: usize,
13285) {
13286 for token in tokens {
13287 index.entry(token.clone()).or_default().push(entry_index);
13288 }
13289}
13290
13291impl<'a> TraversalCodeLookup<'a> {
13292 fn new(
13293 symbols: &'a [TraversalSymbolIndexEntry],
13294 files: &'a [TraversalFileIndexEntry],
13295 routes: &'a [TraversalRouteIndexEntry],
13296 multiplicities: &'a [TraversalMultiplicityIndexEntry],
13297 ) -> Self {
13298 let mut symbol_index = HashMap::new();
13299 for (idx, entry) in symbols.iter().enumerate() {
13300 push_traversal_token_index(&mut symbol_index, &entry.tokens, idx);
13301 }
13302 let mut file_index = HashMap::new();
13303 let mut file_path_index = HashMap::new();
13304 for (idx, entry) in files.iter().enumerate() {
13305 push_traversal_token_index(&mut file_index, &entry.tokens, idx);
13306 if let Some(path) = entry.node.path.as_ref() {
13307 file_path_index.insert(path.clone(), path.clone());
13308 }
13309 }
13310 let mut route_index = HashMap::new();
13311 for (idx, entry) in routes.iter().enumerate() {
13312 push_traversal_token_index(&mut route_index, &entry.tokens, idx);
13313 }
13314 let mut multiplicity_index = HashMap::new();
13315 for (idx, entry) in multiplicities.iter().enumerate() {
13316 push_traversal_token_index(&mut multiplicity_index, &entry.tokens, idx);
13317 }
13318 Self {
13319 symbols,
13320 files,
13321 routes,
13322 multiplicities,
13323 symbol_index,
13324 file_index,
13325 route_index,
13326 multiplicity_index,
13327 file_path_index,
13328 }
13329 }
13330
13331 fn touched_files_for_line(&self, line: &str) -> Vec<String> {
13332 let mut touched_files = BTreeSet::new();
13333 for candidate in markdown_code_spans(line)
13334 .into_iter()
13335 .chain(line.split_whitespace().map(str::to_string))
13336 {
13337 for path in traversal_path_candidates(&candidate) {
13338 if let Some(file) = self.file_path_index.get(&path) {
13339 touched_files.insert(file.clone());
13340 }
13341 }
13342 }
13343 touched_files.into_iter().collect()
13344 }
13345}
13346
13347fn traversal_path_candidates(candidate: &str) -> Vec<String> {
13348 let trimmed = candidate.trim_matches(|ch: char| {
13349 matches!(
13350 ch,
13351 '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}'
13352 )
13353 });
13354 if trimmed.is_empty() {
13355 return Vec::new();
13356 }
13357 let mut candidates = vec![trimmed.to_string()];
13358 if let Some((path, line_suffix)) = trimmed.rsplit_once(':')
13359 && !path.is_empty()
13360 && line_suffix.chars().all(|ch| ch.is_ascii_digit())
13361 {
13362 candidates.push(path.to_string());
13363 }
13364 candidates
13365}
13366
13367fn parse_worker_result_line(
13368 line: &str,
13369 lookup: &TraversalCodeLookup<'_>,
13370) -> Vec<ParsedWorkerResult> {
13371 if line.trim_start().starts_with("- [") {
13372 return Vec::new();
13373 }
13374 let lower = line.to_ascii_lowercase();
13375 let status =
13376 if lower.contains("completed") || lower.contains("code-complete") || lower.contains("done")
13377 {
13378 "completed"
13379 } else if lower.contains("blocked") || lower.contains("externally blocked") {
13380 "blocked"
13381 } else {
13382 return Vec::new();
13383 };
13384 let result_prefix_end = ["follow-up", "follow up", "next:"]
13385 .iter()
13386 .filter_map(|marker| lower.find(marker))
13387 .min()
13388 .unwrap_or(line.len());
13389 let ids = extract_conflict_target_refs(&line[..result_prefix_end]);
13390 if ids.is_empty() {
13391 return Vec::new();
13392 }
13393 let result_ids = ids.iter().cloned().collect::<BTreeSet<_>>();
13394 let all_ids = extract_conflict_target_refs(line);
13395
13396 let touched_files = lookup.touched_files_for_line(line);
13397 let tests = markdown_code_spans(line)
13398 .into_iter()
13399 .filter(|span| span.to_ascii_lowercase().contains("test"))
13400 .collect::<Vec<_>>();
13401
13402 ids.iter()
13403 .map(|id| ParsedWorkerResult {
13404 id: id.clone(),
13405 status: status.to_string(),
13406 touched_files: touched_files.clone(),
13407 tests: tests.clone(),
13408 follow_up_ids: all_ids
13409 .iter()
13410 .filter(|other| *other != id && !result_ids.contains(*other))
13411 .cloned()
13412 .collect(),
13413 })
13414 .collect()
13415}
13416
13417fn hinted_markdown_file(root: &Path, path_hint: &Path) -> Option<PathBuf> {
13418 let hinted_path = if path_hint.is_absolute() {
13419 path_hint.to_path_buf()
13420 } else {
13421 root.join(path_hint)
13422 };
13423 if hinted_path.extension().and_then(|ext| ext.to_str()) == Some("md") && hinted_path.is_file() {
13424 return Some(hinted_path);
13425 }
13426 None
13427}
13428
13429fn traversal_path_is_session_markdown(root: &Path, source_root: &Path, path: &Path) -> bool {
13430 let candidate = if path.is_absolute() {
13431 path.to_path_buf()
13432 } else {
13433 source_root.join(path)
13434 };
13435 if !candidate.starts_with(source_root) && !candidate.starts_with(root) {
13436 return false;
13437 }
13438 if !matches!(
13439 candidate.extension().and_then(|ext| ext.to_str()),
13440 Some("md" | "mdx")
13441 ) {
13442 return false;
13443 }
13444 fs::read_to_string(&candidate)
13445 .map(|content| session_markdown::markdown_content_looks_like_agent_doc_session(&content))
13446 .unwrap_or(false)
13447}
13448
13449fn markdown_files_for_traversal(root: &Path, path_hint: &Path) -> Result<Vec<PathBuf>> {
13450 if let Some(hinted_path) = hinted_markdown_file(root, path_hint) {
13451 return Ok(vec![hinted_path]);
13452 }
13453 let mut files = Vec::new();
13454 let walker = ignore::WalkBuilder::new(root)
13455 .hidden(true)
13456 .git_ignore(true)
13457 .git_global(true)
13458 .git_exclude(true)
13459 .build();
13460 for result in walker {
13461 let entry =
13462 result.with_context(|| format!("walking markdown files under {}", root.display()))?;
13463 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
13464 continue;
13465 }
13466 if traversal_path_is_generated_artifact(root, root, entry.path()) {
13467 continue;
13468 }
13469 if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") {
13470 files.push(entry.path().to_path_buf());
13471 }
13472 }
13473 files.sort();
13474 Ok(files)
13475}
13476
13477fn traversal_watermark_path(root: &Path, path: &Path) -> String {
13478 path.strip_prefix(root)
13479 .unwrap_or(path)
13480 .to_string_lossy()
13481 .replace('\\', "/")
13482}
13483
13484fn push_traversal_metadata_watermark_part(
13485 root: &Path,
13486 path: &Path,
13487 label: &str,
13488 parts: &mut Vec<String>,
13489) {
13490 let display = traversal_watermark_path(root, path);
13491 match fs::metadata(path) {
13492 Ok(metadata) => {
13493 let (secs, nanos) = metadata
13494 .modified()
13495 .ok()
13496 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
13497 .map(|duration| (duration.as_secs(), duration.subsec_nanos()))
13498 .unwrap_or((0, 0));
13499 parts.push(format!(
13500 "{label}:{display}:len={}:mtime={secs}.{nanos}",
13501 metadata.len()
13502 ));
13503 }
13504 Err(_) => parts.push(format!("{label}:{display}:missing")),
13505 }
13506}
13507
13508#[derive(Serialize)]
13509struct TraversalSummaryWatermarkRow<'a> {
13510 symbol_name: &'a str,
13511 file_path: &'a str,
13512 entities: &'a Option<Vec<summarize::Entity>>,
13513 relationships: &'a Option<Vec<summarize::Relationship>>,
13514 concept_labels: &'a Option<Vec<String>>,
13515}
13516
13517fn push_traversal_summaries_watermark_part(root: &Path, parts: &mut Vec<String>) -> Result<()> {
13518 let summaries_db = root.join(".tsift/summaries.db");
13519 if !summaries_db.exists() {
13520 parts.push("summaries_db:absent".to_string());
13521 return Ok(());
13522 }
13523
13524 match summarize::SummaryDb::open_read_only_resilient(&summaries_db)
13525 .and_then(|summary_db| summary_db.all())
13526 {
13527 Ok(summaries) => {
13528 let rows = summaries
13529 .iter()
13530 .map(|summary| TraversalSummaryWatermarkRow {
13531 symbol_name: &summary.symbol_name,
13532 file_path: &summary.file_path,
13533 entities: &summary.entities,
13534 relationships: &summary.relationships,
13535 concept_labels: &summary.concept_labels,
13536 })
13537 .collect::<Vec<_>>();
13538 parts.push(format!(
13539 "summaries_db:rows={}:semantic_hash={}",
13540 rows.len(),
13541 content_hash(&rows)?
13542 ));
13543 }
13544 Err(_) => {
13545 push_traversal_metadata_watermark_part(
13546 root,
13547 &summaries_db,
13548 "summaries_db_unreadable",
13549 parts,
13550 );
13551 }
13552 }
13553 Ok(())
13554}
13555
13556#[cfg(test)]
13557fn traversal_relative_path_is_generated_artifact(relative: &str) -> bool {
13558 resolution::relative_path_is_generated_artifact(relative)
13559}
13560
13561fn traversal_path_is_generated_artifact(root: &Path, source_root: &Path, path: &Path) -> bool {
13562 resolution::path_is_generated_artifact(root, source_root, path)
13563}
13564
13565fn traversal_index_snapshot_part_is_generated(root: &Path, source_root: &Path, part: &str) -> bool {
13566 resolution::index_snapshot_part_is_generated(root, source_root, part)
13567}
13568
13569pub(crate) fn traversal_source_watermark(
13570 root: &Path,
13571 path_hint: &Path,
13572 scope: Option<&str>,
13573 session_only: bool,
13574) -> Result<Option<String>> {
13575 let mut parts = vec![
13576 format!("projection_version:{GRAPH_PROJECTION_VERSION}"),
13577 format!("scope:{}", scope.unwrap_or("root")),
13578 format!("path_hint:{}", traversal_watermark_path(root, path_hint)),
13579 format!("session_only:{session_only}"),
13580 ];
13581
13582 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
13583 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
13584 Ok(targets) => targets,
13585 Err(_) => return Ok(None),
13586 };
13587 let Some(target) = targets.into_iter().next() else {
13588 return Ok(None);
13589 };
13590 let db = match index::IndexDb::open_read_only_resilient(&target.db_path) {
13591 Ok(db) => db,
13592 Err(_) => return Ok(None),
13593 };
13594 parts.push(format!("index_label:{}", target.label));
13595 parts.push(format!(
13596 "index_scope:{}",
13597 target.scope_name.as_deref().unwrap_or("root")
13598 ));
13599 parts.push(format!(
13600 "index_source_root:{}",
13601 traversal_watermark_path(root, &target.source_root)
13602 ));
13603 let mut snapshot_rows = 0usize;
13604 for part in db.source_snapshot_parts()? {
13605 if traversal_index_snapshot_part_is_generated(root, &target.source_root, &part) {
13606 continue;
13607 }
13608 snapshot_rows += 1;
13609 parts.push(format!("index_snapshot:{part}"));
13610 }
13611 parts.push(format!("index_snapshot_rows:{snapshot_rows}"));
13612 }
13613
13614 let markdown_files = markdown_files_for_traversal(root, path_hint)?;
13615 parts.push(format!("markdown_count:{}", markdown_files.len()));
13616 for markdown_path in markdown_files {
13617 push_traversal_metadata_watermark_part(root, &markdown_path, "markdown", &mut parts);
13618 }
13619
13620 push_traversal_summaries_watermark_part(root, &mut parts)?;
13621
13622 Ok(Some(content_hash(&parts)?))
13623}
13624
13625fn ranked_symbol_matches<'a>(
13626 query_tokens: &BTreeSet<String>,
13627 entries: &'a [TraversalSymbolIndexEntry],
13628 index: &HashMap<String, Vec<usize>>,
13629) -> Vec<(usize, &'a TraversalSymbolIndexEntry)> {
13630 let mut scores = BTreeMap::<usize, usize>::new();
13631 for token in query_tokens {
13632 if let Some(indices) = index.get(token) {
13633 for idx in indices {
13634 *scores.entry(*idx).or_default() += 1;
13635 }
13636 }
13637 }
13638 let mut matches = scores
13639 .into_iter()
13640 .map(|(idx, score)| (score, &entries[idx]))
13641 .collect::<Vec<_>>();
13642 matches.sort_by(|(left_score, left), (right_score, right)| {
13643 right_score
13644 .cmp(left_score)
13645 .then_with(|| left.node.label.cmp(&right.node.label))
13646 .then_with(|| left.handle.cmp(&right.handle))
13647 });
13648 matches
13649}
13650
13651fn ranked_file_matches<'a>(
13652 query_tokens: &BTreeSet<String>,
13653 entries: &'a [TraversalFileIndexEntry],
13654 index: &HashMap<String, Vec<usize>>,
13655) -> Vec<(usize, &'a TraversalFileIndexEntry)> {
13656 let mut scores = BTreeMap::<usize, usize>::new();
13657 for token in query_tokens {
13658 if let Some(indices) = index.get(token) {
13659 for idx in indices {
13660 *scores.entry(*idx).or_default() += 1;
13661 }
13662 }
13663 }
13664 let mut matches = scores
13665 .into_iter()
13666 .map(|(idx, score)| (score, &entries[idx]))
13667 .collect::<Vec<_>>();
13668 matches.sort_by(|(left_score, left), (right_score, right)| {
13669 right_score
13670 .cmp(left_score)
13671 .then_with(|| left.node.label.cmp(&right.node.label))
13672 .then_with(|| left.handle.cmp(&right.handle))
13673 });
13674 matches
13675}
13676
13677fn ranked_route_matches<'a>(
13678 query_tokens: &BTreeSet<String>,
13679 entries: &'a [TraversalRouteIndexEntry],
13680 index: &HashMap<String, Vec<usize>>,
13681) -> Vec<(usize, &'a TraversalRouteIndexEntry)> {
13682 let mut scores = BTreeMap::<usize, usize>::new();
13683 for token in query_tokens {
13684 if let Some(indices) = index.get(token) {
13685 for idx in indices {
13686 *scores.entry(*idx).or_default() += 1;
13687 }
13688 }
13689 }
13690 let mut matches = scores
13691 .into_iter()
13692 .map(|(idx, score)| (score, &entries[idx]))
13693 .collect::<Vec<_>>();
13694 matches.sort_by(|(left_score, left), (right_score, right)| {
13695 right_score
13696 .cmp(left_score)
13697 .then_with(|| left.node.label.cmp(&right.node.label))
13698 .then_with(|| left.handle.cmp(&right.handle))
13699 });
13700 matches
13701}
13702
13703fn ranked_multiplicity_matches<'a>(
13704 query_tokens: &BTreeSet<String>,
13705 entries: &'a [TraversalMultiplicityIndexEntry],
13706 index: &HashMap<String, Vec<usize>>,
13707) -> Vec<(usize, &'a TraversalMultiplicityIndexEntry)> {
13708 let mut scores = BTreeMap::<usize, usize>::new();
13709 for token in query_tokens {
13710 if let Some(indices) = index.get(token) {
13711 for idx in indices {
13712 *scores.entry(*idx).or_default() += 1;
13713 }
13714 }
13715 }
13716 let mut matches = scores
13717 .into_iter()
13718 .map(|(idx, score)| (score, &entries[idx]))
13719 .collect::<Vec<_>>();
13720 matches.sort_by(|(left_score, left), (right_score, right)| {
13721 right_score
13722 .cmp(left_score)
13723 .then_with(|| left.node.kind.cmp(&right.node.kind))
13724 .then_with(|| left.node.label.cmp(&right.node.label))
13725 .then_with(|| left.handle.cmp(&right.handle))
13726 });
13727 matches
13728}
13729
13730fn link_backlog_to_code_nodes(
13731 graph: &mut TraversalGraphBuild,
13732 backlog: &TraversalNode,
13733 text: &str,
13734 lookup: &TraversalCodeLookup<'_>,
13735 limit: usize,
13736) {
13737 let mut query_tokens = traversal_tokens(text);
13738 if let Some(ref_id) = &backlog.ref_id {
13739 query_tokens.extend(traversal_tokens(ref_id));
13740 }
13741 if query_tokens.is_empty() {
13742 return;
13743 }
13744
13745 for (score, entry) in ranked_symbol_matches(&query_tokens, lookup.symbols, &lookup.symbol_index)
13746 .into_iter()
13747 .take(limit)
13748 {
13749 graph.add_edge(
13750 &backlog.handle,
13751 &entry.handle,
13752 "mentions",
13753 Some("backlog text matches symbol tokens".to_string()),
13754 score,
13755 );
13756 }
13757
13758 for (score, entry) in ranked_file_matches(&query_tokens, lookup.files, &lookup.file_index)
13759 .into_iter()
13760 .take(limit.min(5))
13761 {
13762 graph.add_edge(
13763 &backlog.handle,
13764 &entry.handle,
13765 "mentions",
13766 Some("backlog text matches file tokens".to_string()),
13767 score,
13768 );
13769 }
13770
13771 for (score, entry) in ranked_route_matches(&query_tokens, lookup.routes, &lookup.route_index)
13772 .into_iter()
13773 .take(limit.min(5))
13774 {
13775 graph.add_edge(
13776 &backlog.handle,
13777 &entry.handle,
13778 "mentions",
13779 Some("backlog text matches route tokens".to_string()),
13780 score,
13781 );
13782 }
13783
13784 for (score, entry) in ranked_multiplicity_matches(
13785 &query_tokens,
13786 lookup.multiplicities,
13787 &lookup.multiplicity_index,
13788 )
13789 .into_iter()
13790 .take(limit.min(5))
13791 {
13792 graph.add_edge(
13793 &backlog.handle,
13794 &entry.handle,
13795 "mentions",
13796 Some("backlog text matches multiplicity tokens".to_string()),
13797 score,
13798 );
13799 }
13800}
13801
13802fn load_agent_doc_traversal_nodes(
13803 root: &Path,
13804 path_hint: &Path,
13805 graph: &mut TraversalGraphBuild,
13806 lookup: &TraversalCodeLookup<'_>,
13807) -> Result<()> {
13808 for markdown_path in markdown_files_for_traversal(root, path_hint)? {
13809 let content = match fs::read_to_string(&markdown_path) {
13810 Ok(content) => content,
13811 Err(err) => {
13812 graph.warnings.push(format!(
13813 "session artifact unavailable: {}: {err}",
13814 markdown_path.display()
13815 ));
13816 continue;
13817 }
13818 };
13819 let Some(document) = AgentDocSessionDocument::parse_if_session(&content) else {
13820 continue;
13821 };
13822
13823 let session = traversal_session_node(root, &markdown_path, document.session_id.as_deref());
13824 graph.add_node(session.clone());
13825 let lines = content.lines().collect::<Vec<_>>();
13826 let mut backlog_by_id = BTreeMap::<String, TraversalNode>::new();
13827 for item in &document.backlog_items {
13828 let backlog = traversal_backlog_node(
13829 root,
13830 &markdown_path,
13831 &item.id,
13832 &item.text,
13833 item.line as i64,
13834 );
13835 graph.add_node(backlog.clone());
13836 backlog_by_id.insert(item.id.clone(), backlog.clone());
13837 graph.add_edge(
13838 &session.handle,
13839 &backlog.handle,
13840 "contains",
13841 Some("session backlog item".to_string()),
13842 1,
13843 );
13844 link_backlog_to_code_nodes(graph, &backlog, &item.text, lookup, 8);
13845 }
13846
13847 let mut job_by_id = BTreeMap::<String, TraversalNode>::new();
13848 for item in &document.queue_items {
13849 match item {
13850 AgentDocQueueItem::Dispatch { value, line }
13851 | AgentDocQueueItem::Preset { value, line } => {
13852 let dispatch_ref = value.strip_prefix('#').unwrap_or(value.as_str());
13853 let node = traversal_job_packet_node(
13854 root,
13855 &markdown_path,
13856 &format!("dispatch {value}"),
13857 Some(dispatch_ref),
13858 "agent-doc dispatch preset",
13859 *line as i64,
13860 );
13861 graph.add_node(node.clone());
13862 graph.add_edge(
13863 &session.handle,
13864 &node.handle,
13865 "contains",
13866 Some("session queued dispatch".to_string()),
13867 1,
13868 );
13869 }
13870 AgentDocQueueItem::Do { id, line } => {
13871 let detail = backlog_by_id
13872 .get(id)
13873 .and_then(|node| node.detail.clone())
13874 .unwrap_or_else(|| "queued backlog item".to_string());
13875 let node = traversal_job_packet_node(
13876 root,
13877 &markdown_path,
13878 &format!("do #{id}"),
13879 Some(id),
13880 &detail,
13881 *line as i64,
13882 );
13883 graph.add_node(node.clone());
13884 graph.add_edge(
13885 &session.handle,
13886 &node.handle,
13887 "contains",
13888 Some("session queued job packet".to_string()),
13889 1,
13890 );
13891 if let Some(backlog) = backlog_by_id.get(id) {
13892 graph.add_edge(
13893 &node.handle,
13894 &backlog.handle,
13895 "targets",
13896 Some("queued backlog item".to_string()),
13897 1,
13898 );
13899 }
13900 job_by_id.insert(id.clone(), node);
13901 }
13902 }
13903 }
13904
13905 let mut seen_results = BTreeSet::<(String, String, i64)>::new();
13906 for (idx, line) in lines.iter().enumerate() {
13907 for parsed in parse_worker_result_line(line, lookup) {
13908 let line_no = idx as i64 + 1;
13909 if !seen_results.insert((parsed.id.clone(), parsed.status.clone(), line_no)) {
13910 continue;
13911 }
13912 let result =
13913 traversal_worker_result_node(root, &markdown_path, &parsed, line, line_no);
13914 graph.add_node(result.clone());
13915 graph.add_edge(
13916 &session.handle,
13917 &result.handle,
13918 "contains",
13919 Some("session worker result".to_string()),
13920 1,
13921 );
13922 if let Some(backlog) = backlog_by_id.get(&parsed.id) {
13923 graph.add_edge(
13924 &backlog.handle,
13925 &result.handle,
13926 "has_result",
13927 Some(format!("worker result {}", parsed.status)),
13928 1,
13929 );
13930 }
13931 if let Some(job) = job_by_id.get(&parsed.id) {
13932 graph.add_edge(
13933 &job.handle,
13934 &result.handle,
13935 "has_result",
13936 Some(format!("queued worker result {}", parsed.status)),
13937 1,
13938 );
13939 }
13940 let mut result_text = line.to_string();
13941 if !parsed.touched_files.is_empty() {
13942 result_text.push(' ');
13943 result_text.push_str(&parsed.touched_files.join(" "));
13944 }
13945 link_backlog_to_code_nodes(graph, &result, &result_text, lookup, 8);
13946 }
13947 }
13948 }
13949 Ok(())
13950}
13951
13952#[derive(Debug, Clone)]
13953struct AgentDocIndexGate {
13954 db_path: Option<PathBuf>,
13955 source_root: PathBuf,
13956 diagnostics: Vec<String>,
13957}
13958
13959#[derive(Clone, Hash, PartialEq, Eq)]
13960struct AgentDocIndexGateCacheKey {
13961 root: PathBuf,
13962 path_hint: PathBuf,
13963 scope: Option<String>,
13964 packet_label: String,
13965}
13966
13967fn agent_doc_index_gate_cache() -> &'static std::sync::Mutex<
13968 std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>,
13969> {
13970 static CACHE: std::sync::OnceLock<
13971 std::sync::Mutex<std::collections::HashMap<AgentDocIndexGateCacheKey, AgentDocIndexGate>>,
13972 > = std::sync::OnceLock::new();
13973 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
13974}
13975
13976fn prepare_agent_doc_index_gate_cached(
13977 root: &Path,
13978 path_hint: &Path,
13979 scope: Option<&str>,
13980 packet_label: &str,
13981) -> (AgentDocIndexGate, String) {
13982 let key = AgentDocIndexGateCacheKey {
13983 root: root.to_path_buf(),
13984 path_hint: path_hint.to_path_buf(),
13985 scope: scope.map(str::to_string),
13986 packet_label: packet_label.to_string(),
13987 };
13988 if let Ok(cache) = agent_doc_index_gate_cache().lock()
13989 && let Some(cached) = cache.get(&key)
13990 {
13991 return (
13992 cached.clone(),
13993 "reused from in-process index gate cache by root/path_hint/scope key".to_string(),
13994 );
13995 }
13996 let gate = prepare_agent_doc_index_gate(root, path_hint, scope, packet_label);
13997 if let Ok(mut cache) = agent_doc_index_gate_cache().lock() {
13998 cache.insert(key, gate.clone());
13999 }
14000 (
14001 gate,
14002 "fresh inspection/refresh — cache miss on this preparation key".to_string(),
14003 )
14004}
14005
14006fn index_reason_for_state(state: SearchIndexState) -> Option<RebuildSearchReason> {
14007 match state {
14008 SearchIndexState::Fresh => None,
14009 SearchIndexState::Missing => Some(RebuildSearchReason::Missing),
14010 SearchIndexState::Stale { stale_files } => Some(RebuildSearchReason::Stale { stale_files }),
14011 }
14012}
14013
14014fn index_reason_detail(target: &SearchIndexTarget, reason: RebuildSearchReason) -> String {
14015 rebuild_search_target_detail(&RebuildSearchTarget {
14016 label: target.label.clone(),
14017 reason,
14018 reindex_cmd: target.reindex_cmd.clone(),
14019 })
14020}
14021
14022fn index_refresh_diagnostic(
14023 target: &SearchIndexTarget,
14024 reason: RebuildSearchReason,
14025 summary: &index::IndexSummary,
14026 packet_label: &str,
14027) -> String {
14028 let changed = summary.new + summary.modified + summary.deleted;
14029 format!(
14030 "index refreshed: {}; updated {} changed file{} before {}",
14031 index_reason_detail(target, reason),
14032 changed,
14033 if changed == 1 { "" } else { "s" },
14034 packet_label
14035 )
14036}
14037
14038fn index_refresh_fallback_diagnostic(
14039 target: &SearchIndexTarget,
14040 reason: RebuildSearchReason,
14041 err: &anyhow::Error,
14042 packet_label: &str,
14043) -> String {
14044 format!(
14045 "{}; could not refresh before {}: {err:#}; falling back to raw source file nodes",
14046 index_reason_detail(target, reason),
14047 packet_label
14048 )
14049}
14050
14051fn graph_fallback_source_root(root: &Path, path_hint: &Path, scope: Option<&str>) -> PathBuf {
14052 if let Some(scope_name) = scope
14053 && let Ok(Some(scope)) = config::Config::find_submodule(root, scope_name)
14054 {
14055 return scope.source_root;
14056 }
14057 if let Some(scope_name) = scope
14058 && let Ok(Some(package)) = multiplicity::find_cargo_package(root, scope_name)
14059 {
14060 return package.package_root;
14061 }
14062 if let Ok(Some(scope)) = config::Config::infer_submodule_from_path(root, path_hint) {
14063 return scope.source_root;
14064 }
14065 if let Ok(Some(package)) = multiplicity::infer_cargo_package_from_path(root, path_hint) {
14066 return package.package_root;
14067 }
14068 if let Ok(Some(scope)) = infer_agent_doc_task_submodule(root, path_hint) {
14069 return scope.source_root;
14070 }
14071 root.to_path_buf()
14072}
14073
14074fn prepare_agent_doc_index_gate(
14075 root: &Path,
14076 path_hint: &Path,
14077 scope: Option<&str>,
14078 packet_label: &str,
14079) -> AgentDocIndexGate {
14080 let fallback_source_root = graph_fallback_source_root(root, path_hint, scope);
14081 let targets = match resolve_search_index_targets(root, path_hint, scope, false) {
14082 Ok(targets) => targets,
14083 Err(err) => {
14084 return AgentDocIndexGate {
14085 db_path: None,
14086 source_root: fallback_source_root,
14087 diagnostics: vec![format!(
14088 "code index unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14089 )],
14090 };
14091 }
14092 };
14093 let Some(target) = targets.into_iter().next() else {
14094 return AgentDocIndexGate {
14095 db_path: None,
14096 source_root: fallback_source_root,
14097 diagnostics: vec![format!(
14098 "code index unavailable before {packet_label}: no index target resolved; falling back to raw source file nodes"
14099 )],
14100 };
14101 };
14102
14103 let state = match inspect_search_index(&target) {
14104 Ok(state) => state,
14105 Err(err) => {
14106 return AgentDocIndexGate {
14107 db_path: None,
14108 source_root: target.source_root,
14109 diagnostics: vec![format!(
14110 "code index freshness unavailable before {packet_label}: {err:#}; falling back to raw source file nodes"
14111 )],
14112 };
14113 }
14114 };
14115
14116 let Some(reason) = index_reason_for_state(state) else {
14117 return AgentDocIndexGate {
14118 db_path: Some(target.db_path),
14119 source_root: target.source_root,
14120 diagnostics: Vec::new(),
14121 };
14122 };
14123
14124 match apply_search_index_update(root, &target) {
14125 Ok(summary) => {
14126 index::inspect_scope_invalidate_all();
14132 let diagnostics = vec![index_refresh_diagnostic(
14133 &target,
14134 reason,
14135 &summary,
14136 packet_label,
14137 )];
14138 AgentDocIndexGate {
14139 db_path: Some(target.db_path),
14140 source_root: target.source_root,
14141 diagnostics,
14142 }
14143 }
14144 Err(err) => {
14145 let diagnostics = vec![index_refresh_fallback_diagnostic(
14146 &target,
14147 reason,
14148 &err,
14149 packet_label,
14150 )];
14151 AgentDocIndexGate {
14152 db_path: None,
14153 source_root: target.source_root,
14154 diagnostics,
14155 }
14156 }
14157 }
14158}
14159
14160fn add_raw_source_file_nodes(
14161 root: &Path,
14162 source_root: &Path,
14163 graph: &mut TraversalGraphBuild,
14164 file_entries: &mut Vec<TraversalFileIndexEntry>,
14165) -> Result<()> {
14166 let mut entries = walk::walk_files(source_root)?;
14167 entries.sort_by(|left, right| left.path.cmp(&right.path));
14168 for entry in entries {
14169 let file = entry.path.to_string_lossy();
14170 let node = traversal_raw_source_file_node(root, file.as_ref());
14171 let entry = TraversalFileIndexEntry {
14172 handle: node.handle.clone(),
14173 tokens: traversal_node_tokens(&node),
14174 node: node.clone(),
14175 };
14176 graph.add_node(node);
14177 file_entries.push(entry);
14178 }
14179 Ok(())
14180}
14181
14182fn relative_path_inside_scope(path: &str, scope_root: &str) -> bool {
14183 if scope_root.is_empty() {
14184 return true;
14185 }
14186 path == scope_root || path.starts_with(&format!("{scope_root}/"))
14187}
14188
14189fn traversal_symbol_source_path(root: &Path, source_root: &Path, file: &str) -> PathBuf {
14190 let path = Path::new(file);
14191 if path.is_absolute() {
14192 return path.to_path_buf();
14193 }
14194 let source_candidate = source_root.join(path);
14195 if source_candidate.exists() {
14196 source_candidate
14197 } else {
14198 root.join(path)
14199 }
14200}
14201
14202fn cargo_import_alias_from_line(line: &str) -> Option<String> {
14203 let trimmed = line.trim();
14204 let rest = trimmed
14205 .strip_prefix("pub use ")
14206 .or_else(|| trimmed.strip_prefix("use "))
14207 .or_else(|| trimmed.strip_prefix("extern crate "))?;
14208 let alias = rest
14209 .split([':', ';', ' ', '\t'])
14210 .next()
14211 .unwrap_or_default()
14212 .trim();
14213 (!alias.is_empty()).then(|| alias.to_string())
14214}
14215
14216fn cargo_import_aliases(package: &multiplicity::CargoPackageInfo) -> Result<BTreeSet<String>> {
14217 let mut aliases = BTreeSet::new();
14218 for entry in walk::walk_files(&package.package_root)? {
14219 if entry.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
14220 continue;
14221 }
14222 let content = fs::read_to_string(&entry.path)
14223 .with_context(|| format!("reading Rust source {}", entry.path.display()))?;
14224 aliases.extend(content.lines().filter_map(cargo_import_alias_from_line));
14225 }
14226 Ok(aliases)
14227}
14228
14229fn load_multiplicity_traversal_nodes(
14230 root: &Path,
14231 source_root: &Path,
14232 graph: &mut TraversalGraphBuild,
14233 file_handle_by_path: &HashMap<String, String>,
14234 multiplicity_entries: &mut Vec<TraversalMultiplicityIndexEntry>,
14235) -> Result<()> {
14236 let inventory = multiplicity::discover_cargo_inventory(source_root)?;
14237 let mut workspace_handle_by_root = BTreeMap::<String, String>::new();
14238 for workspace in &inventory.workspaces {
14239 let node = traversal_cargo_workspace_node(root, workspace);
14240 workspace_handle_by_root.insert(workspace.relative_root.clone(), node.handle.clone());
14241 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14242 handle: node.handle.clone(),
14243 tokens: traversal_node_tokens(&node),
14244 node: node.clone(),
14245 });
14246 graph.add_node(node);
14247 }
14248
14249 let mut package_handle_by_name = BTreeMap::<String, Vec<String>>::new();
14250 let mut package_nodes = Vec::new();
14251 for package in &inventory.packages {
14252 let node = traversal_cargo_package_node(root, package);
14253 package_handle_by_name
14254 .entry(package.name.clone())
14255 .or_default()
14256 .push(node.handle.clone());
14257 package_handle_by_name
14258 .entry(package.normalized_name.clone())
14259 .or_default()
14260 .push(node.handle.clone());
14261 multiplicity_entries.push(TraversalMultiplicityIndexEntry {
14262 handle: node.handle.clone(),
14263 tokens: traversal_node_tokens(&node),
14264 node: node.clone(),
14265 });
14266 graph.add_node(node.clone());
14267 package_nodes.push((package, node));
14268 }
14269
14270 for (package, node) in &package_nodes {
14271 if let Some(workspace_handle) =
14272 workspace_handle_by_root.get(&package.relative_workspace_root)
14273 {
14274 graph.add_edge(
14275 workspace_handle,
14276 &node.handle,
14277 "contains_package",
14278 Some("Cargo workspace member package".to_string()),
14279 1,
14280 );
14281 }
14282 let package_root = relativize_pathbuf(&package.package_root, root)
14283 .to_string_lossy()
14284 .replace('\\', "/");
14285 for (file, handle) in file_handle_by_path {
14286 if relative_path_inside_scope(file, &package_root) {
14287 graph.add_edge(
14288 &node.handle,
14289 handle,
14290 "owns_file",
14291 Some("Cargo package owns source file".to_string()),
14292 1,
14293 );
14294 }
14295 }
14296 for dependency in &package.dependencies {
14297 if let Some(handles) = package_handle_by_name.get(&dependency.name)
14298 && handles.len() == 1
14299 {
14300 graph.add_edge(
14301 &node.handle,
14302 &handles[0],
14303 "declares_dependency",
14304 Some(format!("{} Cargo dependency", dependency.kind)),
14305 1,
14306 );
14307 }
14308 }
14309 for alias in cargo_import_aliases(package)? {
14310 if let Some(handles) = package_handle_by_name.get(&alias)
14311 && handles.len() == 1
14312 && handles[0] != node.handle
14313 {
14314 graph.add_edge(
14315 &node.handle,
14316 &handles[0],
14317 "uses_crate",
14318 Some("Rust use/extern crate reference".to_string()),
14319 1,
14320 );
14321 graph.add_edge(
14322 &node.handle,
14323 &handles[0],
14324 "imports",
14325 Some("Rust use/extern crate import".to_string()),
14326 1,
14327 );
14328 }
14329 }
14330 }
14331
14332 Ok(())
14333}
14334
14335fn build_traversal_graph_source_with_options(
14336 root: &Path,
14337 path_hint: &Path,
14338 scope: Option<&str>,
14339 session_only: bool,
14340) -> Result<TraversalGraphBuild> {
14341 let mut graph = TraversalGraphBuild::default();
14342 let mut symbol_entries = Vec::new();
14343 let mut file_entries = Vec::new();
14344 let mut route_entries = Vec::new();
14345 let mut multiplicity_entries = Vec::new();
14346 let mut file_handle_by_path = HashMap::<String, String>::new();
14347 let bounded_session_projection = hinted_markdown_file(root, path_hint).is_some();
14348 if !session_only || hinted_markdown_file(root, path_hint).is_none() {
14349 let (gate, _cache_detail) =
14350 prepare_agent_doc_index_gate_cached(root, path_hint, scope, "graph traversal packet");
14351 graph.warnings.extend(gate.diagnostics);
14352 let gate_source_root = gate.source_root.clone();
14353
14354 match gate.db_path {
14355 Some(db_path) if db_path.exists() => {
14356 let db = index::IndexDb::open_read_only_resilient(&db_path)?;
14357 let file_paths = db.file_paths()?;
14358 for file in file_paths {
14359 if traversal_path_is_generated_artifact(
14360 root,
14361 &gate_source_root,
14362 Path::new(&file),
14363 ) {
14364 continue;
14365 }
14366 let node = traversal_file_node(root, &file);
14367 let entry = TraversalFileIndexEntry {
14368 handle: node.handle.clone(),
14369 tokens: traversal_node_tokens(&node),
14370 node: node.clone(),
14371 };
14372 if let Some(path) = entry.node.path.as_ref() {
14373 file_handle_by_path.insert(path.clone(), entry.handle.clone());
14374 }
14375 graph.add_node(node);
14376 file_entries.push(entry);
14377 }
14378
14379 let symbols = db.all_symbols()?;
14380 let mut symbol_by_file_name_line = HashMap::new();
14381 let mut span_by_file_name_line = HashMap::new();
14382 let mut first_symbol_by_name = BTreeMap::<String, String>::new();
14383 let mut first_span_by_name = BTreeMap::<String, String>::new();
14384 let mut ast_entries = Vec::<TraversalAstSpanIndexEntry>::new();
14385 let mut source_by_file = HashMap::<String, Option<Vec<u8>>>::new();
14386 for symbol in symbols.iter().filter(|symbol| {
14387 !traversal_path_is_generated_artifact(
14388 root,
14389 &gate_source_root,
14390 Path::new(&symbol.file),
14391 )
14392 }) {
14393 let node = traversal_symbol_node(root, symbol);
14394 let file = relativize(&symbol.file, root);
14395 symbol_by_file_name_line.insert(
14396 format!("{file}:{}:{}", symbol.line, symbol.name),
14397 node.handle.clone(),
14398 );
14399 first_symbol_by_name
14400 .entry(symbol.name.clone())
14401 .or_insert_with(|| node.handle.clone());
14402 let entry = TraversalSymbolIndexEntry {
14403 handle: node.handle.clone(),
14404 tokens: traversal_node_tokens(&node),
14405 node: node.clone(),
14406 };
14407 graph.add_node(node.clone());
14408 if let Some(file_handle) = file_handle_by_path.get(&file) {
14409 graph.add_edge(
14410 file_handle,
14411 &node.handle,
14412 "defines",
14413 Some("file defines symbol".to_string()),
14414 1,
14415 );
14416 }
14417 if !source_by_file.contains_key(&symbol.file) {
14418 let source_path =
14419 traversal_symbol_source_path(root, &gate_source_root, &symbol.file);
14420 source_by_file.insert(symbol.file.clone(), fs::read(source_path).ok());
14421 }
14422 if let Some(Some(source)) = source_by_file.get(&symbol.file)
14423 && let Some((ast_node, mut ast_entry)) =
14424 traversal_ast_span_node(root, symbol, source, &symbols)
14425 {
14426 ast_entry.symbol_handle = node.handle.clone();
14427 ast_entry.file_handle = file_handle_by_path.get(&file).cloned();
14428 span_by_file_name_line.insert(
14429 format!("{file}:{}:{}", symbol.line, symbol.name),
14430 ast_node.handle.clone(),
14431 );
14432 first_span_by_name
14433 .entry(symbol.name.clone())
14434 .or_insert_with(|| ast_node.handle.clone());
14435 graph.add_node(ast_node.clone());
14436 graph.add_edge(
14437 &node.handle,
14438 &ast_node.handle,
14439 "has_ast_span",
14440 Some("symbol projects to indexed AST span".to_string()),
14441 1,
14442 );
14443 graph.add_edge(
14444 &ast_node.handle,
14445 &node.handle,
14446 "represents_symbol",
14447 Some("AST span represents indexed symbol".to_string()),
14448 1,
14449 );
14450 ast_entries.push(ast_entry);
14451 }
14452 symbol_entries.push(entry);
14453 }
14454 link_ast_navigation_edges(&mut graph, &ast_entries);
14455 link_markdown_embedded_code_edges(&mut graph, root, &ast_entries);
14456
14457 if !bounded_session_projection {
14458 for edge in db.all_stored_edges()? {
14459 if traversal_path_is_generated_artifact(
14460 root,
14461 &gate_source_root,
14462 Path::new(&edge.caller_file),
14463 ) {
14464 continue;
14465 }
14466 let caller_file = relativize(&edge.caller_file, root);
14467 let caller_key =
14468 format!("{caller_file}:{}:{}", edge.caller_line, edge.caller_name);
14469 let Some(caller_handle) =
14470 symbol_by_file_name_line.get(&caller_key).cloned()
14471 else {
14472 continue;
14473 };
14474 let callee_handle = if let Some(handle) =
14475 first_symbol_by_name.get(&edge.callee_name)
14476 {
14477 handle.clone()
14478 } else {
14479 let node = traversal_unresolved_symbol_node(root, &edge.callee_name);
14480 let handle = node.handle.clone();
14481 graph.add_node(node);
14482 handle
14483 };
14484 graph.add_edge(
14485 &caller_handle,
14486 &callee_handle,
14487 "calls",
14488 Some(format!("call site {}:{}", caller_file, edge.call_site_line)),
14489 1,
14490 );
14491 if let Some(caller_span) = span_by_file_name_line.get(&caller_key)
14492 && let Some(callee_span) = first_span_by_name.get(&edge.callee_name)
14493 {
14494 graph.add_edge(
14495 caller_span,
14496 callee_span,
14497 "calls",
14498 Some(format!(
14499 "AST call site {}:{}",
14500 caller_file, edge.call_site_line
14501 )),
14502 1,
14503 );
14504 }
14505 }
14506 }
14507
14508 for route in db.all_routes()? {
14509 if traversal_path_is_generated_artifact(
14510 root,
14511 &gate_source_root,
14512 Path::new(&route.file),
14513 ) {
14514 continue;
14515 }
14516 let node = traversal_route_node(root, &route);
14517 let entry = TraversalRouteIndexEntry {
14518 handle: node.handle.clone(),
14519 tokens: traversal_node_tokens(&node),
14520 node: node.clone(),
14521 };
14522 graph.add_node(node.clone());
14523 if let Some(path) = node.path.as_ref()
14524 && let Some(file_handle) = file_handle_by_path.get(path)
14525 {
14526 graph.add_edge(
14527 file_handle,
14528 &node.handle,
14529 "defines_route",
14530 Some("file declares route".to_string()),
14531 1,
14532 );
14533 }
14534 let handler_handle =
14535 if let Some(handle) = first_symbol_by_name.get(&route.handler_name) {
14536 handle.clone()
14537 } else {
14538 let node = traversal_unresolved_symbol_node(root, &route.handler_name);
14539 let handle = node.handle.clone();
14540 graph.add_node(node);
14541 handle
14542 };
14543 graph.add_edge(
14544 &entry.handle,
14545 &handler_handle,
14546 "handled_by",
14547 Some("route handler reference".to_string()),
14548 1,
14549 );
14550 if let Some(handler_span) = first_span_by_name.get(&route.handler_name) {
14551 graph.add_edge(
14552 &entry.handle,
14553 handler_span,
14554 "handled_by",
14555 Some("route handler AST span".to_string()),
14556 1,
14557 );
14558 graph.add_edge(
14559 handler_span,
14560 &entry.handle,
14561 "handles_route",
14562 Some("AST span handles route".to_string()),
14563 1,
14564 );
14565 }
14566 route_entries.push(entry);
14567 }
14568 }
14569 _ => {
14570 add_raw_source_file_nodes(root, &gate_source_root, &mut graph, &mut file_entries)
14571 .with_context(|| {
14572 format!(
14573 "loading raw source fallback nodes from {}",
14574 gate_source_root.display()
14575 )
14576 })?;
14577 for entry in &file_entries {
14578 if let Some(path) = entry.node.path.as_ref() {
14579 file_handle_by_path.insert(path.clone(), entry.handle.clone());
14580 }
14581 }
14582 }
14583 }
14584 load_multiplicity_traversal_nodes(
14585 root,
14586 &gate_source_root,
14587 &mut graph,
14588 &file_handle_by_path,
14589 &mut multiplicity_entries,
14590 )?;
14591 }
14592
14593 let code_lookup = TraversalCodeLookup::new(
14594 &symbol_entries,
14595 &file_entries,
14596 &route_entries,
14597 &multiplicity_entries,
14598 );
14599 load_agent_doc_traversal_nodes(root, path_hint, &mut graph, &code_lookup)?;
14600 Ok(graph)
14601}
14602
14603#[cfg(test)]
14604fn build_traversal_graph_source(
14605 root: &Path,
14606 path_hint: &Path,
14607 scope: Option<&str>,
14608) -> Result<TraversalGraphBuild> {
14609 build_traversal_graph_source_with_options(root, path_hint, scope, false)
14610}
14611
14612const GRAPH_DB_WRITE_LOCK_TIMEOUT: Duration = Duration::from_secs(15);
14617const GRAPH_DB_WRITE_LOCK_POLL: Duration = Duration::from_millis(50);
14618
14619pub(crate) struct GraphDbWriteLock {
14621 file: std::fs::File,
14622}
14623
14624impl Drop for GraphDbWriteLock {
14625 fn drop(&mut self) {
14626 let _ = fs4::fs_std::FileExt::unlock(&self.file);
14627 }
14628}
14629
14630pub(crate) fn graph_db_write_lock_path(graph_db: &Path) -> PathBuf {
14631 let stem = graph_db
14632 .file_stem()
14633 .and_then(|stem| stem.to_str())
14634 .unwrap_or("graph");
14635 graph_db.with_file_name(format!("{stem}.write.lock"))
14636}
14637
14638pub(crate) fn acquire_graph_db_write_lock(graph_db: &Path) -> Result<GraphDbWriteLock> {
14645 acquire_graph_db_write_lock_with_timeout(graph_db, GRAPH_DB_WRITE_LOCK_TIMEOUT)
14646}
14647
14648pub(crate) fn acquire_graph_db_write_lock_with_timeout(
14649 graph_db: &Path,
14650 timeout: Duration,
14651) -> Result<GraphDbWriteLock> {
14652 use fs4::fs_std::FileExt;
14653
14654 let lock_path = graph_db_write_lock_path(graph_db);
14655 if let Some(parent) = lock_path.parent() {
14656 fs::create_dir_all(parent)
14657 .with_context(|| format!("creating graph-db lock dir: {}", parent.display()))?;
14658 }
14659 let file = std::fs::OpenOptions::new()
14660 .read(true)
14661 .write(true)
14662 .create(true)
14663 .truncate(false)
14664 .open(&lock_path)
14665 .with_context(|| format!("opening graph-db write lock {}", lock_path.display()))?;
14666
14667 let deadline = Instant::now() + timeout;
14668 loop {
14669 match file.try_lock_exclusive() {
14670 Ok(true) => return Ok(GraphDbWriteLock { file }),
14671 Ok(false) => {
14672 if Instant::now() >= deadline {
14673 bail!(
14674 "another tsift graph-db writer is active for {} (lock: {}); \
14675 a concurrent graph-db refresh or snapshot-import is in progress, \
14676 wait for it to finish before retrying",
14677 graph_db.display(),
14678 lock_path.display()
14679 );
14680 }
14681 std::thread::sleep(GRAPH_DB_WRITE_LOCK_POLL);
14682 }
14683 Err(err) => {
14684 return Err(err).with_context(|| {
14685 format!("locking graph-db write lock {}", lock_path.display())
14686 });
14687 }
14688 }
14689 }
14690}
14691
14692pub(crate) fn write_traversal_graph_store_with_options(
14693 root: &Path,
14694 path_hint: &Path,
14695 scope: Option<&str>,
14696 session_only: bool,
14697) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14698 let source_graph =
14699 build_traversal_graph_source_with_options(root, path_hint, scope, session_only)?;
14700 let projection = traversal_projection_from_graph(root, scope, &source_graph)?;
14701 let graph_db = graph_substrate_db_path(root, scope);
14702 let _write_lock = acquire_graph_db_write_lock(&graph_db)?;
14704 let mut store = SqliteGraphStore::open(&graph_db)?;
14705 let source_watermark = traversal_source_watermark(root, path_hint, scope, session_only)
14706 .ok()
14707 .flatten()
14708 .or_else(|| graph_projection_content_hash(&projection));
14709 let refresh = store.replace_projection_with_version(
14710 scope.unwrap_or("root"),
14711 &projection,
14712 Some(GRAPH_PROJECTION_VERSION),
14713 source_watermark,
14714 )?;
14715 Ok((source_graph, refresh))
14716}
14717
14718pub(crate) fn write_traversal_graph_store(
14719 root: &Path,
14720 path_hint: &Path,
14721 scope: Option<&str>,
14722) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14723 write_traversal_graph_store_with_options(root, path_hint, scope, false)
14724}
14725
14726fn refresh_traversal_graph_store_with_options(
14727 root: &Path,
14728 path_hint: &Path,
14729 scope: Option<&str>,
14730 session_only: bool,
14731) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14732 let (source_graph, refresh) =
14733 write_traversal_graph_store_with_options(root, path_hint, scope, session_only)?;
14734 let graph_db = graph_substrate_db_path(root, scope);
14735 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
14736 let mut graph = traversal_graph_from_store(root, &store)?;
14737 graph.warnings = source_graph.warnings;
14738 Ok((graph, refresh))
14739}
14740
14741fn refresh_traversal_graph_store(
14742 root: &Path,
14743 path_hint: &Path,
14744 scope: Option<&str>,
14745) -> Result<(TraversalGraphBuild, SqliteProjectionRefresh)> {
14746 refresh_traversal_graph_store_with_options(root, path_hint, scope, false)
14747}
14748
14749pub(crate) fn build_traversal_graph(
14750 root: &Path,
14751 path_hint: &Path,
14752 scope: Option<&str>,
14753) -> Result<TraversalGraphBuild> {
14754 let (graph, _refresh) = refresh_traversal_graph_store(root, path_hint, scope)?;
14755 Ok(graph)
14756}
14757
14758fn traversal_query_kind_priority(kind: &str) -> usize {
14759 match kind {
14760 "backlog" => 0,
14761 "job_packet" => 1,
14762 "worker_result" => 2,
14763 "symbol" => 3,
14764 "ast_span" => 4,
14765 "file" => 5,
14766 "route" => 6,
14767 "cargo_package" => 7,
14768 "cargo_workspace" => 8,
14769 "session" => 9,
14770 "semantic_concept" => 10,
14771 "semantic_entity" => 11,
14772 _ => 12,
14773 }
14774}
14775
14776fn traversal_node_match_rank(node: &TraversalNode, query: &str) -> Option<(usize, usize, String)> {
14777 let trimmed = query.trim();
14778 if trimmed.is_empty() {
14779 return None;
14780 }
14781 let kind_priority = traversal_query_kind_priority(&node.kind);
14782 if node.handle == trimmed {
14783 return Some((0, kind_priority, node.handle.clone()));
14784 }
14785 if node.path.as_deref() == Some(trimmed) {
14786 let path_priority = if node.kind == "file" {
14787 0
14788 } else {
14789 kind_priority.saturating_add(1)
14790 };
14791 return Some((1, path_priority, node.handle.clone()));
14792 }
14793 let normalized_backlog = trimmed.trim_start_matches('#');
14794 if node.ref_id.as_deref() == Some(trimmed) || node.ref_id.as_deref() == Some(normalized_backlog)
14795 {
14796 return Some((2, kind_priority, node.handle.clone()));
14797 }
14798 if node.label == trimmed || (node.kind == "symbol" && node.label == normalized_backlog) {
14799 return Some((3, kind_priority, node.handle.clone()));
14800 }
14801 None
14802}
14803
14804fn resolve_traversal_node<'a>(
14805 graph: &'a TraversalGraphBuild,
14806 query: &str,
14807) -> Option<&'a TraversalNode> {
14808 graph
14809 .nodes
14810 .values()
14811 .filter_map(|node| traversal_node_match_rank(node, query).map(|rank| (rank, node)))
14812 .min_by(|(left_rank, _), (right_rank, _)| left_rank.cmp(right_rank))
14813 .map(|(_, node)| node)
14814}
14815
14816fn traversal_adjacency(edges: &[TraversalEdge]) -> BTreeMap<String, Vec<String>> {
14817 let mut adj = BTreeMap::<String, BTreeSet<String>>::new();
14818 for edge in edges {
14819 adj.entry(edge.from.clone())
14820 .or_default()
14821 .insert(edge.to.clone());
14822 adj.entry(edge.to.clone())
14823 .or_default()
14824 .insert(edge.from.clone());
14825 }
14826 adj.into_iter()
14827 .map(|(node, neighbors)| (node, neighbors.into_iter().collect()))
14828 .collect()
14829}
14830
14831fn traversal_shortest_handles(
14832 edges: &[TraversalEdge],
14833 from: &str,
14834 to: &str,
14835) -> Option<Vec<String>> {
14836 if from == to {
14837 return Some(vec![from.to_string()]);
14838 }
14839 let adj = traversal_adjacency(edges);
14840 if !adj.contains_key(from) || !adj.contains_key(to) {
14841 return None;
14842 }
14843 let mut visited = BTreeSet::new();
14844 let mut queue = VecDeque::new();
14845 let mut parent = BTreeMap::<String, String>::new();
14846 visited.insert(from.to_string());
14847 queue.push_back(from.to_string());
14848 while let Some(current) = queue.pop_front() {
14849 if let Some(neighbors) = adj.get(¤t) {
14850 for neighbor in neighbors {
14851 if visited.insert(neighbor.clone()) {
14852 parent.insert(neighbor.clone(), current.clone());
14853 if neighbor == to {
14854 let mut path = vec![to.to_string()];
14855 let mut cursor = to.to_string();
14856 while let Some(prev) = parent.get(&cursor) {
14857 path.push(prev.clone());
14858 cursor = prev.clone();
14859 }
14860 path.reverse();
14861 return Some(path);
14862 }
14863 queue.push_back(neighbor.clone());
14864 }
14865 }
14866 }
14867 }
14868 None
14869}
14870
14871fn traversal_scored_neighbors(edges: &[TraversalEdge], current: &str) -> Vec<String> {
14872 let mut best_score_by_neighbor = BTreeMap::<String, usize>::new();
14873 for edge in edges {
14874 let neighbor = if edge.from == current {
14875 edge.to.as_str()
14876 } else if edge.to == current {
14877 edge.from.as_str()
14878 } else {
14879 continue;
14880 };
14881 let score = traversal_relation_score(edge, current);
14882 best_score_by_neighbor
14883 .entry(neighbor.to_string())
14884 .and_modify(|best| *best = (*best).max(score))
14885 .or_insert(score);
14886 }
14887 let mut ranked = best_score_by_neighbor.into_iter().collect::<Vec<_>>();
14888 ranked.sort_by(|(left_handle, left_score), (right_handle, right_score)| {
14889 right_score
14890 .cmp(left_score)
14891 .then_with(|| left_handle.cmp(right_handle))
14892 });
14893 ranked.into_iter().map(|(handle, _)| handle).collect()
14894}
14895
14896fn traversal_neighborhood_handles(
14897 edges: &[TraversalEdge],
14898 origin: &str,
14899 depth: usize,
14900 limit: usize,
14901) -> BTreeSet<String> {
14902 let mut seen = BTreeSet::new();
14903 let mut queue = VecDeque::new();
14904 seen.insert(origin.to_string());
14905 queue.push_back((origin.to_string(), 0usize));
14906 while let Some((current, current_depth)) = queue.pop_front() {
14907 if current_depth >= depth {
14908 continue;
14909 }
14910 for neighbor in traversal_scored_neighbors(edges, ¤t) {
14911 if limit > 0 && seen.len() >= limit {
14912 return seen;
14913 }
14914 if seen.insert(neighbor.clone()) {
14915 queue.push_back((neighbor, current_depth + 1));
14916 }
14917 }
14918 }
14919 seen
14920}
14921
14922fn traversal_edges_between(
14923 handles: &BTreeSet<String>,
14924 edges: &[TraversalEdge],
14925) -> Vec<TraversalEdge> {
14926 edges
14927 .iter()
14928 .filter(|edge| handles.contains(&edge.from) && handles.contains(&edge.to))
14929 .cloned()
14930 .collect()
14931}
14932
14933fn traversal_path_edges(path: &[String], edges: &[TraversalEdge]) -> Vec<TraversalEdge> {
14934 let mut result = Vec::new();
14935 for pair in path.windows(2) {
14936 if let Some(edge) = edges.iter().find(|edge| {
14937 (edge.from == pair[0] && edge.to == pair[1])
14938 || (edge.from == pair[1] && edge.to == pair[0])
14939 }) {
14940 result.push(edge.clone());
14941 }
14942 }
14943 result
14944}
14945
14946fn sorted_traversal_nodes<'a>(
14947 nodes: impl IntoIterator<Item = &'a TraversalNode>,
14948) -> Vec<TraversalNode> {
14949 let mut nodes = nodes.into_iter().cloned().collect::<Vec<_>>();
14950 nodes.sort_by(|left, right| {
14951 left.kind
14952 .cmp(&right.kind)
14953 .then_with(|| left.label.cmp(&right.label))
14954 .then_with(|| left.path.cmp(&right.path))
14955 .then_with(|| left.handle.cmp(&right.handle))
14956 });
14957 nodes
14958}
14959
14960fn traversal_relation_score(edge: &TraversalEdge, origin: &str) -> usize {
14961 let base = match edge.relation.as_str() {
14962 "mentions" => 100,
14963 "contains" => 80,
14964 "parent" | "child" | "has_ast_span" | "represents_symbol" => 78,
14965 "contains_embedded_symbol" | "embedded_in_fence" => 77,
14966 "contains_markdown_block"
14967 | "contains_embedded_code"
14968 | "enclosing_module"
14969 | "enclosing_section" => 76,
14970 "calls" => {
14971 if edge.from == origin {
14972 70
14973 } else {
14974 65
14975 }
14976 }
14977 "handled_by" | "handles_route" => 68,
14978 "defines_route" => 62,
14979 "imports" => 62,
14980 "previous_sibling" | "next_sibling" => 54,
14981 "mentions_concept" | "mentions_entity" => 66,
14982 "semantic_relation" => 64,
14983 "tagged_concept" | "related_concept" => 58,
14984 "defines" => {
14985 if edge.from == origin {
14986 60
14987 } else {
14988 55
14989 }
14990 }
14991 _ => 10,
14992 };
14993 base + edge.weight
14994}
14995
14996fn traversal_recommendation_reason(edge: &TraversalEdge, origin: &str) -> String {
14997 match edge.relation.as_str() {
14998 "mentions" => "matched from backlog/session text".to_string(),
14999 "contains" => "contained in the selected session artifact".to_string(),
15000 "has_ast_span" => "indexed AST span for the selected symbol".to_string(),
15001 "represents_symbol" => "indexed symbol represented by the selected AST span".to_string(),
15002 "parent" => "parent AST span".to_string(),
15003 "child" => "child AST span".to_string(),
15004 "previous_sibling" => "previous AST sibling".to_string(),
15005 "next_sibling" => "next AST sibling".to_string(),
15006 "contains_markdown_block" => "Markdown section block".to_string(),
15007 "contains_embedded_symbol" => "embedded code symbol in Markdown fence".to_string(),
15008 "embedded_in_fence" => "Markdown fence containing the embedded symbol".to_string(),
15009 "contains_embedded_code" => "embedded code symbol in Markdown section".to_string(),
15010 "enclosing_module" => "nearest enclosing module".to_string(),
15011 "enclosing_section" => "nearest enclosing Markdown section".to_string(),
15012 "defines" if edge.from == origin => "symbol defined in selected file".to_string(),
15013 "defines" => "file that defines the selected symbol".to_string(),
15014 "defines_route" if edge.from == origin => "route declared in selected file".to_string(),
15015 "defines_route" => "file that declares the selected route".to_string(),
15016 "handled_by" if edge.from == origin => "handler for the selected route".to_string(),
15017 "handled_by" => "route handled by the selected symbol".to_string(),
15018 "handles_route" => "route handled by the selected AST span".to_string(),
15019 "imports" => "import dependency from the selected package".to_string(),
15020 "mentions_concept" => "cached summary concept for the selected source".to_string(),
15021 "mentions_entity" => "cached summary entity for the selected source".to_string(),
15022 "semantic_relation" => "LLM-extracted semantic relationship".to_string(),
15023 "tagged_concept" => "concept label attached to the selected entity".to_string(),
15024 "related_concept" => "co-occurring cached summary concept".to_string(),
15025 "calls" if edge.from == origin => "callee from the selected symbol".to_string(),
15026 "calls" => "caller of the selected symbol".to_string(),
15027 other => format!("connected by {other}"),
15028 }
15029}
15030
15031fn traversal_recommendations(
15032 graph: &TraversalGraphBuild,
15033 origin: Option<&str>,
15034 shortest_path: Option<&[String]>,
15035 limit: usize,
15036) -> Vec<TraversalRecommendation> {
15037 let Some(origin) = origin else {
15038 return Vec::new();
15039 };
15040 let mut recommendations = Vec::new();
15041 let mut seen = BTreeSet::new();
15042
15043 if let Some(path) = shortest_path
15044 && path.len() > 1
15045 && path.first().is_some_and(|handle| handle == origin)
15046 && let Some(next) = graph.nodes.get(&path[1])
15047 {
15048 seen.insert(next.handle.clone());
15049 recommendations.push(TraversalRecommendation {
15050 handle: next.handle.clone(),
15051 kind: next.kind.clone(),
15052 label: next.label.clone(),
15053 reason: "next hop on shortest path".to_string(),
15054 score: 1_000,
15055 expand: next.expand.clone(),
15056 });
15057 }
15058
15059 let mut candidates = graph
15060 .edges
15061 .iter()
15062 .filter_map(|edge| {
15063 let neighbor = if edge.from == origin {
15064 edge.to.as_str()
15065 } else if edge.to == origin {
15066 edge.from.as_str()
15067 } else {
15068 return None;
15069 };
15070 let node = graph.nodes.get(neighbor)?;
15071 Some((traversal_relation_score(edge, origin), edge, node))
15072 })
15073 .collect::<Vec<_>>();
15074 candidates.sort_by(|(left_score, _, left), (right_score, _, right)| {
15075 right_score
15076 .cmp(left_score)
15077 .then_with(|| left.kind.cmp(&right.kind))
15078 .then_with(|| left.label.cmp(&right.label))
15079 .then_with(|| left.handle.cmp(&right.handle))
15080 });
15081
15082 let max = if limit == 0 { usize::MAX } else { limit };
15083 for (score, edge, node) in candidates {
15084 if recommendations.len() >= max {
15085 break;
15086 }
15087 if seen.insert(node.handle.clone()) {
15088 recommendations.push(TraversalRecommendation {
15089 handle: node.handle.clone(),
15090 kind: node.kind.clone(),
15091 label: node.label.clone(),
15092 reason: traversal_recommendation_reason(edge, origin),
15093 score,
15094 expand: node.expand.clone(),
15095 });
15096 }
15097 }
15098
15099 recommendations
15100}
15101
15102fn exploration_budget_for_counts(nodes: usize, edges: usize) -> ExplorationBudget {
15103 let scale = nodes.saturating_add(edges);
15104 if scale <= 80 {
15105 ExplorationBudget {
15106 project_size: "small".to_string(),
15107 max_source_windows: 8,
15108 lines_per_window: 96,
15109 relationship_limit: 40,
15110 }
15111 } else if scale <= 800 {
15112 ExplorationBudget {
15113 project_size: "medium".to_string(),
15114 max_source_windows: 6,
15115 lines_per_window: 80,
15116 relationship_limit: 32,
15117 }
15118 } else {
15119 ExplorationBudget {
15120 project_size: "large".to_string(),
15121 max_source_windows: 4,
15122 lines_per_window: 64,
15123 relationship_limit: 24,
15124 }
15125 }
15126}
15127
15128fn exploration_node_label(node: &TraversalNode) -> String {
15129 format!("{}:{}", node.kind, node.label)
15130}
15131
15132fn exploration_source_window_for_node(
15133 root: &Path,
15134 node: &TraversalNode,
15135 budget: &ExplorationBudget,
15136) -> Option<ExplorationSourceWindow> {
15137 let file = node.path.as_ref()?;
15138 let anchor = node
15139 .line
15140 .and_then(|line| usize::try_from(line).ok())
15141 .and_then(|line| line.checked_add(1))
15142 .unwrap_or(1);
15143 let context_before = budget.lines_per_window / 3;
15144 let start = anchor.saturating_sub(context_before).max(1);
15145 let end = start
15146 .saturating_add(budget.lines_per_window)
15147 .saturating_sub(1);
15148 let handle = stable_handle("xwin", &format!("{file}:{start}:{end}:{}", node.handle));
15149 Some(ExplorationSourceWindow {
15150 handle,
15151 file: file.clone(),
15152 start,
15153 end,
15154 reason: format!("cluster around {}", exploration_node_label(node)),
15155 expand: source_read_command(root, file, start, budget.lines_per_window),
15156 })
15157}
15158
15159fn build_exploration_packet(
15160 root: &Path,
15161 totals: &TraversalTotals,
15162 selected_nodes: &[TraversalNode],
15163 selected_edges: &[TraversalEdge],
15164) -> ExplorationPacket {
15165 let budget = exploration_budget_for_counts(totals.nodes, totals.edges);
15166 let node_by_handle = selected_nodes
15167 .iter()
15168 .map(|node| (node.handle.as_str(), node))
15169 .collect::<BTreeMap<_, _>>();
15170 let relationship_map = selected_edges
15171 .iter()
15172 .take(budget.relationship_limit)
15173 .filter_map(|edge| {
15174 let from = node_by_handle.get(edge.from.as_str())?;
15175 let to = node_by_handle.get(edge.to.as_str())?;
15176 Some(ExplorationRelation {
15177 from: exploration_node_label(from),
15178 relation: edge.relation.clone(),
15179 to: exploration_node_label(to),
15180 label: edge.label.clone(),
15181 })
15182 })
15183 .collect::<Vec<_>>();
15184
15185 let mut seen_windows = BTreeSet::new();
15186 let mut source_windows = Vec::new();
15187 for node in selected_nodes {
15188 if source_windows.len() >= budget.max_source_windows {
15189 break;
15190 }
15191 let Some(window) = exploration_source_window_for_node(root, node, &budget) else {
15192 continue;
15193 };
15194 let key = (window.file.clone(), window.start, window.end);
15195 if seen_windows.insert(key) {
15196 source_windows.push(window);
15197 }
15198 }
15199
15200 ExplorationPacket {
15201 budget,
15202 relationship_map,
15203 source_windows,
15204 worker_context: Vec::new(),
15205 no_reread_guidance:
15206 "Use the source_windows expand commands for line-numbered context; avoid whole-file reads unless the needed line is outside every listed window."
15207 .to_string(),
15208 }
15209}
15210
15211pub(crate) fn traversal_report(
15212 root: &Path,
15213 scope: Option<&str>,
15214 graph: TraversalGraphBuild,
15215 query: Option<&str>,
15216 target: Option<&str>,
15217 depth: usize,
15218 limit: usize,
15219) -> Result<TraversalReport> {
15220 let totals = TraversalTotals {
15221 nodes: graph.nodes.len(),
15222 edges: graph.edges.len(),
15223 };
15224 let origin_node = query.and_then(|value| resolve_traversal_node(&graph, value));
15225 let target_node = target.and_then(|value| resolve_traversal_node(&graph, value));
15226 if let Some(query) = query
15227 && origin_node.is_none()
15228 {
15229 bail!("traversal node not found: {}", query);
15230 }
15231 if let Some(target) = target
15232 && target_node.is_none()
15233 {
15234 bail!("traversal target not found: {}", target);
15235 }
15236
15237 let (mode, selected_nodes, selected_edges, shortest_path) =
15238 if let (Some(origin), Some(target)) = (origin_node, target_node) {
15239 if let Some(handles) =
15240 traversal_shortest_handles(&graph.edges, &origin.handle, &target.handle)
15241 {
15242 let handle_set = handles.iter().cloned().collect::<BTreeSet<_>>();
15243 let nodes = handles
15244 .iter()
15245 .filter_map(|handle| graph.nodes.get(handle).cloned())
15246 .collect::<Vec<_>>();
15247 let edges = traversal_path_edges(&handles, &graph.edges);
15248 let path = TraversalPathReport {
15249 from: origin.clone(),
15250 to: target.clone(),
15251 hops: handles.len().saturating_sub(1),
15252 nodes: nodes.clone(),
15253 edges: edges.clone(),
15254 };
15255 (
15256 "path".to_string(),
15257 nodes,
15258 traversal_edges_between(&handle_set, &graph.edges),
15259 Some(path),
15260 )
15261 } else {
15262 (
15263 "path".to_string(),
15264 vec![origin.clone(), target.clone()],
15265 Vec::new(),
15266 None,
15267 )
15268 }
15269 } else if let Some(origin) = origin_node {
15270 let handles =
15271 traversal_neighborhood_handles(&graph.edges, &origin.handle, depth, limit);
15272 let nodes =
15273 sorted_traversal_nodes(handles.iter().filter_map(|handle| graph.nodes.get(handle)));
15274 let edges = traversal_edges_between(&handles, &graph.edges);
15275 ("neighborhood".to_string(), nodes, edges, None)
15276 } else {
15277 let mut nodes = sorted_traversal_nodes(graph.nodes.values());
15278 let truncated_nodes = limit > 0 && nodes.len() > limit;
15279 if truncated_nodes {
15280 nodes.truncate(limit);
15281 }
15282 let handles = nodes
15283 .iter()
15284 .map(|node| node.handle.clone())
15285 .collect::<BTreeSet<_>>();
15286 let mut edges = traversal_edges_between(&handles, &graph.edges);
15287 let truncated_edges = limit > 0 && edges.len() > limit;
15288 if truncated_edges {
15289 edges.truncate(limit);
15290 }
15291 ("export".to_string(), nodes, edges, None)
15292 };
15293
15294 let shortest_handles = shortest_path.as_ref().map(|path| {
15295 path.nodes
15296 .iter()
15297 .map(|node| node.handle.clone())
15298 .collect::<Vec<_>>()
15299 });
15300 let recommendations = traversal_recommendations(
15301 &graph,
15302 origin_node.map(|node| node.handle.as_str()),
15303 shortest_handles.as_deref(),
15304 if limit == 0 { 10 } else { limit.min(10) },
15305 );
15306 let exploration = build_exploration_packet(root, &totals, &selected_nodes, &selected_edges);
15307 let truncated = selected_nodes.len() < totals.nodes || selected_edges.len() < totals.edges;
15308
15309 Ok(TraversalReport {
15310 root: root.to_string_lossy().to_string(),
15311 scope: scope.map(str::to_string),
15312 mode,
15313 totals,
15314 query: query.map(str::to_string),
15315 target: target.map(str::to_string),
15316 nodes: selected_nodes,
15317 edges: selected_edges,
15318 shortest_path,
15319 recommendations,
15320 exploration,
15321 truncated,
15322 warnings: graph.warnings,
15323 })
15324}
15325
15326fn html_escape(input: &str) -> String {
15327 input
15328 .replace('&', "&")
15329 .replace('<', "<")
15330 .replace('>', ">")
15331 .replace('"', """)
15332 .replace('\'', "'")
15333}
15334
15335pub(crate) fn traversal_report_html(report: &TraversalReport) -> Result<String> {
15336 let json = serde_json::to_string(report)?.replace("</", "<\\/");
15337 let mut html = String::new();
15338 html.push_str(
15339 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift traversal graph</title>",
15340 );
15341 html.push_str(
15342 r#"<style>
15343:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#ffffff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e;--semantic:#9a3412}
15344@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf;--semantic:#fb923c}}
15345*{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}}
15346</style>"#,
15347 );
15348 html.push_str("</head><body>");
15349 html.push_str("<div class=\"page\">");
15350 html.push_str(&format!(
15351 "<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>",
15352 html_escape(&report.mode),
15353 report.nodes.len(),
15354 report.totals.nodes,
15355 report.edges.len(),
15356 report.totals.edges
15357 ));
15358 html.push_str(
15359 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>"#,
15360 );
15361 html.push_str("<script id=\"graph-data\" type=\"application/json\">");
15362 html.push_str(&json);
15363 html.push_str(
15364 r##"</script><script>
15365const report = JSON.parse(document.getElementById("graph-data").textContent);
15366const svg = document.getElementById("graph-canvas");
15367const list = document.getElementById("node-list");
15368const selected = document.getElementById("selected");
15369const filter = document.getElementById("filter");
15370const legend = document.getElementById("legend");
15371const nodes = report.nodes.map((node, index) => ({...node, index}));
15372const nodeByHandle = new Map(nodes.map(node => [node.handle, node]));
15373const edges = report.edges.filter(edge => nodeByHandle.has(edge.from) && nodeByHandle.has(edge.to));
15374const colorByKind = new Map([
15375 ["file", "#2563eb"], ["symbol", "#16a34a"], ["route", "#7c3aed"],
15376 ["session", "#0891b2"], ["backlog", "#dc2626"], ["job_packet", "#ea580c"],
15377 ["semantic_concept", "#9a3412"], ["semantic_entity", "#b45309"],
15378 ["source_handle", "#64748b"], ["worker_context", "#475569"], ["worker_result", "#15803d"]
15379]);
15380function color(kind){ return colorByKind.get(kind) || "#6b7280"; }
15381function isSemantic(edge){ return edge.relation.includes("concept") || edge.relation.includes("entity") || edge.relation.includes("semantic"); }
15382function text(value){ return value == null ? "" : String(value); }
15383function matches(node, query){
15384 if (!query) return true;
15385 const haystack = [node.kind,node.label,node.handle,node.ref_id,node.path,node.detail].map(text).join(" ").toLowerCase();
15386 return haystack.includes(query);
15387}
15388function layout(){
15389 const rect = svg.getBoundingClientRect();
15390 const width = rect.width || 900;
15391 const height = rect.height || 650;
15392 const cx = width / 2;
15393 const cy = height / 2;
15394 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15395 const counts = new Map();
15396 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind) || 0) + 1);
15397 const offsets = new Map();
15398 for (const node of nodes) {
15399 const group = kinds.indexOf(node.kind);
15400 const index = offsets.get(node.kind) || 0;
15401 offsets.set(node.kind, index + 1);
15402 const groupCount = counts.get(node.kind) || 1;
15403 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
15404 const angle = (Math.PI * 2 * index / Math.max(groupCount, 1)) + (group * 0.47);
15405 node.x = cx + Math.cos(angle) * ring;
15406 node.y = cy + Math.sin(angle) * ring;
15407 }
15408}
15409function draw(){
15410 const query = filter.value.trim().toLowerCase();
15411 const visible = new Set(nodes.filter(node => matches(node, query)).map(node => node.handle));
15412 svg.innerHTML = "";
15413 for (const edge of edges) {
15414 if (!visible.has(edge.from) || !visible.has(edge.to)) continue;
15415 const from = nodeByHandle.get(edge.from);
15416 const to = nodeByHandle.get(edge.to);
15417 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
15418 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
15419 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
15420 line.setAttribute("class", "edge" + (isSemantic(edge) ? " semantic" : ""));
15421 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.relation + (edge.label ? ": " + edge.label : "");
15422 svg.appendChild(line);
15423 }
15424 for (const node of nodes) {
15425 if (!visible.has(node.handle)) continue;
15426 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
15427 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
15428 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
15429 circle.setAttribute("fill", color(node.kind));
15430 circle.setAttribute("class", "node" + (node.kind.startsWith("semantic_") ? " semantic" : ""));
15431 circle.addEventListener("click", () => selectNode(node));
15432 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
15433 svg.appendChild(circle);
15434 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
15435 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
15436 label.setAttribute("class", "node-label");
15437 label.textContent = node.label.length > 34 ? node.label.slice(0, 31) + "..." : node.label;
15438 svg.appendChild(label);
15439 }
15440 renderList(query);
15441}
15442function renderLegend(){
15443 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
15444 legend.innerHTML = kinds.map(kind => `<span><b style="color:${color(kind)}">●</b> ${kind}</span>`).join("");
15445}
15446function renderList(query){
15447 const rows = nodes.filter(node => matches(node, query)).slice(0, 120);
15448 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("");
15449 for (const row of list.querySelectorAll(".row")) {
15450 row.addEventListener("click", () => selectNode(nodeByHandle.get(row.dataset.handle)));
15451 }
15452}
15453function selectNode(node){
15454 const adjacent = edges.filter(edge => edge.from === node.handle || edge.to === node.handle).slice(0, 20);
15455 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>`;
15456}
15457function escapeHtml(value){
15458 return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));
15459}
15460filter.addEventListener("input", draw);
15461window.addEventListener("resize", () => { layout(); draw(); });
15462renderLegend();
15463layout();
15464draw();
15465if (nodes.length) selectNode(nodes[0]);
15466</script></div></body></html>"##,
15467 );
15468 Ok(html)
15469}
15470
15471fn semantic_related_report_from_store(
15472 root: &Path,
15473 scope: Option<&str>,
15474 query: &str,
15475 limit: usize,
15476 kind: SemanticRelatedKind,
15477 store: &impl GraphStore,
15478) -> Result<SemanticRelatedReport> {
15479 if query.trim().is_empty() {
15480 bail!("semantic query cannot be empty");
15481 }
15482
15483 let query_embedding = semantic_embedding(query);
15484 let node_kinds: &[&str] = match kind {
15485 SemanticRelatedKind::Concept => &["semantic_concept"],
15486 SemanticRelatedKind::Entity => &["semantic_entity"],
15487 SemanticRelatedKind::All => &["semantic_concept", "semantic_entity"],
15488 };
15489
15490 let items = store
15491 .semantic_top_candidates(&query_embedding, node_kinds, limit)?
15492 .into_iter()
15493 .map(|candidate| {
15494 let node = candidate.node;
15495 SemanticRelatedItem {
15496 handle: node
15497 .properties
15498 .get("handle")
15499 .cloned()
15500 .unwrap_or_else(|| node.id.clone()),
15501 kind: node.kind,
15502 label: node.label,
15503 score: candidate.score,
15504 file_path: node
15505 .properties
15506 .get("source_file")
15507 .or_else(|| node.properties.get("path"))
15508 .cloned(),
15509 source_symbol: node.properties.get("source_symbol").cloned(),
15510 detail: node
15511 .properties
15512 .get("description")
15513 .or_else(|| node.properties.get("detail"))
15514 .cloned(),
15515 expand: node
15516 .properties
15517 .get("expand")
15518 .cloned()
15519 .unwrap_or_else(|| traversal_expand_command(root, &node.id)),
15520 }
15521 })
15522 .collect::<Vec<_>>();
15523
15524 let mut warnings = Vec::new();
15525 if items.is_empty() {
15526 warnings.push(
15527 "no semantic graph rows found; run `tsift summarize --extract <path>` first"
15528 .to_string(),
15529 );
15530 }
15531
15532 Ok(SemanticRelatedReport {
15533 root: root.to_string_lossy().to_string(),
15534 scope: scope.map(str::to_string),
15535 query: query.to_string(),
15536 embedding_model: SEMANTIC_EMBEDDING_MODEL.to_string(),
15537 count: items.len(),
15538 items,
15539 warnings,
15540 })
15541}
15542
15543fn graph_store_semantic_node_count(store: &impl GraphStore) -> Result<usize> {
15544 Ok(store.nodes_by_kind("semantic_concept")?.len()
15545 + store.nodes_by_kind("semantic_entity")?.len())
15546}
15547
15548fn graph_db_semantic_edge_scan_cap(limit: usize) -> usize {
15549 if limit == 0 {
15550 return 0;
15551 }
15552 limit.saturating_mul(4).clamp(
15553 GRAPH_DB_SEMANTIC_MIN_EDGE_SCAN_CAP,
15554 GRAPH_DB_SEMANTIC_MAX_EDGE_SCAN_CAP,
15555 )
15556}
15557
15558fn graph_db_semantic_node_discovery_cap(seed_count: usize, limit: usize) -> usize {
15559 if limit == 0 {
15560 return usize::MAX;
15561 }
15562 limit.saturating_mul(3).max(limit).max(seed_count)
15563}
15564
15565fn graph_db_semantic_seeded_neighborhood(
15566 store: &impl GraphStore,
15567 seed_ids: &[String],
15568 depth: usize,
15569 limit: usize,
15570) -> Result<GraphDbSemanticSeededSubgraph> {
15571 let edge_scan_cap = graph_db_semantic_edge_scan_cap(limit);
15572 let node_discovery_cap = graph_db_semantic_node_discovery_cap(seed_ids.len(), limit);
15573 let mut diagnostics = vec![
15574 "semantic-seeded retrieval uses phrase similarity to pick graph seeds".to_string(),
15575 "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(),
15576 format!(
15577 "seed expansion ranks incident/outgoing edges before caps; per-node edge scan cap={} node discovery cap={}",
15578 if edge_scan_cap == 0 {
15579 "unbounded".to_string()
15580 } else {
15581 edge_scan_cap.to_string()
15582 },
15583 if node_discovery_cap == usize::MAX {
15584 "unbounded".to_string()
15585 } else {
15586 node_discovery_cap.to_string()
15587 }
15588 ),
15589 ];
15590
15591 let options = SemanticSeededNeighborhoodOptions::new(depth, limit)
15592 .with_edge_scan_cap(edge_scan_cap)
15593 .with_node_discovery_cap(node_discovery_cap);
15594 let result = store.semantic_seeded_neighborhood(seed_ids, &options)?;
15595
15596 for seed_id in &result.missing_seed_ids {
15597 diagnostics.push(format!(
15598 "semantic seed {seed_id} was not present in the graph store"
15599 ));
15600 }
15601
15602 if result.skipped_by_edge_cap > 0 {
15603 diagnostics.push(format!(
15604 "semantic-seeded expansion skipped {} lower-scoring incident/outgoing edge(s) after per-node caps",
15605 result.skipped_by_edge_cap
15606 ));
15607 }
15608 if result.skipped_by_node_cap > 0 {
15609 diagnostics.push(format!(
15610 "semantic-seeded expansion skipped {} lower-scoring node discovery edge(s) after the discovery cap",
15611 result.skipped_by_node_cap
15612 ));
15613 }
15614
15615 if result.truncated {
15616 diagnostics.push(format!(
15617 "semantic-seeded neighborhood truncated from {} to {limit} node(s)",
15618 result.total_discovered
15619 ));
15620 }
15621
15622 Ok(GraphDbSemanticSeededSubgraph {
15623 nodes: result.nodes,
15624 edges: result.edges,
15625 truncated: result.truncated,
15626 diagnostics,
15627 })
15628}
15629
15630#[allow(clippy::too_many_arguments)]
15631fn cmd_semantic_related(
15632 query: &str,
15633 path: &Path,
15634 scope: Option<&str>,
15635 limit: usize,
15636 kind: SemanticRelatedKind,
15637 json_output: bool,
15638 compact: bool,
15639 pretty: bool,
15640 terse: bool,
15641 schema: bool,
15642 profile: Option<String>,
15643) -> Result<()> {
15644 let root = lint::resolve_project_root_or_canonical_path(path)?;
15645 write_traversal_graph_store(&root, path, scope)?;
15646 let graph_db = graph_substrate_db_path(&root, scope);
15647 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)?;
15648 let mut report = semantic_related_report_from_store(&root, scope, query, limit, kind, &store)?;
15649 if let Some(recovery) = store.read_only_recovery() {
15650 report
15651 .warnings
15652 .push(graph_db_read_recovery_diagnostic(recovery));
15653 }
15654 if let Some(note) =
15655 profile_preference_note(profile.as_deref(), tsift_local_model::ModelRole::Embed)
15656 {
15657 report.warnings.push(note);
15658 }
15659
15660 if json_output {
15661 println!("{}", to_json_schema(&report, pretty, terse, false, schema)?);
15662 } else if compact {
15663 for item in &report.items {
15664 println!(
15665 "{:.3}\t{}\t{}\t{}",
15666 item.score, item.kind, item.label, item.handle
15667 );
15668 }
15669 for warning in &report.warnings {
15670 eprintln!("warning: {warning}");
15671 }
15672 } else {
15673 println!(
15674 "Related semantic graph rows for {:?} ({})",
15675 report.query, report.embedding_model
15676 );
15677 for item in &report.items {
15678 println!(
15679 " {:.3} [{}] {} ({})",
15680 item.score, item.kind, item.label, item.handle
15681 );
15682 if let Some(detail) = &item.detail {
15683 println!(" {}", detail);
15684 }
15685 if let Some(file_path) = &item.file_path {
15686 println!(" file: {}", file_path);
15687 }
15688 println!(" expand: {}", item.expand);
15689 }
15690 for warning in &report.warnings {
15691 eprintln!("warning: {warning}");
15692 }
15693 }
15694
15695 Ok(())
15696}
15697
15698fn profile_preference_note(
15703 profile: Option<&str>,
15704 role: tsift_local_model::ModelRole,
15705) -> Option<String> {
15706 let preference = tsift_local_model::ProfilePreference::from_cli(profile);
15707 if matches!(preference, tsift_local_model::ProfilePreference::Auto) {
15708 return None;
15709 }
15710 let probe = tsift_local_model::probe_nvidia_smi();
15711 let resolution = tsift_local_model::resolve_profile_preference(&preference, role, &probe);
15712 Some(format!(
15713 "profile preference {} -> {} ({})",
15714 preference.describe(),
15715 resolution.profile.id,
15716 resolution.reason
15717 ))
15718}
15719
15720#[derive(Serialize)]
15721struct SourceLinePreview {
15722 line: usize,
15723 text: String,
15724}
15725
15726#[derive(Serialize)]
15727pub(crate) struct SourceRangePreview {
15728 start: usize,
15729 end: usize,
15730 total_lines: usize,
15731 truncated_before: bool,
15732 truncated_after: bool,
15733}
15734
15735#[derive(Serialize)]
15736struct SourceExpandCommands {
15737 #[serde(skip_serializing_if = "Option::is_none")]
15738 before: Option<String>,
15739 #[serde(skip_serializing_if = "Option::is_none")]
15740 after: Option<String>,
15741 #[serde(skip_serializing_if = "Option::is_none")]
15742 body: Option<String>,
15743 file: String,
15744 #[serde(skip_serializing_if = "Option::is_none")]
15745 markdown_ast: Option<String>,
15746}
15747
15748#[derive(Serialize)]
15749struct SourceSymbolRef {
15750 handle: String,
15751 name: String,
15752 kind: String,
15753 language: String,
15754 file: String,
15755 line: usize,
15756 #[serde(skip_serializing_if = "Option::is_none")]
15757 end_line: Option<usize>,
15758 #[serde(skip_serializing_if = "Option::is_none")]
15759 signature: Option<String>,
15760 #[serde(skip_serializing_if = "Option::is_none")]
15761 span: Option<AstSpanPreview>,
15762 expand: String,
15763}
15764
15765#[derive(Serialize)]
15766struct SourceSummaryRef {
15767 handle: String,
15768 symbol_name: String,
15769 file_path: String,
15770 summary: String,
15771 expand: String,
15772}
15773
15774#[derive(Serialize)]
15775struct SourceReadReport {
15776 handle: String,
15777 root: String,
15778 file: String,
15779 range: SourceRangePreview,
15780 preview: Vec<SourceLinePreview>,
15781 symbols: Vec<SourceSymbolRef>,
15782 summaries: Vec<SourceSummaryRef>,
15783 #[serde(skip_serializing_if = "Option::is_none")]
15784 markdown: Option<SourceReadMarkdownProjection>,
15785 expand: SourceExpandCommands,
15786 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15787 warnings: Vec<String>,
15788}
15789
15790#[derive(Serialize)]
15791struct SourceReadAstExpandCommands {
15792 window: String,
15793 file_window: String,
15794 #[serde(skip_serializing_if = "Option::is_none")]
15795 markdown_ast: Option<String>,
15796}
15797
15798#[derive(Serialize)]
15799struct SourceReadAstReport {
15800 handle: String,
15801 root: String,
15802 file: String,
15803 range: SourceRangePreview,
15804 symbols: Vec<SourceSymbolRef>,
15805 summaries: Vec<SourceSummaryRef>,
15806 #[serde(skip_serializing_if = "Option::is_none")]
15807 markdown: Option<SourceReadMarkdownProjection>,
15808 expand: SourceReadAstExpandCommands,
15809 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15810 warnings: Vec<String>,
15811}
15812
15813#[derive(Serialize)]
15814struct SymbolReadTarget {
15815 handle: String,
15816 name: String,
15817 kind: String,
15818 language: String,
15819 file: String,
15820 line: usize,
15821 #[serde(skip_serializing_if = "Option::is_none")]
15822 end_line: Option<usize>,
15823 #[serde(skip_serializing_if = "Option::is_none")]
15824 signature: Option<String>,
15825 #[serde(skip_serializing_if = "Option::is_none")]
15826 parent_module: Option<String>,
15827 #[serde(skip_serializing_if = "Option::is_none")]
15828 visibility: Option<String>,
15829 #[serde(skip_serializing_if = "Option::is_none")]
15830 span: Option<AstSpanPreview>,
15831}
15832
15833#[derive(Serialize)]
15834struct SymbolReadExpandCommands {
15835 source_window: String,
15836 #[serde(skip_serializing_if = "Option::is_none")]
15837 body: Option<String>,
15838 file: String,
15839 explain: String,
15840 callers: String,
15841 callees: String,
15842 #[serde(skip_serializing_if = "Option::is_none")]
15843 markdown_ast: Option<String>,
15844}
15845
15846#[derive(Serialize)]
15847struct SymbolReadReport {
15848 handle: String,
15849 root: String,
15850 query: String,
15851 symbol: SymbolReadTarget,
15852 range: SourceRangePreview,
15853 body: Vec<SourceLinePreview>,
15854 child_symbols: Vec<SourceSymbolRef>,
15855 summaries: Vec<SourceSummaryRef>,
15856 expand: SymbolReadExpandCommands,
15857 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15858 warnings: Vec<String>,
15859}
15860
15861#[derive(Clone)]
15862pub(crate) struct MarkdownAstRawNode {
15863 handle: String,
15864 span_handle: String,
15865 name: String,
15866 kind: String,
15867 block_kind: String,
15868 node_kind: String,
15869 start_byte: usize,
15870 end_byte: usize,
15871 body_start_byte: Option<usize>,
15872 body_end_byte: Option<usize>,
15873}
15874
15875#[derive(Clone)]
15876pub(crate) struct MarkdownAstProjection {
15877 source_hash: String,
15878 nodes: Vec<MarkdownAstRawNode>,
15879 parse_duration_micros: u128,
15880 cache_hit: bool,
15881}
15882
15883#[derive(Clone)]
15884struct MarkdownAstCacheEntry {
15885 source_hash: String,
15886 nodes: Vec<MarkdownAstRawNode>,
15887 parse_duration_micros: u128,
15888}
15889
15890static MARKDOWN_AST_CACHE: OnceLock<Mutex<HashMap<String, MarkdownAstCacheEntry>>> =
15891 OnceLock::new();
15892
15893#[derive(Serialize, Clone)]
15894struct MarkdownAstNodeMetadata {
15895 #[serde(skip_serializing_if = "Option::is_none")]
15896 heading_level: Option<usize>,
15897 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15898 section_path: Vec<String>,
15899 #[serde(skip_serializing_if = "Option::is_none")]
15900 section_handle: Option<String>,
15901 #[serde(skip_serializing_if = "Option::is_none")]
15902 list_depth: Option<usize>,
15903 #[serde(skip_serializing_if = "Option::is_none")]
15904 list_marker: Option<String>,
15905 #[serde(skip_serializing_if = "Option::is_none")]
15906 list_order: Option<usize>,
15907 #[serde(skip_serializing_if = "Option::is_none")]
15908 fence_language: Option<String>,
15909 #[serde(skip_serializing_if = "Option::is_none")]
15910 fence_marker: Option<String>,
15911 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15912 embedded_symbols: Vec<MarkdownEmbeddedSymbol>,
15913}
15914
15915#[derive(Serialize, Clone)]
15916struct MarkdownAstNodeExpand {
15917 source_window: String,
15918 source_body: String,
15919 symbol_read: String,
15920 edit_intents: String,
15921}
15922
15923#[derive(Serialize, Clone)]
15924struct MarkdownAstCacheReport {
15925 source_hash: String,
15926 cache_hit: bool,
15927 parse_duration_micros: u128,
15928 node_count: usize,
15929 section_count: usize,
15930 list_item_count: usize,
15931 code_block_count: usize,
15932}
15933
15934#[derive(Serialize, Clone)]
15935struct MarkdownAstPhaseTiming {
15936 name: String,
15937 duration_micros: u128,
15938 detail: String,
15939}
15940
15941#[derive(Serialize, Clone)]
15942struct MarkdownAstOutlineEntry {
15943 handle: String,
15944 span_handle: String,
15945 name: String,
15946 kind: String,
15947 block_kind: String,
15948 line: usize,
15949 end_line: usize,
15950 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15951 section_path: Vec<String>,
15952 child_count: usize,
15953 expand: String,
15954}
15955
15956#[derive(Serialize, Clone)]
15957struct MarkdownAstProjectionPreview {
15958 mode: String,
15959 total_nodes: usize,
15960 returned_nodes: usize,
15961 omitted_nodes: usize,
15962 selected_node: Option<String>,
15963 cache: MarkdownAstCacheReport,
15964 outline: Vec<MarkdownAstOutlineEntry>,
15965 phase_timings: Vec<MarkdownAstPhaseTiming>,
15966}
15967
15968#[derive(Serialize)]
15969struct SourceReadMarkdownProjection {
15970 handle: String,
15971 mode: String,
15972 total_nodes: usize,
15973 visible_nodes: usize,
15974 outline: Vec<MarkdownAstOutlineEntry>,
15975 expand: String,
15976}
15977
15978#[derive(Serialize, Clone)]
15979struct SourceByteRangePreview {
15980 start: usize,
15981 end: usize,
15982}
15983
15984#[derive(Serialize, Clone)]
15985struct MarkdownAstNode {
15986 handle: String,
15987 span_handle: String,
15988 name: String,
15989 kind: String,
15990 block_kind: String,
15991 node_kind: String,
15992 line: usize,
15993 end_line: usize,
15994 byte_span: SourceByteRangePreview,
15995 #[serde(skip_serializing_if = "Option::is_none")]
15996 body_byte_span: Option<SourceByteRangePreview>,
15997 parent_handle: Option<String>,
15998 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15999 child_handles: Vec<String>,
16000 metadata: MarkdownAstNodeMetadata,
16001 expand: MarkdownAstNodeExpand,
16002}
16003
16004#[derive(Serialize)]
16005struct MarkdownAstExpandCommands {
16006 file: String,
16007 source_read: String,
16008 edit_intents: String,
16009}
16010
16011#[derive(Serialize)]
16012struct MarkdownAstReport {
16013 handle: String,
16014 root: String,
16015 file: String,
16016 range: SourceRangePreview,
16017 projection: MarkdownAstProjectionPreview,
16018 nodes: Vec<MarkdownAstNode>,
16019 expand: MarkdownAstExpandCommands,
16020 #[serde(skip_serializing_if = "Vec::is_empty", default)]
16021 warnings: Vec<String>,
16022}
16023
16024pub(crate) fn resolve_source_file(root: &Path, file: &Path) -> Result<PathBuf> {
16025 let candidate = if file.is_absolute() {
16026 file.to_path_buf()
16027 } else {
16028 root.join(file)
16029 };
16030 let canonical = candidate
16031 .canonicalize()
16032 .with_context(|| format!("canonicalizing source file {}", candidate.display()))?;
16033 if !canonical.is_file() {
16034 bail!("source file is not a regular file: {}", canonical.display());
16035 }
16036 let canonical_root = root
16037 .canonicalize()
16038 .with_context(|| format!("canonicalizing project root {}", root.display()))?;
16039 if !canonical.starts_with(&canonical_root) {
16040 bail!(
16041 "source file {} is outside project root {}",
16042 canonical.display(),
16043 canonical_root.display()
16044 );
16045 }
16046 Ok(canonical)
16047}
16048
16049pub(crate) fn source_read_command(root: &Path, file: &str, start: usize, lines: usize) -> String {
16050 source_read_window_command(root, file, start, lines)
16051}
16052
16053pub(crate) fn source_read_window_command(
16054 root: &Path,
16055 file: &str,
16056 start: usize,
16057 lines: usize,
16058) -> String {
16059 format!(
16060 "tsift --envelope source-read {} --path {} --style window --start {} --lines {} --budget normal",
16061 shell_quote(file),
16062 shell_quote(&root.to_string_lossy()),
16063 start,
16064 lines
16065 )
16066}
16067
16068pub(crate) fn source_read_ast_command(root: &Path, file: &str) -> String {
16069 format!(
16070 "tsift --envelope source-read {} --path {} --budget normal",
16071 shell_quote(file),
16072 shell_quote(&root.to_string_lossy())
16073 )
16074}
16075
16076pub(crate) fn source_symbol_read_command(root: &Path, symbol: &str, file: &str) -> String {
16077 format!(
16078 "tsift --envelope symbol-read {} --path {} --file {} --budget normal",
16079 shell_quote(symbol),
16080 shell_quote(&root.to_string_lossy()),
16081 shell_quote(file)
16082 )
16083}
16084
16085fn source_symbol_expand_command(root: &Path, symbol: &str) -> String {
16086 format!(
16087 "tsift --envelope explain {} --path {} --budget normal",
16088 shell_quote(symbol),
16089 shell_quote(&root.to_string_lossy())
16090 )
16091}
16092
16093fn source_symbol_graph_command(root: &Path, symbol: &str, relation: &str) -> String {
16094 format!(
16095 "tsift graph {} --path {} --{} --json",
16096 shell_quote(symbol),
16097 shell_quote(&root.to_string_lossy()),
16098 relation
16099 )
16100}
16101
16102fn source_summary_expand_command(root: &Path, symbol: &str) -> String {
16103 format!(
16104 "tsift summarize {} --path {} --json",
16105 shell_quote(symbol),
16106 shell_quote(&root.to_string_lossy())
16107 )
16108}
16109
16110pub(crate) fn markdown_ast_command(root: &Path, file: &str, node: Option<&str>) -> String {
16111 let mut command = format!(
16112 "tsift --envelope markdown-ast {} --path {} --budget normal",
16113 shell_quote(file),
16114 shell_quote(&root.to_string_lossy())
16115 );
16116 if let Some(node) = node {
16117 command.push_str(" --node ");
16118 command.push_str(&shell_quote(node));
16119 }
16120 command
16121}
16122
16123fn markdown_edit_intents_command(root: &Path) -> String {
16124 format!(
16125 "tsift --envelope edit-intents --path {} --budget normal",
16126 shell_quote(&root.to_string_lossy())
16127 )
16128}
16129
16130pub(crate) fn source_symbol_line(symbol: &index::StoredSymbol) -> usize {
16131 usize::try_from(symbol.line)
16132 .ok()
16133 .and_then(|line| line.checked_add(1))
16134 .unwrap_or(1)
16135}
16136
16137fn source_symbol_end_line(symbol: &index::StoredSymbol) -> Option<usize> {
16138 symbol
16139 .end_line
16140 .and_then(|line| usize::try_from(line).ok())
16141 .and_then(|line| line.checked_add(1))
16142}
16143
16144fn symbol_span_byte(value: Option<i64>) -> Option<usize> {
16145 value.and_then(|byte| usize::try_from(byte).ok())
16146}
16147
16148fn source_line_for_byte(source: &[u8], byte: usize) -> usize {
16149 let byte = byte.min(source.len());
16150 source[..byte]
16151 .iter()
16152 .filter(|value| **value == b'\n')
16153 .count()
16154 .saturating_add(1)
16155}
16156
16157fn source_line_for_end_byte(source: &[u8], end_byte: usize) -> usize {
16158 source_line_for_byte(source, end_byte.saturating_sub(1))
16159}
16160
16161fn ast_span_handle(
16162 file: &str,
16163 name: &str,
16164 kind: &str,
16165 start_byte: usize,
16166 end_byte: usize,
16167) -> String {
16168 stable_handle(
16169 "span",
16170 &format!("{file}:{kind}:{name}:{start_byte}:{end_byte}"),
16171 )
16172}
16173
16174pub(crate) fn stored_symbol_span_bounds(symbol: &index::StoredSymbol) -> Option<(usize, usize)> {
16175 Some((
16176 symbol_span_byte(symbol.start_byte)?,
16177 symbol_span_byte(symbol.end_byte)?,
16178 ))
16179}
16180
16181pub(crate) fn symbol_hit_span_bounds(symbol: &index::SymbolHit) -> Option<(usize, usize)> {
16182 Some((
16183 symbol_span_byte(symbol.start_byte)?,
16184 symbol_span_byte(symbol.end_byte)?,
16185 ))
16186}
16187
16188pub(crate) fn stored_symbol_span_handle(symbol: &index::StoredSymbol) -> Option<String> {
16189 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16190 Some(ast_span_handle(
16191 &symbol.file,
16192 &symbol.name,
16193 &symbol.kind,
16194 start_byte,
16195 end_byte,
16196 ))
16197}
16198
16199fn same_stored_symbol_span(left: &index::StoredSymbol, right: &index::StoredSymbol) -> bool {
16200 left.file == right.file
16201 && left.name == right.name
16202 && left.kind == right.kind
16203 && stored_symbol_span_bounds(left) == stored_symbol_span_bounds(right)
16204}
16205
16206fn stored_symbol_parent_span_handle(
16207 symbol: &index::StoredSymbol,
16208 symbols: &[index::StoredSymbol],
16209) -> Option<String> {
16210 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16211 symbols
16212 .iter()
16213 .filter(|candidate| {
16214 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16215 return false;
16216 }
16217 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16218 else {
16219 return false;
16220 };
16221 candidate_start <= start_byte && candidate_end >= end_byte
16222 })
16223 .min_by_key(|candidate| {
16224 stored_symbol_span_bounds(candidate)
16225 .map(|(start, end)| end.saturating_sub(start))
16226 .unwrap_or(usize::MAX)
16227 })
16228 .and_then(stored_symbol_span_handle)
16229}
16230
16231fn stored_symbol_child_span_handles(
16232 symbol: &index::StoredSymbol,
16233 symbols: &[index::StoredSymbol],
16234 limit: usize,
16235) -> Vec<String> {
16236 let Some((start_byte, end_byte)) = stored_symbol_span_bounds(symbol) else {
16237 return Vec::new();
16238 };
16239 symbols
16240 .iter()
16241 .filter(|candidate| {
16242 if candidate.file != symbol.file || same_stored_symbol_span(candidate, symbol) {
16243 return false;
16244 }
16245 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16246 else {
16247 return false;
16248 };
16249 candidate_start >= start_byte && candidate_end <= end_byte
16250 })
16251 .take(limit)
16252 .filter_map(stored_symbol_span_handle)
16253 .collect()
16254}
16255
16256fn markdown_heading_level(source: &[u8], start_byte: usize) -> Option<usize> {
16257 let start = start_byte.min(source.len());
16258 let line_end = source[start..]
16259 .iter()
16260 .position(|value| *value == b'\n')
16261 .map(|pos| start + pos)
16262 .unwrap_or(source.len());
16263 let line = std::str::from_utf8(&source[start..line_end]).unwrap_or("");
16264 let marker = line.trim_start();
16265 let level = marker.chars().take_while(|ch| *ch == '#').count();
16266 (1..=6).contains(&level).then_some(level)
16267}
16268
16269fn markdown_list_depth(source: &[u8], start_byte: usize) -> usize {
16270 let start = start_byte.min(source.len());
16271 let line_start = source[..start]
16272 .iter()
16273 .rposition(|value| *value == b'\n')
16274 .map(|pos| pos + 1)
16275 .unwrap_or(0);
16276 source[line_start..start]
16277 .iter()
16278 .map(|byte| match byte {
16279 b'\t' => 4,
16280 b' ' => 1,
16281 _ => 0,
16282 })
16283 .sum::<usize>()
16284 / 2
16285}
16286
16287fn markdown_enclosing_heading_symbols<'a>(
16288 file: &str,
16289 start_byte: usize,
16290 end_byte: usize,
16291 symbols: &'a [index::StoredSymbol],
16292) -> Vec<&'a index::StoredSymbol> {
16293 let mut headings = symbols
16294 .iter()
16295 .filter(|candidate| candidate.file == file && candidate.kind == "heading")
16296 .filter(|candidate| {
16297 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
16298 else {
16299 return false;
16300 };
16301 candidate_start <= start_byte && candidate_end >= end_byte
16302 })
16303 .collect::<Vec<_>>();
16304 headings.sort_by(|left, right| {
16305 stored_symbol_span_bounds(left)
16306 .map(|(start, _)| start)
16307 .unwrap_or(usize::MAX)
16308 .cmp(
16309 &stored_symbol_span_bounds(right)
16310 .map(|(start, _)| start)
16311 .unwrap_or(usize::MAX),
16312 )
16313 .then(left.name.cmp(&right.name))
16314 });
16315 headings
16316}
16317
16318fn markdown_stored_symbol_metadata(
16319 symbol: &index::StoredSymbol,
16320 source: &[u8],
16321 symbols: &[index::StoredSymbol],
16322) -> Option<MarkdownSpanMetadata> {
16323 if symbol.language != "markdown" {
16324 return None;
16325 }
16326 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16327 let section_symbols =
16328 markdown_enclosing_heading_symbols(&symbol.file, start_byte, end_byte, symbols);
16329 let section_path = section_symbols
16330 .iter()
16331 .map(|heading| heading.name.clone())
16332 .collect::<Vec<_>>();
16333 let section_handle = section_symbols
16334 .last()
16335 .and_then(|heading| stored_symbol_span_handle(heading));
16336 let heading_level = (symbol.kind == "heading")
16337 .then(|| markdown_heading_level(source, start_byte))
16338 .flatten();
16339 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16340 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16341 let embedded_symbols = if symbol.kind == "code_block" {
16342 markdown_embedded_symbols(
16343 &symbol.file,
16344 source,
16345 symbol_span_byte(symbol.body_start_byte),
16346 symbol_span_byte(symbol.body_end_byte),
16347 fence_language.as_deref(),
16348 )
16349 } else {
16350 Vec::new()
16351 };
16352
16353 (heading_level.is_some()
16354 || !section_path.is_empty()
16355 || section_handle.is_some()
16356 || list_depth.is_some()
16357 || fence_language.is_some()
16358 || !embedded_symbols.is_empty())
16359 .then_some(MarkdownSpanMetadata {
16360 heading_level,
16361 section_path,
16362 section_handle,
16363 list_depth,
16364 fence_language,
16365 embedded_symbols,
16366 })
16367}
16368
16369fn markdown_symbol_hit_metadata(
16370 symbol: &index::SymbolHit,
16371 source: &[u8],
16372 start_byte: usize,
16373) -> Option<MarkdownSpanMetadata> {
16374 if symbol.language != "markdown" {
16375 return None;
16376 }
16377 let heading_level = (symbol.kind == "heading")
16378 .then(|| markdown_heading_level(source, start_byte))
16379 .flatten();
16380 let list_depth = (symbol.kind == "list_item").then(|| markdown_list_depth(source, start_byte));
16381 let fence_language = (symbol.kind == "code_block").then(|| symbol.name.clone());
16382 let embedded_symbols = if symbol.kind == "code_block" {
16383 markdown_embedded_symbols(
16384 &symbol.file,
16385 source,
16386 symbol_span_byte(symbol.body_start_byte),
16387 symbol_span_byte(symbol.body_end_byte),
16388 fence_language.as_deref(),
16389 )
16390 } else {
16391 Vec::new()
16392 };
16393 (heading_level.is_some()
16394 || list_depth.is_some()
16395 || fence_language.is_some()
16396 || !embedded_symbols.is_empty())
16397 .then_some(MarkdownSpanMetadata {
16398 heading_level,
16399 section_path: Vec::new(),
16400 section_handle: None,
16401 list_depth,
16402 fence_language,
16403 embedded_symbols,
16404 })
16405}
16406
16407fn is_markdown_path(path: &Path) -> bool {
16408 path.extension()
16409 .and_then(|ext| ext.to_str())
16410 .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "mdx"))
16411 .unwrap_or(false)
16412}
16413
16414fn markdown_ast_block_kind(kind: &str) -> String {
16415 match kind {
16416 "heading" => "section",
16417 "code_block" => "fenced_code_block",
16418 "list_item" => "list_item",
16419 other => other,
16420 }
16421 .to_string()
16422}
16423
16424fn markdown_embedded_language_key(language: &str) -> Option<String> {
16425 let key = language
16426 .split_whitespace()
16427 .next()
16428 .unwrap_or("")
16429 .trim()
16430 .trim_start_matches("language-")
16431 .trim_start_matches("lang-")
16432 .trim_matches(|ch| matches!(ch, '`' | '"' | '\''))
16433 .to_ascii_lowercase();
16434 (!key.is_empty()).then_some(key)
16435}
16436
16437fn markdown_embedded_lang(language: &str) -> Option<graph::Lang> {
16438 let key = markdown_embedded_language_key(language)?;
16439 let extension = match key.as_str() {
16440 "rust" => "rs",
16441 "python" => "py",
16442 "typescript" => "ts",
16443 "javascript" => "js",
16444 "kotlin" => "kt",
16445 "shell" | "sh" | "zsh" => "bash",
16446 other => other,
16447 };
16448 let lang = graph::Lang::from_extension(extension)?;
16449 (lang.name() != "markdown").then_some(lang)
16450}
16451
16452fn markdown_embedded_ast_span_handle(
16453 file: &str,
16454 language: &str,
16455 name: &str,
16456 kind: &str,
16457 start_byte: usize,
16458 end_byte: usize,
16459) -> String {
16460 stable_handle(
16461 "span",
16462 &format!("{file}:embedded:{language}:{kind}:{name}:{start_byte}:{end_byte}"),
16463 )
16464}
16465
16466fn markdown_embedded_symbols(
16467 file: &str,
16468 source: &[u8],
16469 body_start_byte: Option<usize>,
16470 body_end_byte: Option<usize>,
16471 fence_language: Option<&str>,
16472) -> Vec<MarkdownEmbeddedSymbol> {
16473 let Some(fence_language) = fence_language else {
16474 return Vec::new();
16475 };
16476 let Some(lang) = markdown_embedded_lang(fence_language) else {
16477 return Vec::new();
16478 };
16479 let Some((body_start_byte, body_end_byte)) = body_start_byte.zip(body_end_byte) else {
16480 return Vec::new();
16481 };
16482 let Some(body) = source.get(body_start_byte.min(source.len())..body_end_byte.min(source.len()))
16483 else {
16484 return Vec::new();
16485 };
16486 if body.is_empty() {
16487 return Vec::new();
16488 }
16489
16490 let Ok(symbols) = lang.extract_symbols(body) else {
16491 return Vec::new();
16492 };
16493 let language = lang.name().to_string();
16494 symbols
16495 .into_iter()
16496 .map(|symbol| {
16497 let start_byte = body_start_byte.saturating_add(symbol.start_byte);
16498 let end_byte = body_start_byte.saturating_add(symbol.end_byte);
16499 let body_start = symbol
16500 .body_start_byte
16501 .map(|byte| body_start_byte.saturating_add(byte));
16502 let body_end = symbol
16503 .body_end_byte
16504 .map(|byte| body_start_byte.saturating_add(byte));
16505 let start_line = source_line_for_byte(source, start_byte);
16506 let end_line = source_line_for_end_byte(source, end_byte).max(start_line);
16507 MarkdownEmbeddedSymbol {
16508 handle: markdown_embedded_ast_span_handle(
16509 file,
16510 &language,
16511 &symbol.name,
16512 &symbol.kind,
16513 start_byte,
16514 end_byte,
16515 ),
16516 name: symbol.name,
16517 kind: symbol.kind,
16518 language: language.clone(),
16519 node_kind: symbol.node_kind,
16520 start_byte,
16521 end_byte,
16522 start_line,
16523 end_line,
16524 body_start_byte: body_start,
16525 body_end_byte: body_end,
16526 body_start_line: body_start.map(|byte| source_line_for_byte(source, byte)),
16527 body_end_line: body_end.map(|byte| source_line_for_end_byte(source, byte)),
16528 }
16529 })
16530 .collect()
16531}
16532
16533fn markdown_source_line(source: &[u8], start_byte: usize) -> &str {
16534 let start = start_byte.min(source.len());
16535 let line_start = source[..start]
16536 .iter()
16537 .rposition(|value| *value == b'\n')
16538 .map(|pos| pos + 1)
16539 .unwrap_or(0);
16540 let line_end = source[start..]
16541 .iter()
16542 .position(|value| *value == b'\n')
16543 .map(|pos| start + pos)
16544 .unwrap_or(source.len());
16545 std::str::from_utf8(&source[line_start..line_end]).unwrap_or("")
16546}
16547
16548fn markdown_list_attributes(source: &[u8], start_byte: usize) -> (Option<String>, Option<usize>) {
16549 let line = markdown_source_line(source, start_byte);
16550 let trimmed = line.trim_start();
16551 for marker in ["-", "*", "+"] {
16552 if trimmed
16553 .strip_prefix(marker)
16554 .and_then(|rest| rest.strip_prefix(' '))
16555 .is_some()
16556 {
16557 return (Some(marker.to_string()), None);
16558 }
16559 }
16560
16561 let digit_end = trimmed
16562 .find(|ch: char| !ch.is_ascii_digit())
16563 .unwrap_or(trimmed.len());
16564 let (digits, rest) = trimmed.split_at(digit_end);
16565 if !digits.is_empty() {
16566 for marker in [".", ")"] {
16567 if rest
16568 .strip_prefix(marker)
16569 .and_then(|value| value.strip_prefix(' '))
16570 .is_some()
16571 {
16572 return (
16573 Some(format!("{digits}{marker}")),
16574 digits.parse::<usize>().ok(),
16575 );
16576 }
16577 }
16578 }
16579 (None, None)
16580}
16581
16582fn markdown_fence_marker(source: &[u8], start_byte: usize) -> Option<String> {
16583 let line = markdown_source_line(source, start_byte);
16584 let trimmed = line.trim_start();
16585 ["```", "~~~"]
16586 .into_iter()
16587 .find(|marker| trimmed.starts_with(marker))
16588 .map(str::to_string)
16589}
16590
16591fn markdown_ast_extract_raw_nodes(file: &str, source: &[u8]) -> Result<Vec<MarkdownAstRawNode>> {
16592 let mut nodes = graph::Lang::Markdown
16593 .extract_symbols(source)
16594 .context("extracting Markdown AST nodes")?
16595 .into_iter()
16596 .map(|symbol| {
16597 let body_start_byte = symbol.body_start_byte;
16598 let body_end_byte = symbol.body_end_byte;
16599 let span_handle = ast_span_handle(
16600 file,
16601 &symbol.name,
16602 &symbol.kind,
16603 symbol.start_byte,
16604 symbol.end_byte,
16605 );
16606 MarkdownAstRawNode {
16607 handle: stable_handle(
16608 "mdast",
16609 &format!(
16610 "{}:{}:{}:{}:{}",
16611 file, symbol.kind, symbol.name, symbol.start_byte, symbol.end_byte
16612 ),
16613 ),
16614 span_handle,
16615 name: symbol.name,
16616 kind: symbol.kind.clone(),
16617 block_kind: markdown_ast_block_kind(&symbol.kind),
16618 node_kind: symbol.node_kind,
16619 start_byte: symbol.start_byte,
16620 end_byte: symbol.end_byte,
16621 body_start_byte,
16622 body_end_byte,
16623 }
16624 })
16625 .collect::<Vec<_>>();
16626 nodes.sort_by(|left, right| {
16627 left.start_byte
16628 .cmp(&right.start_byte)
16629 .then(left.end_byte.cmp(&right.end_byte))
16630 .then(left.kind.cmp(&right.kind))
16631 .then(left.name.cmp(&right.name))
16632 });
16633 Ok(nodes)
16634}
16635
16636pub(crate) fn markdown_ast_projection(file: &str, source: &[u8]) -> Result<MarkdownAstProjection> {
16637 let source_hash = blake3::hash(source).to_hex().to_string();
16638 let cache_key = format!("{file}:{source_hash}");
16639 let cache = MARKDOWN_AST_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
16640 if let Some(entry) = cache
16641 .lock()
16642 .expect("markdown ast cache poisoned")
16643 .get(&cache_key)
16644 {
16645 return Ok(MarkdownAstProjection {
16646 source_hash: entry.source_hash.clone(),
16647 nodes: entry.nodes.clone(),
16648 parse_duration_micros: entry.parse_duration_micros,
16649 cache_hit: true,
16650 });
16651 }
16652
16653 let started = Instant::now();
16654 let nodes = markdown_ast_extract_raw_nodes(file, source)?;
16655 let parse_duration_micros = started.elapsed().as_micros();
16656 cache.lock().expect("markdown ast cache poisoned").insert(
16657 cache_key,
16658 MarkdownAstCacheEntry {
16659 source_hash: source_hash.clone(),
16660 nodes: nodes.clone(),
16661 parse_duration_micros,
16662 },
16663 );
16664 Ok(MarkdownAstProjection {
16665 source_hash,
16666 nodes,
16667 parse_duration_micros,
16668 cache_hit: false,
16669 })
16670}
16671
16672fn markdown_ast_cache_report(projection: &MarkdownAstProjection) -> MarkdownAstCacheReport {
16673 MarkdownAstCacheReport {
16674 source_hash: projection.source_hash.clone(),
16675 cache_hit: projection.cache_hit,
16676 parse_duration_micros: projection.parse_duration_micros,
16677 node_count: projection.nodes.len(),
16678 section_count: projection
16679 .nodes
16680 .iter()
16681 .filter(|node| node.kind == "heading")
16682 .count(),
16683 list_item_count: projection
16684 .nodes
16685 .iter()
16686 .filter(|node| node.kind == "list_item")
16687 .count(),
16688 code_block_count: projection
16689 .nodes
16690 .iter()
16691 .filter(|node| node.kind == "code_block")
16692 .count(),
16693 }
16694}
16695
16696fn markdown_ast_node_direct_child_count(
16697 node: &MarkdownAstRawNode,
16698 nodes: &[MarkdownAstRawNode],
16699) -> usize {
16700 nodes
16701 .iter()
16702 .filter(|candidate| {
16703 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16704 })
16705 .count()
16706}
16707
16708fn markdown_ast_outline_entry(
16709 root: &Path,
16710 file: &str,
16711 source: &[u8],
16712 nodes: &[MarkdownAstRawNode],
16713 node: &MarkdownAstRawNode,
16714 max_bytes: usize,
16715) -> MarkdownAstOutlineEntry {
16716 let line = source_line_for_byte(source, node.start_byte);
16717 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16718 MarkdownAstOutlineEntry {
16719 handle: node.handle.clone(),
16720 span_handle: node.span_handle.clone(),
16721 name: truncate_for_budget(&node.name, max_bytes),
16722 kind: node.kind.clone(),
16723 block_kind: node.block_kind.clone(),
16724 line,
16725 end_line,
16726 section_path: markdown_ast_node_metadata(file, node, source, nodes).section_path,
16727 child_count: markdown_ast_node_direct_child_count(node, nodes),
16728 expand: markdown_ast_command(root, file, Some(&node.handle)),
16729 }
16730}
16731
16732fn markdown_ast_outline_entries(
16733 root: &Path,
16734 file: &str,
16735 source: &[u8],
16736 nodes: &[MarkdownAstRawNode],
16737 limit: usize,
16738 max_bytes: usize,
16739) -> Vec<MarkdownAstOutlineEntry> {
16740 let mut headings = nodes
16741 .iter()
16742 .filter(|node| node.kind == "heading")
16743 .collect::<Vec<_>>();
16744 let mut blocks = nodes
16745 .iter()
16746 .filter(|node| node.kind != "heading")
16747 .collect::<Vec<_>>();
16748 headings.sort_by_key(|node| (node.start_byte, node.end_byte));
16749 blocks.sort_by_key(|node| (node.start_byte, node.end_byte));
16750 headings
16751 .into_iter()
16752 .chain(blocks)
16753 .take(limit)
16754 .map(|node| markdown_ast_outline_entry(root, file, source, nodes, node, max_bytes))
16755 .collect()
16756}
16757
16758fn markdown_ast_node_intersects_lines(
16759 source: &[u8],
16760 node: &MarkdownAstRawNode,
16761 start: usize,
16762 end: usize,
16763) -> bool {
16764 let line = source_line_for_byte(source, node.start_byte);
16765 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16766 line <= end && end_line >= start
16767}
16768
16769fn source_read_markdown_projection(
16770 root: &Path,
16771 file: &str,
16772 source: &[u8],
16773 start: usize,
16774 end: usize,
16775 budget: ResponseBudget,
16776) -> Result<SourceReadMarkdownProjection> {
16777 let projection = markdown_ast_projection(file, source)?;
16778 let visible_nodes = projection
16779 .nodes
16780 .iter()
16781 .filter(|node| markdown_ast_node_intersects_lines(source, node, start, end))
16782 .collect::<Vec<_>>();
16783 let mut outline_nodes = visible_nodes.clone();
16784 outline_nodes.sort_by_key(|node| {
16785 (
16786 node.kind != "heading",
16787 node.start_byte,
16788 node.end_byte,
16789 node.name.as_str(),
16790 )
16791 });
16792 let outline = outline_nodes
16793 .into_iter()
16794 .take(budget.preview_items())
16795 .map(|node| {
16796 markdown_ast_outline_entry(
16797 root,
16798 file,
16799 source,
16800 &projection.nodes,
16801 node,
16802 budget.preview_bytes(),
16803 )
16804 })
16805 .collect::<Vec<_>>();
16806 Ok(SourceReadMarkdownProjection {
16807 handle: stable_handle(
16808 "mdproj",
16809 &format!("{file}:{start}:{end}:{}", projection.source_hash),
16810 ),
16811 mode: "window_outline".to_string(),
16812 total_nodes: projection.nodes.len(),
16813 visible_nodes: visible_nodes.len(),
16814 outline,
16815 expand: markdown_ast_command(root, file, None),
16816 })
16817}
16818
16819fn markdown_ast_contains(parent: &MarkdownAstRawNode, child: &MarkdownAstRawNode) -> bool {
16820 if parent.handle == child.handle {
16821 return false;
16822 }
16823 parent.start_byte <= child.start_byte && parent.end_byte >= child.end_byte
16824}
16825
16826fn markdown_ast_parent_handle(
16827 node: &MarkdownAstRawNode,
16828 nodes: &[MarkdownAstRawNode],
16829) -> Option<String> {
16830 nodes
16831 .iter()
16832 .filter(|candidate| markdown_ast_contains(candidate, node))
16833 .min_by_key(|candidate| {
16834 (
16835 candidate.end_byte.saturating_sub(candidate.start_byte),
16836 candidate.start_byte,
16837 )
16838 })
16839 .map(|candidate| candidate.handle.clone())
16840}
16841
16842fn markdown_ast_child_handles(
16843 node: &MarkdownAstRawNode,
16844 nodes: &[MarkdownAstRawNode],
16845 limit: usize,
16846) -> Vec<String> {
16847 nodes
16848 .iter()
16849 .filter(|candidate| {
16850 markdown_ast_parent_handle(candidate, nodes).as_deref() == Some(&node.handle)
16851 })
16852 .take(limit)
16853 .map(|candidate| candidate.handle.clone())
16854 .collect()
16855}
16856
16857fn markdown_ast_section_nodes<'a>(
16858 node: &MarkdownAstRawNode,
16859 nodes: &'a [MarkdownAstRawNode],
16860) -> Vec<&'a MarkdownAstRawNode> {
16861 let mut headings = nodes
16862 .iter()
16863 .filter(|candidate| candidate.kind == "heading")
16864 .filter(|candidate| {
16865 candidate.start_byte <= node.start_byte && candidate.end_byte >= node.end_byte
16866 })
16867 .collect::<Vec<_>>();
16868 headings.sort_by(|left, right| {
16869 left.start_byte
16870 .cmp(&right.start_byte)
16871 .then(left.end_byte.cmp(&right.end_byte))
16872 .then(left.name.cmp(&right.name))
16873 });
16874 headings
16875}
16876
16877fn markdown_ast_node_metadata(
16878 file: &str,
16879 node: &MarkdownAstRawNode,
16880 source: &[u8],
16881 nodes: &[MarkdownAstRawNode],
16882) -> MarkdownAstNodeMetadata {
16883 let section_nodes = markdown_ast_section_nodes(node, nodes);
16884 let section_path = section_nodes
16885 .iter()
16886 .map(|heading| heading.name.clone())
16887 .collect::<Vec<_>>();
16888 let section_handle = section_nodes.last().map(|heading| heading.handle.clone());
16889 let heading_level = (node.kind == "heading")
16890 .then(|| markdown_heading_level(source, node.start_byte))
16891 .flatten();
16892 let (list_marker, list_order) = if node.kind == "list_item" {
16893 markdown_list_attributes(source, node.start_byte)
16894 } else {
16895 (None, None)
16896 };
16897 let fence_language = (node.kind == "code_block").then(|| node.name.clone());
16898 let embedded_symbols = if node.kind == "code_block" {
16899 markdown_embedded_symbols(
16900 file,
16901 source,
16902 node.body_start_byte,
16903 node.body_end_byte,
16904 fence_language.as_deref(),
16905 )
16906 } else {
16907 Vec::new()
16908 };
16909 MarkdownAstNodeMetadata {
16910 heading_level,
16911 section_path,
16912 section_handle,
16913 list_depth: (node.kind == "list_item")
16914 .then(|| markdown_list_depth(source, node.start_byte)),
16915 list_marker,
16916 list_order,
16917 fence_language,
16918 fence_marker: (node.kind == "code_block")
16919 .then(|| markdown_fence_marker(source, node.start_byte))
16920 .flatten(),
16921 embedded_symbols,
16922 }
16923}
16924
16925fn markdown_ast_node_expand(
16926 root: &Path,
16927 file: &str,
16928 node: &MarkdownAstRawNode,
16929 source: &[u8],
16930) -> MarkdownAstNodeExpand {
16931 let start_line = source_line_for_byte(source, node.start_byte);
16932 let end_line = source_line_for_end_byte(source, node.end_byte).max(start_line);
16933 let line_count = end_line.saturating_sub(start_line).saturating_add(1).max(1);
16934 let body_start_line = node
16935 .body_start_byte
16936 .map(|byte| source_line_for_byte(source, byte))
16937 .unwrap_or(start_line);
16938 let body_end_line = node
16939 .body_end_byte
16940 .map(|byte| source_line_for_end_byte(source, byte))
16941 .unwrap_or(end_line)
16942 .max(body_start_line);
16943 let body_line_count = body_end_line
16944 .saturating_sub(body_start_line)
16945 .saturating_add(1)
16946 .max(1);
16947 MarkdownAstNodeExpand {
16948 source_window: source_read_command(root, file, start_line, line_count),
16949 source_body: source_read_command(root, file, body_start_line, body_line_count),
16950 symbol_read: source_symbol_read_command(root, &node.name, file),
16951 edit_intents: markdown_edit_intents_command(root),
16952 }
16953}
16954
16955fn markdown_ast_node(
16956 root: &Path,
16957 file: &str,
16958 node: &MarkdownAstRawNode,
16959 source: &[u8],
16960 nodes: &[MarkdownAstRawNode],
16961 child_limit: usize,
16962) -> MarkdownAstNode {
16963 let line = source_line_for_byte(source, node.start_byte);
16964 let end_line = source_line_for_end_byte(source, node.end_byte).max(line);
16965 let body_byte_span = node
16966 .body_start_byte
16967 .zip(node.body_end_byte)
16968 .map(|(start, end)| SourceByteRangePreview { start, end });
16969 MarkdownAstNode {
16970 handle: node.handle.clone(),
16971 span_handle: node.span_handle.clone(),
16972 name: node.name.clone(),
16973 kind: node.kind.clone(),
16974 block_kind: node.block_kind.clone(),
16975 node_kind: node.node_kind.clone(),
16976 line,
16977 end_line,
16978 byte_span: SourceByteRangePreview {
16979 start: node.start_byte,
16980 end: node.end_byte,
16981 },
16982 body_byte_span,
16983 parent_handle: markdown_ast_parent_handle(node, nodes),
16984 child_handles: markdown_ast_child_handles(node, nodes, child_limit),
16985 metadata: markdown_ast_node_metadata(file, node, source, nodes),
16986 expand: markdown_ast_node_expand(root, file, node, source),
16987 }
16988}
16989
16990pub(crate) fn stored_symbol_ast_span(
16991 symbol: &index::StoredSymbol,
16992 source: &[u8],
16993 symbols: &[index::StoredSymbol],
16994 child_limit: usize,
16995) -> Option<AstSpanPreview> {
16996 let (start_byte, end_byte) = stored_symbol_span_bounds(symbol)?;
16997 let node_kind = symbol.node_kind.clone()?;
16998 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
16999 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17000 Some(AstSpanPreview {
17001 handle: ast_span_handle(
17002 &symbol.file,
17003 &symbol.name,
17004 &symbol.kind,
17005 start_byte,
17006 end_byte,
17007 ),
17008 node_kind,
17009 start_byte,
17010 end_byte,
17011 start_line: source_line_for_byte(source, start_byte),
17012 end_line: source_line_for_end_byte(source, end_byte),
17013 body_start_byte,
17014 body_end_byte,
17015 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17016 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17017 parent_handle: stored_symbol_parent_span_handle(symbol, symbols),
17018 child_handles: stored_symbol_child_span_handles(symbol, symbols, child_limit),
17019 markdown: markdown_stored_symbol_metadata(symbol, source, symbols),
17020 })
17021}
17022
17023pub(crate) fn symbol_hit_ast_span(
17024 symbol: &index::SymbolHit,
17025 source: &[u8],
17026) -> Option<AstSpanPreview> {
17027 let (start_byte, end_byte) = symbol_hit_span_bounds(symbol)?;
17028 let node_kind = symbol.node_kind.clone()?;
17029 let body_start_byte = symbol_span_byte(symbol.body_start_byte);
17030 let body_end_byte = symbol_span_byte(symbol.body_end_byte);
17031 Some(AstSpanPreview {
17032 handle: ast_span_handle(
17033 &symbol.file,
17034 &symbol.name,
17035 &symbol.kind,
17036 start_byte,
17037 end_byte,
17038 ),
17039 node_kind,
17040 start_byte,
17041 end_byte,
17042 start_line: source_line_for_byte(source, start_byte),
17043 end_line: source_line_for_end_byte(source, end_byte),
17044 body_start_byte,
17045 body_end_byte,
17046 body_start_line: body_start_byte.map(|byte| source_line_for_byte(source, byte)),
17047 body_end_line: body_end_byte.map(|byte| source_line_for_end_byte(source, byte)),
17048 parent_handle: None,
17049 child_handles: Vec::new(),
17050 markdown: markdown_symbol_hit_metadata(symbol, source, start_byte),
17051 })
17052}
17053
17054pub(crate) fn symbol_hit_line(symbol: &index::SymbolHit) -> usize {
17055 usize::try_from(symbol.line)
17056 .ok()
17057 .and_then(|line| line.checked_add(1))
17058 .unwrap_or(1)
17059}
17060
17061pub(crate) fn symbol_hit_end_line(symbol: &index::SymbolHit) -> Option<usize> {
17062 symbol
17063 .end_line
17064 .and_then(|line| usize::try_from(line).ok())
17065 .and_then(|line| line.checked_add(1))
17066}
17067
17068fn source_symbol_intersects(symbol: &index::StoredSymbol, start: usize, end: usize) -> bool {
17069 if end == 0 {
17070 return false;
17071 }
17072 let symbol_start = source_symbol_line(symbol);
17073 let symbol_end = source_symbol_end_line(symbol).unwrap_or(symbol_start);
17074 symbol_start <= end && symbol_end >= start
17075}
17076
17077#[allow(clippy::too_many_arguments)]
17078fn load_source_symbols(
17079 root: &Path,
17080 file_abs: &Path,
17081 file_display: &str,
17082 source: &[u8],
17083 scope: Option<&str>,
17084 start: usize,
17085 end: usize,
17086 limit: usize,
17087 max_bytes: usize,
17088 warnings: &mut Vec<String>,
17089) -> Vec<SourceSymbolRef> {
17090 let target = match resolve_query_index_target(root, file_abs, scope) {
17091 Ok(target) => target,
17092 Err(err) => {
17093 warnings.push(format!("index refs unavailable: {err:#}"));
17094 return Vec::new();
17095 }
17096 };
17097 if let Err(err) = ensure_query_index_current(root, &target) {
17106 warnings.push(format!("index refs unavailable: {err:#}"));
17107 return Vec::new();
17108 }
17109 let db_path = target.db_path;
17110 if !db_path.exists() {
17111 warnings.push(format!(
17112 "index refs unavailable: no index found at {}",
17113 db_path.display()
17114 ));
17115 return Vec::new();
17116 }
17117
17118 let db = match index::IndexDb::open_read_only_resilient(&db_path) {
17119 Ok(db) => db,
17120 Err(err) => {
17121 warnings.push(format!("index refs unavailable: {err:#}"));
17122 return Vec::new();
17123 }
17124 };
17125
17126 let file_key = file_abs.to_string_lossy().to_string();
17127 let symbols = match db.symbols_for_file(&file_key) {
17128 Ok(symbols) => symbols,
17129 Err(err) => {
17130 warnings.push(format!("symbol refs unavailable: {err:#}"));
17131 return Vec::new();
17132 }
17133 };
17134
17135 symbols
17136 .iter()
17137 .filter(|symbol| source_symbol_intersects(symbol, start, end))
17138 .take(limit)
17139 .map(|symbol| {
17140 let line = source_symbol_line(symbol);
17141 let end_line = source_symbol_end_line(symbol);
17142 let handle = stable_handle(
17143 "ssym",
17144 &format!("{}:{}:{}", file_display, symbol.name, line),
17145 );
17146 SourceSymbolRef {
17147 handle,
17148 name: truncate_for_budget(&symbol.name, max_bytes),
17149 kind: symbol.kind.clone(),
17150 language: symbol.language.clone(),
17151 file: file_display.to_string(),
17152 line,
17153 end_line,
17154 signature: symbol
17155 .signature
17156 .clone()
17157 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17158 span: stored_symbol_ast_span(symbol, source, &symbols, limit),
17159 expand: source_symbol_read_command(root, &symbol.name, file_display),
17160 }
17161 })
17162 .collect()
17163}
17164
17165fn load_source_summaries(
17166 root: &Path,
17167 file_display: &str,
17168 limit: usize,
17169 max_bytes: usize,
17170 warnings: &mut Vec<String>,
17171) -> Vec<SourceSummaryRef> {
17172 let db_path = root.join(".tsift/summaries.db");
17173 if !db_path.exists() {
17174 return Vec::new();
17175 }
17176 let db = match summarize::SummaryDb::open_read_only_resilient(&db_path) {
17177 Ok(db) => db,
17178 Err(err) => {
17179 warnings.push(format!("summary refs unavailable: {err:#}"));
17180 return Vec::new();
17181 }
17182 };
17183 let summaries = match db.get_by_file(file_display) {
17184 Ok(summaries) => summaries,
17185 Err(err) => {
17186 warnings.push(format!("summary refs unavailable: {err:#}"));
17187 return Vec::new();
17188 }
17189 };
17190
17191 summaries
17192 .into_iter()
17193 .take(limit)
17194 .map(|summary| SourceSummaryRef {
17195 handle: stable_handle(
17196 "sum",
17197 &format!(
17198 "{}:{}:{}",
17199 summary.file_path, summary.symbol_name, summary.id
17200 ),
17201 ),
17202 symbol_name: truncate_for_budget(&summary.symbol_name, max_bytes),
17203 file_path: summary.file_path,
17204 summary: truncate_for_budget(&summary.summary, max_bytes),
17205 expand: source_summary_expand_command(root, &summary.symbol_name),
17206 })
17207 .collect()
17208}
17209
17210fn cmd_markdown_ast(
17211 file: &Path,
17212 path: &Path,
17213 node: Option<&str>,
17214 format: OutputFormat,
17215 absolute: bool,
17216 budget: ResponseBudget,
17217) -> Result<()> {
17218 let root = lint::resolve_project_root_or_canonical_path(path)?;
17219 let file_abs = resolve_source_file(&root, file)?;
17220 if !is_markdown_path(&file_abs) {
17221 bail!(
17222 "markdown-ast only supports Markdown files (.md/.mdx): {}",
17223 file_abs.display()
17224 );
17225 }
17226 let file_display = if absolute {
17227 file_abs.to_string_lossy().to_string()
17228 } else {
17229 relativize_pathbuf(&file_abs, &root)
17230 .to_string_lossy()
17231 .to_string()
17232 };
17233 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17234 let text = String::from_utf8_lossy(&source);
17235 let total_lines = text.lines().count();
17236 let projection = markdown_ast_projection(&file_display, &source)?;
17237 let raw_nodes = &projection.nodes;
17238 let max_items = budget.preview_items();
17239 let max_bytes = budget.preview_bytes();
17240
17241 let selected_nodes = if let Some(handle) = node {
17242 let matches = raw_nodes
17243 .iter()
17244 .filter(|candidate| candidate.handle == handle || candidate.span_handle == handle)
17245 .collect::<Vec<_>>();
17246 if matches.is_empty() {
17247 bail!("Markdown AST node handle {handle:?} was not found in {file_display}");
17248 }
17249 matches
17250 } else {
17251 raw_nodes.iter().take(max_items).collect::<Vec<_>>()
17252 };
17253 let nodes = selected_nodes
17254 .into_iter()
17255 .map(|raw| {
17256 let mut node =
17257 markdown_ast_node(&root, &file_display, raw, &source, raw_nodes, max_items);
17258 node.name = truncate_for_budget(&node.name, max_bytes);
17259 node
17260 })
17261 .collect::<Vec<_>>();
17262 let outline_started = Instant::now();
17263 let outline = markdown_ast_outline_entries(
17264 &root,
17265 &file_display,
17266 &source,
17267 raw_nodes,
17268 max_items,
17269 max_bytes,
17270 );
17271 let outline_duration_micros = outline_started.elapsed().as_micros();
17272 let projection_preview = MarkdownAstProjectionPreview {
17273 mode: if node.is_some() {
17274 "selected_node".to_string()
17275 } else {
17276 "outline_first".to_string()
17277 },
17278 total_nodes: raw_nodes.len(),
17279 returned_nodes: nodes.len(),
17280 omitted_nodes: raw_nodes.len().saturating_sub(nodes.len()),
17281 selected_node: node.map(str::to_string),
17282 cache: markdown_ast_cache_report(&projection),
17283 outline,
17284 phase_timings: vec![
17285 MarkdownAstPhaseTiming {
17286 name: "parse_extract".to_string(),
17287 duration_micros: projection.parse_duration_micros,
17288 detail: if projection.cache_hit {
17289 "reused cached tree-sitter Markdown symbol extraction".to_string()
17290 } else {
17291 "tree-sitter Markdown symbol extraction".to_string()
17292 },
17293 },
17294 MarkdownAstPhaseTiming {
17295 name: "outline_projection".to_string(),
17296 duration_micros: outline_duration_micros,
17297 detail: "outline-first section/block preview construction".to_string(),
17298 },
17299 ],
17300 };
17301 let report = MarkdownAstReport {
17302 handle: stable_handle("mdastrep", &file_display),
17303 root: root.to_string_lossy().to_string(),
17304 file: file_display.clone(),
17305 range: SourceRangePreview {
17306 start: 1,
17307 end: total_lines,
17308 total_lines,
17309 truncated_before: false,
17310 truncated_after: false,
17311 },
17312 projection: projection_preview,
17313 nodes,
17314 expand: MarkdownAstExpandCommands {
17315 file: markdown_ast_command(&root, &file_display, None),
17316 source_read: source_read_command(&root, &file_display, 1, total_lines.max(1)),
17317 edit_intents: markdown_edit_intents_command(&root),
17318 },
17319 warnings: Vec::new(),
17320 };
17321
17322 if format.json_output {
17323 let truncated = node.is_none() && raw_nodes.len() > report.nodes.len();
17324 let mut follow_up = vec![
17325 report.expand.file.clone(),
17326 report.expand.source_read.clone(),
17327 report.expand.edit_intents.clone(),
17328 ];
17329 follow_up.extend(
17330 report
17331 .nodes
17332 .iter()
17333 .map(|node| node.expand.source_window.clone()),
17334 );
17335 print_json_or_envelope(
17336 &report,
17337 &format,
17338 "markdown-ast",
17339 "ast",
17340 ToolEnvelopeSummary {
17341 text: format!("markdown ast {} nodes:{}", report.file, report.nodes.len()),
17342 metrics: vec![
17343 envelope_metric("nodes", report.nodes.len()),
17344 envelope_metric("total_nodes", report.projection.total_nodes),
17345 envelope_metric(
17346 "parse_duration_micros",
17347 report.projection.cache.parse_duration_micros,
17348 ),
17349 envelope_metric("total_lines", report.range.total_lines),
17350 ],
17351 },
17352 truncated,
17353 follow_up,
17354 )?;
17355 } else if format.compact {
17356 println!(
17357 "markdown-ast {} nodes:{} handle:{}",
17358 report.file,
17359 report.nodes.len(),
17360 report.handle
17361 );
17362 for node in &report.nodes {
17363 println!(
17364 " {} {} {}:{}-{}",
17365 node.handle, node.kind, node.name, node.line, node.end_line
17366 );
17367 }
17368 if node.is_none() && raw_nodes.len() > report.nodes.len() {
17369 println!("expand: {}", report.expand.file);
17370 }
17371 } else {
17372 println!(
17373 "Markdown AST `{}` nodes {} of {} ({})",
17374 report.file,
17375 report.nodes.len(),
17376 raw_nodes.len(),
17377 report.handle
17378 );
17379 for node in &report.nodes {
17380 println!(
17381 " {} `{}` {}:{}-{} — {}",
17382 node.handle,
17383 node.name,
17384 node.kind,
17385 node.line,
17386 node.end_line,
17387 node.expand.source_window
17388 );
17389 }
17390 if node.is_none() && raw_nodes.len() > report.nodes.len() {
17391 println!();
17392 println!("Expand:");
17393 println!(" file: {}", report.expand.file);
17394 }
17395 }
17396
17397 Ok(())
17398}
17399
17400#[allow(clippy::too_many_arguments)]
17401fn cmd_source_read(
17402 file: &Path,
17403 path: &Path,
17404 style: SourceReadStyle,
17405 start: usize,
17406 lines: usize,
17407 end: Option<usize>,
17408 scope: Option<&str>,
17409 format: OutputFormat,
17410 absolute: bool,
17411 budget: ResponseBudget,
17412) -> Result<()> {
17413 if start == 0 {
17414 bail!("--start is 1-based and must be greater than zero");
17415 }
17416 if lines == 0 {
17417 bail!("--lines must be greater than zero");
17418 }
17419 if let Some(end) = end
17420 && end < start
17421 {
17422 bail!("--end must be greater than or equal to --start");
17423 }
17424
17425 let root = lint::resolve_project_root_or_canonical_path(path)?;
17426 let file_abs = resolve_source_file(&root, file)?;
17427 let file_display = if absolute {
17428 file_abs.to_string_lossy().to_string()
17429 } else {
17430 relativize_pathbuf(&file_abs, &root)
17431 .to_string_lossy()
17432 .to_string()
17433 };
17434
17435 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17436 let text = String::from_utf8_lossy(&source);
17437 let all_lines: Vec<&str> = text.lines().collect();
17438 let total_lines = all_lines.len();
17439 if total_lines > 0 && start > total_lines {
17440 bail!(
17441 "--start {} is beyond end of {} ({} lines)",
17442 start,
17443 file_display,
17444 total_lines
17445 );
17446 }
17447 let requested_end = end.unwrap_or_else(|| start.saturating_add(lines).saturating_sub(1));
17448 let end_line = requested_end.min(total_lines);
17449 let mut warnings = Vec::new();
17450 let max_items = budget.preview_items();
17451 let max_bytes = budget.preview_bytes();
17452 if style == SourceReadStyle::Ast {
17453 let symbols = load_source_symbols(
17454 &root,
17455 &file_abs,
17456 &file_display,
17457 &source,
17458 scope,
17459 start,
17460 end_line,
17461 max_items,
17462 max_bytes,
17463 &mut warnings,
17464 );
17465 let summaries =
17466 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17467 let markdown = if is_markdown_path(&file_abs) {
17468 match source_read_markdown_projection(
17469 &root,
17470 &file_display,
17471 &source,
17472 start,
17473 end_line,
17474 budget,
17475 ) {
17476 Ok(markdown) => Some(markdown),
17477 Err(err) => {
17478 warnings.push(format!("markdown projection unavailable: {err:#}"));
17479 None
17480 }
17481 }
17482 } else {
17483 None
17484 };
17485 let window_lines = end_line.saturating_sub(start).saturating_add(1).max(1);
17486 let report = SourceReadAstReport {
17487 handle: stable_handle("sast", &format!("{file_display}:{start}:{end_line}")),
17488 root: root.to_string_lossy().to_string(),
17489 file: file_display.clone(),
17490 range: SourceRangePreview {
17491 start,
17492 end: end_line,
17493 total_lines,
17494 truncated_before: start > 1,
17495 truncated_after: end_line < total_lines,
17496 },
17497 symbols,
17498 summaries,
17499 markdown,
17500 expand: SourceReadAstExpandCommands {
17501 window: source_read_window_command(&root, &file_display, start, window_lines),
17502 file_window: source_read_window_command(
17503 &root,
17504 &file_display,
17505 1,
17506 total_lines.max(window_lines),
17507 ),
17508 markdown_ast: is_markdown_path(&file_abs)
17509 .then(|| markdown_ast_command(&root, &file_display, None)),
17510 },
17511 warnings,
17512 };
17513
17514 if format.json_output {
17515 let truncated = report.range.truncated_before
17516 || report.range.truncated_after
17517 || report.symbols.len() >= max_items
17518 || report.summaries.len() >= max_items;
17519 let follow_up = [
17520 Some(report.expand.window.clone()),
17521 Some(report.expand.file_window.clone()),
17522 report.expand.markdown_ast.clone(),
17523 ]
17524 .into_iter()
17525 .flatten()
17526 .collect::<Vec<_>>();
17527 print_json_or_envelope(
17528 &report,
17529 &format,
17530 "source-read",
17531 "ast",
17532 ToolEnvelopeSummary {
17533 text: format!(
17534 "source ast {}:{}-{}",
17535 report.file, report.range.start, report.range.end
17536 ),
17537 metrics: vec![
17538 envelope_metric("symbols", report.symbols.len()),
17539 envelope_metric("summaries", report.summaries.len()),
17540 envelope_metric(
17541 "markdown_nodes",
17542 report
17543 .markdown
17544 .as_ref()
17545 .map_or(0, |markdown| markdown.visible_nodes),
17546 ),
17547 ],
17548 },
17549 truncated,
17550 follow_up,
17551 )?;
17552 } else if format.compact {
17553 println!(
17554 "source-ast {}:{}-{} / {} handle:{}",
17555 report.file,
17556 report.range.start,
17557 report.range.end,
17558 report.range.total_lines,
17559 report.handle
17560 );
17561 for symbol in &report.symbols {
17562 println!(
17563 " {} {}:{} {}",
17564 symbol.name, symbol.file, symbol.line, symbol.expand
17565 );
17566 }
17567 if !report.summaries.is_empty() {
17568 println!("summaries[{}]", report.summaries.len());
17569 }
17570 for warning in &report.warnings {
17571 eprintln!("warning: {warning}");
17572 }
17573 } else {
17574 println!(
17575 "Source AST `{}` lines {}-{} of {} ({})",
17576 report.file,
17577 report.range.start,
17578 report.range.end,
17579 report.range.total_lines,
17580 report.handle
17581 );
17582 if !report.symbols.is_empty() {
17583 println!();
17584 println!("Symbol refs:");
17585 for symbol in &report.symbols {
17586 println!(
17587 " {} `{}` {}:{} — {}",
17588 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17589 );
17590 }
17591 }
17592 if !report.summaries.is_empty() {
17593 println!();
17594 println!("Summary refs:");
17595 for summary in &report.summaries {
17596 println!(
17597 " {} `{}` — {}",
17598 summary.handle, summary.symbol_name, summary.expand
17599 );
17600 }
17601 }
17602 println!();
17603 println!("Expand:");
17604 println!(" window: {}", report.expand.window);
17605 println!(" file window: {}", report.expand.file_window);
17606 if let Some(markdown_ast) = &report.expand.markdown_ast {
17607 println!(" markdown: {}", markdown_ast);
17608 }
17609 for warning in &report.warnings {
17610 eprintln!("warning: {warning}");
17611 }
17612 }
17613
17614 return Ok(());
17615 }
17616 let max_bytes = budget.preview_bytes();
17617 let token_cap = budget.body_token_cap();
17618 let (preview, preview_end, body_truncated) = if total_lines == 0 {
17619 (Vec::new(), end_line, false)
17620 } else {
17621 let capped = build_token_capped_preview(&all_lines, start, end_line, max_bytes, token_cap);
17622 (capped.preview, capped.capped_end, capped.was_capped)
17623 };
17624 let effective_end = if body_truncated {
17625 preview_end
17626 } else {
17627 end_line
17628 };
17629
17630 if body_truncated {
17631 warnings.push(format!(
17632 "body preview capped at ~{token_cap} tokens at line {preview_end} of {end_line}"
17633 ));
17634 }
17635 let symbols = load_source_symbols(
17636 &root,
17637 &file_abs,
17638 &file_display,
17639 &source,
17640 scope,
17641 start,
17642 effective_end,
17643 max_items,
17644 max_bytes,
17645 &mut warnings,
17646 );
17647 let summaries =
17648 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17649 let markdown = if is_markdown_path(&file_abs) {
17650 match source_read_markdown_projection(
17651 &root,
17652 &file_display,
17653 &source,
17654 start,
17655 effective_end,
17656 budget,
17657 ) {
17658 Ok(markdown) => Some(markdown),
17659 Err(err) => {
17660 warnings.push(format!("markdown projection unavailable: {err:#}"));
17661 None
17662 }
17663 }
17664 } else {
17665 None
17666 };
17667
17668 let expand = SourceExpandCommands {
17669 before: (start > 1).then(|| {
17670 let before_start = start.saturating_sub(lines).max(1);
17671 source_read_window_command(&root, &file_display, before_start, start - before_start)
17672 }),
17673 after: (effective_end < total_lines)
17674 .then(|| source_read_window_command(&root, &file_display, effective_end + 1, lines)),
17675 body: body_truncated.then(|| {
17676 let remaining = end_line.saturating_sub(effective_end);
17677 source_read_window_command(&root, &file_display, effective_end + 1, remaining)
17678 }),
17679 file: source_read_ast_command(&root, &file_display),
17680 markdown_ast: is_markdown_path(&file_abs)
17681 .then(|| markdown_ast_command(&root, &file_display, None)),
17682 };
17683
17684 let report = SourceReadReport {
17685 handle: stable_handle("swin", &format!("{file_display}:{start}:{effective_end}")),
17686 root: root.to_string_lossy().to_string(),
17687 file: file_display,
17688 range: SourceRangePreview {
17689 start,
17690 end: effective_end,
17691 total_lines,
17692 truncated_before: start > 1,
17693 truncated_after: effective_end < total_lines,
17694 },
17695 preview,
17696 symbols,
17697 summaries,
17698 markdown,
17699 expand,
17700 warnings,
17701 };
17702
17703 if format.json_output {
17704 let truncated = report.range.truncated_before || report.range.truncated_after;
17705 let follow_up = [
17706 report.expand.before.clone(),
17707 report.expand.after.clone(),
17708 report.expand.body.clone(),
17709 Some(report.expand.file.clone()),
17710 report.expand.markdown_ast.clone(),
17711 ]
17712 .into_iter()
17713 .flatten()
17714 .collect::<Vec<_>>();
17715 print_json_or_envelope(
17716 &report,
17717 &format,
17718 "source-read",
17719 "window",
17720 ToolEnvelopeSummary {
17721 text: format!(
17722 "source window {}:{}-{}",
17723 report.file, report.range.start, report.range.end
17724 ),
17725 metrics: vec![
17726 envelope_metric("lines", report.preview.len()),
17727 envelope_metric("symbols", report.symbols.len()),
17728 envelope_metric("summaries", report.summaries.len()),
17729 envelope_metric(
17730 "markdown_nodes",
17731 report
17732 .markdown
17733 .as_ref()
17734 .map_or(0, |markdown| markdown.visible_nodes),
17735 ),
17736 ],
17737 },
17738 truncated,
17739 follow_up,
17740 )?;
17741 } else if format.compact {
17742 println!(
17743 "source {}:{}-{} / {} handle:{}",
17744 report.file,
17745 report.range.start,
17746 report.range.end,
17747 report.range.total_lines,
17748 report.handle
17749 );
17750 for line in &report.preview {
17751 println!("{:>5} {}", line.line, line.text);
17752 }
17753 if !report.symbols.is_empty() {
17754 println!("syms[{}]:", report.symbols.len());
17755 for symbol in &report.symbols {
17756 println!(" {} {}:{}", symbol.name, symbol.file, symbol.line);
17757 }
17758 }
17759 if report.range.truncated_before || report.range.truncated_after {
17760 println!("expand: {}", report.expand.file);
17761 }
17762 } else {
17763 println!(
17764 "Source window `{}` lines {}-{} of {} ({})",
17765 report.file,
17766 report.range.start,
17767 report.range.end,
17768 report.range.total_lines,
17769 report.handle
17770 );
17771 for line in &report.preview {
17772 println!("{:>5} | {}", line.line, line.text);
17773 }
17774 if !report.symbols.is_empty() {
17775 println!();
17776 println!("Symbol refs:");
17777 for symbol in &report.symbols {
17778 println!(
17779 " {} `{}` {}:{} — {}",
17780 symbol.handle, symbol.name, symbol.file, symbol.line, symbol.expand
17781 );
17782 }
17783 }
17784 if !report.summaries.is_empty() {
17785 println!();
17786 println!("Summary refs:");
17787 for summary in &report.summaries {
17788 println!(
17789 " {} `{}` — {}",
17790 summary.handle, summary.symbol_name, summary.expand
17791 );
17792 }
17793 }
17794 if report.range.truncated_before || report.range.truncated_after {
17795 println!();
17796 println!("Expand:");
17797 if let Some(before) = &report.expand.before {
17798 println!(" before: {}", before);
17799 }
17800 if let Some(after) = &report.expand.after {
17801 println!(" after: {}", after);
17802 }
17803 println!(" file: {}", report.expand.file);
17804 }
17805 for warning in &report.warnings {
17806 eprintln!("warning: {warning}");
17807 }
17808 }
17809
17810 Ok(())
17811}
17812
17813#[allow(clippy::too_many_arguments)]
17814fn cmd_symbol_read(
17815 symbol: &str,
17816 file_hint: Option<&Path>,
17817 path: &Path,
17818 scope: Option<&str>,
17819 format: OutputFormat,
17820 absolute: bool,
17821 budget: ResponseBudget,
17822) -> Result<()> {
17823 let root = lint::resolve_project_root_or_canonical_path(path)?;
17824 let hinted_file_abs = file_hint
17825 .map(|file| resolve_source_file(&root, file))
17826 .transpose()?;
17827 let path_hint = hinted_file_abs.as_deref().unwrap_or(root.as_path());
17828 let target = resolve_query_index_target(&root, path_hint, scope)?;
17834 ensure_query_index_current(&root, &target)?;
17835 let db_path = target.db_path;
17836 if !db_path.exists() {
17837 bail!(
17838 "index refs unavailable: no index found at {}",
17839 db_path.display()
17840 );
17841 }
17842 let db = index::IndexDb::open_read_only_resilient(&db_path)
17843 .with_context(|| format!("opening symbol index {}", db_path.display()))?;
17844 let search_limit = budget.follow_up_items().max(10);
17845 let hits = db
17846 .symbol_search(symbol, search_limit)
17847 .with_context(|| format!("searching symbols for {symbol:?}"))?;
17848 let selected = hits
17849 .into_iter()
17850 .find(|hit| {
17851 let Some(hinted_file_abs) = &hinted_file_abs else {
17852 return true;
17853 };
17854 resolve_source_file(&root, Path::new(&hit.file))
17855 .map(|hit_file| hit_file == *hinted_file_abs)
17856 .unwrap_or(false)
17857 })
17858 .with_context(|| {
17859 let hint = file_hint
17860 .map(|file| format!(" in {}", file.display()))
17861 .unwrap_or_default();
17862 format!("no indexed symbol matched {symbol:?}{hint}")
17863 })?;
17864
17865 let file_abs = resolve_source_file(&root, Path::new(&selected.file))?;
17866 let file_display = if absolute {
17867 file_abs.to_string_lossy().to_string()
17868 } else {
17869 relativize_pathbuf(&file_abs, &root)
17870 .to_string_lossy()
17871 .to_string()
17872 };
17873 let source = fs::read(&file_abs).with_context(|| format!("reading {}", file_abs.display()))?;
17874 let content_hash = blake3::hash(&source).to_hex().to_string();
17875 let text = String::from_utf8_lossy(&source);
17876 let all_lines: Vec<&str> = text.lines().collect();
17877 let total_lines = all_lines.len();
17878 let file_symbols = db
17879 .symbols_for_file(&file_abs.to_string_lossy())
17880 .with_context(|| format!("loading symbols for {}", file_abs.display()))?;
17881 let max_items = budget.preview_items();
17882 let max_bytes = budget.preview_bytes();
17883 let selected_start = symbol_hit_line(&selected);
17884 let selected_end = symbol_hit_end_line(&selected)
17885 .unwrap_or(selected_start)
17886 .max(selected_start);
17887 let stored_target = file_symbols.iter().find(|candidate| {
17888 candidate.name == selected.name
17889 && candidate.kind == selected.kind
17890 && source_symbol_line(candidate) == selected_start
17891 });
17892 let target_span = stored_target
17893 .and_then(|stored| stored_symbol_ast_span(stored, &source, &file_symbols, max_items))
17894 .or_else(|| symbol_hit_ast_span(&selected, &source));
17895 let target_start = target_span
17896 .as_ref()
17897 .map(|span| span.start_line)
17898 .unwrap_or(selected_start);
17899 let target_end = target_span
17900 .as_ref()
17901 .map(|span| span.end_line)
17902 .or_else(|| stored_target.and_then(source_symbol_end_line))
17903 .unwrap_or(selected_end)
17904 .max(target_start);
17905 let target_bounds = stored_target
17906 .and_then(stored_symbol_span_bounds)
17907 .or_else(|| symbol_hit_span_bounds(&selected));
17908 let target_end = stored_target
17909 .and_then(source_symbol_end_line)
17910 .unwrap_or(target_end)
17911 .max(target_start);
17912 let body_line_budget = budget.preview_items().max(1).saturating_mul(16);
17913 let line_capped_end = target_start
17914 .saturating_add(body_line_budget)
17915 .saturating_sub(1)
17916 .min(target_end)
17917 .min(total_lines.max(target_start));
17918 let token_cap = budget.body_token_cap();
17919 let (body, effective_preview_end, body_truncated) =
17920 if total_lines == 0 || target_start > total_lines {
17921 (Vec::new(), line_capped_end, false)
17922 } else {
17923 let capped = build_token_capped_preview(
17924 &all_lines,
17925 target_start,
17926 line_capped_end,
17927 max_bytes,
17928 token_cap,
17929 );
17930 (capped.preview, capped.capped_end, capped.was_capped)
17931 };
17932 let preview_end = if body_truncated {
17933 effective_preview_end
17934 } else {
17935 line_capped_end
17936 };
17937 let child_symbols = file_symbols
17938 .iter()
17939 .filter(|candidate| {
17940 if let Some((target_start_byte, target_end_byte)) = target_bounds {
17941 let Some((candidate_start, candidate_end)) = stored_symbol_span_bounds(candidate)
17942 else {
17943 return false;
17944 };
17945 return candidate_start >= target_start_byte
17946 && candidate_end <= target_end_byte
17947 && (candidate_start, candidate_end) != (target_start_byte, target_end_byte);
17948 }
17949 let line = source_symbol_line(candidate);
17950 line > target_start && line <= target_end
17951 })
17952 .take(max_items)
17953 .map(|symbol| {
17954 let line = source_symbol_line(symbol);
17955 let end_line = source_symbol_end_line(symbol);
17956 SourceSymbolRef {
17957 handle: stable_handle(
17958 "ssym",
17959 &format!("{}:{}:{}", file_display, symbol.name, line),
17960 ),
17961 name: truncate_for_budget(&symbol.name, max_bytes),
17962 kind: symbol.kind.clone(),
17963 language: symbol.language.clone(),
17964 file: file_display.clone(),
17965 line,
17966 end_line,
17967 signature: symbol
17968 .signature
17969 .clone()
17970 .map(|signature| truncate_for_budget(&signature, max_bytes)),
17971 span: stored_symbol_ast_span(symbol, &source, &file_symbols, max_items),
17972 expand: source_symbol_read_command(&root, &symbol.name, &file_display),
17973 }
17974 })
17975 .collect::<Vec<_>>();
17976 let mut warnings = Vec::new();
17977 if body_truncated {
17978 warnings.push(format!(
17979 "body preview capped at ~{token_cap} tokens at line {preview_end} of {target_end}"
17980 ));
17981 }
17982 let summaries =
17983 load_source_summaries(&root, &file_display, max_items, max_bytes, &mut warnings);
17984 let symbol_handle = stable_handle(
17985 "sread",
17986 &format!("{}:{}:{}", file_display, selected.name, target_start),
17987 );
17988 let source_lines = preview_end
17989 .saturating_sub(target_start)
17990 .saturating_add(1)
17991 .max(1);
17992 let expand = SymbolReadExpandCommands {
17993 source_window: source_read_window_command(&root, &file_display, target_start, source_lines),
17994 body: body_truncated.then(|| {
17995 let remaining = target_end.saturating_sub(preview_end);
17996 source_read_window_command(&root, &file_display, preview_end + 1, remaining)
17997 }),
17998 file: source_read_ast_command(&root, &file_display),
17999 explain: source_symbol_expand_command(&root, &selected.name),
18000 callers: source_symbol_graph_command(&root, &selected.name, "callers"),
18001 callees: source_symbol_graph_command(&root, &selected.name, "callees"),
18002 markdown_ast: (selected.language == "markdown").then(|| {
18003 markdown_ast_command(
18004 &root,
18005 &file_display,
18006 target_span.as_ref().map(|span| span.handle.as_str()),
18007 )
18008 }),
18009 };
18010 let report = SymbolReadReport {
18011 handle: symbol_handle.clone(),
18012 root: root.to_string_lossy().to_string(),
18013 query: symbol.to_string(),
18014 symbol: SymbolReadTarget {
18015 handle: symbol_handle,
18016 name: selected.name.clone(),
18017 kind: selected.kind.clone(),
18018 language: selected.language.clone(),
18019 file: file_display.clone(),
18020 line: target_start,
18021 end_line: Some(target_end),
18022 signature: stored_target
18023 .and_then(|stored| stored.signature.clone())
18024 .map(|signature| truncate_for_budget(&signature, max_bytes)),
18025 parent_module: stored_target.and_then(|stored| stored.parent_module.clone()),
18026 visibility: stored_target.and_then(|stored| stored.visibility.clone()),
18027 span: target_span,
18028 },
18029 range: SourceRangePreview {
18030 start: target_start,
18031 end: preview_end,
18032 total_lines,
18033 truncated_before: false,
18034 truncated_after: preview_end < target_end,
18035 },
18036 body,
18037 child_symbols,
18038 summaries,
18039 expand,
18040 warnings,
18041 };
18042
18043 if format.json_output {
18044 let truncated = report.range.truncated_after
18045 || report.body.iter().any(|line| line.text.len() >= max_bytes)
18046 || report.child_symbols.len() >= max_items;
18047 let follow_up = [
18048 Some(report.expand.source_window.clone()),
18049 report.expand.body.clone(),
18050 Some(report.expand.file.clone()),
18051 Some(report.expand.explain.clone()),
18052 Some(report.expand.callers.clone()),
18053 Some(report.expand.callees.clone()),
18054 ]
18055 .into_iter()
18056 .flatten()
18057 .chain(report.expand.markdown_ast.clone())
18058 .collect::<Vec<_>>();
18059 print_json_or_envelope(
18060 &report,
18061 &format,
18062 "symbol-read",
18063 "symbol",
18064 ToolEnvelopeSummary {
18065 text: format!(
18066 "symbol {} {}:{}-{}",
18067 report.symbol.name, report.symbol.file, report.range.start, report.range.end
18068 ),
18069 metrics: vec![
18070 envelope_metric("body_lines", report.body.len()),
18071 envelope_metric("child_symbols", report.child_symbols.len()),
18072 envelope_metric("summaries", report.summaries.len()),
18073 ],
18074 },
18075 truncated,
18076 follow_up,
18077 )?;
18078 } else if format.compact {
18079 println!(
18080 "symbol {} {}:{}-{} handle:{} hash:{}",
18081 report.symbol.name,
18082 report.symbol.file,
18083 report.range.start,
18084 report.range.end,
18085 report.handle,
18086 content_hash
18087 );
18088 for line in &report.body {
18089 println!("{:>5} {}", line.line, line.text);
18090 }
18091 if !report.child_symbols.is_empty() {
18092 println!("children[{}]:", report.child_symbols.len());
18093 for child in &report.child_symbols {
18094 println!(" {} {}:{}", child.name, child.file, child.line);
18095 }
18096 }
18097 } else {
18098 println!(
18099 "Symbol `{}` in `{}` lines {}-{} ({})",
18100 report.symbol.name,
18101 report.symbol.file,
18102 report.range.start,
18103 report.range.end,
18104 report.handle
18105 );
18106 for line in &report.body {
18107 println!("{:>5} | {}", line.line, line.text);
18108 }
18109 if !report.child_symbols.is_empty() {
18110 println!();
18111 println!("Child symbols:");
18112 for child in &report.child_symbols {
18113 println!(
18114 " {} `{}` {}:{} — {}",
18115 child.handle, child.name, child.file, child.line, child.expand
18116 );
18117 }
18118 }
18119 println!();
18120 println!("Expand:");
18121 println!(" source: {}", report.expand.source_window);
18122 println!(" file: {}", report.expand.file);
18123 println!(" explain: {}", report.expand.explain);
18124 println!(" callers: {}", report.expand.callers);
18125 println!(" callees: {}", report.expand.callees);
18126 for warning in &report.warnings {
18127 eprintln!("warning: {warning}");
18128 }
18129 }
18130
18131 Ok(())
18132}
18133
18134#[allow(clippy::too_many_arguments)]
18135#[derive(Serialize)]
18136struct ExplainBudgetDefinitionPreview {
18137 handle: String,
18138 #[serde(skip_serializing_if = "Option::is_none")]
18139 tag_alias: Option<String>,
18140 kind: String,
18141 name: String,
18142 file: String,
18143 line: i64,
18144 expand: String,
18145}
18146
18147#[derive(Serialize)]
18148struct ExplainBudgetEdgePreview {
18149 handle: String,
18150 #[serde(skip_serializing_if = "Option::is_none")]
18151 tag_alias: Option<String>,
18152 name: String,
18153 file: String,
18154 line: i64,
18155 expand: String,
18156}
18157
18158#[derive(Serialize)]
18159struct ExplainBudgetCommunityPreview {
18160 size: usize,
18161 members: Vec<String>,
18162}
18163
18164#[derive(Serialize)]
18165struct ExplainBudgetReport {
18166 symbol: String,
18167 max_items: usize,
18168 max_bytes: usize,
18169 definition_total: usize,
18170 callers_total: usize,
18171 callers_truncated_by_limit: bool,
18172 callees_total: usize,
18173 callees_truncated_by_limit: bool,
18174 truncated: bool,
18175 definitions: Vec<ExplainBudgetDefinitionPreview>,
18176 callers: Vec<ExplainBudgetEdgePreview>,
18177 callees: Vec<ExplainBudgetEdgePreview>,
18178 #[serde(skip_serializing_if = "Option::is_none")]
18179 community: Option<ExplainBudgetCommunityPreview>,
18180}
18181
18182#[allow(clippy::too_many_arguments)]
18183pub(crate) fn build_explain_budget_report(
18184 symbol: &str,
18185 _root: &Path,
18186 symbols: &[index::StoredSymbol],
18187 callers: &[index::StoredEdge],
18188 callers_total: usize,
18189 callers_truncated_by_limit: bool,
18190 callees: &[index::StoredEdge],
18191 callees_total: usize,
18192 callees_truncated_by_limit: bool,
18193 community: Option<&graph::Community>,
18194 budget: ResponseBudget,
18195) -> ExplainBudgetReport {
18196 let max_items = budget.preview_items();
18197 let max_bytes = budget.preview_bytes();
18198 let definitions = symbols
18199 .iter()
18200 .take(max_items)
18201 .map(|entry| {
18202 let symbol_ref = build_compact_symbol_ref(
18203 "edef",
18204 &format!(
18205 "{}:{}:{}:{}",
18206 entry.kind, entry.name, entry.file, entry.line
18207 ),
18208 &entry.name,
18209 entry.tags.as_deref(),
18210 max_bytes,
18211 );
18212 ExplainBudgetDefinitionPreview {
18213 handle: symbol_ref.handle,
18214 tag_alias: symbol_ref.tag_alias,
18215 kind: entry.kind.clone(),
18216 name: symbol_ref.name,
18217 file: truncate_for_budget(&entry.file, max_bytes),
18218 line: entry.line,
18219 expand: format!(
18220 "tsift search {} --exact --path {} --limit 20",
18221 shell_quote(&entry.name),
18222 shell_quote(&entry.file)
18223 ),
18224 }
18225 })
18226 .collect();
18227 let callers_preview: Vec<ExplainBudgetEdgePreview> = callers
18228 .iter()
18229 .take(max_items)
18230 .map(|entry| {
18231 let symbol_ref = build_compact_symbol_ref(
18232 "ecall",
18233 &format!(
18234 "{}:{}:{}:{}",
18235 entry.caller_name, entry.caller_file, entry.call_site_line, symbol
18236 ),
18237 &entry.caller_name,
18238 None,
18239 max_bytes,
18240 );
18241 ExplainBudgetEdgePreview {
18242 handle: symbol_ref.handle,
18243 tag_alias: symbol_ref.tag_alias,
18244 name: symbol_ref.name,
18245 file: truncate_for_budget(&entry.caller_file, max_bytes),
18246 line: entry.call_site_line,
18247 expand: format!(
18248 "tsift explain {} --path {} --limit 0",
18249 shell_quote(&entry.caller_name),
18250 shell_quote(&entry.caller_file)
18251 ),
18252 }
18253 })
18254 .collect();
18255 let callees_preview: Vec<ExplainBudgetEdgePreview> = callees
18256 .iter()
18257 .take(max_items)
18258 .map(|entry| {
18259 let symbol_ref = build_compact_symbol_ref(
18260 "eces",
18261 &format!(
18262 "{}:{}:{}:{}",
18263 entry.callee_name, entry.caller_file, entry.call_site_line, symbol
18264 ),
18265 &entry.callee_name,
18266 None,
18267 max_bytes,
18268 );
18269 ExplainBudgetEdgePreview {
18270 handle: symbol_ref.handle,
18271 tag_alias: symbol_ref.tag_alias,
18272 name: symbol_ref.name,
18273 file: truncate_for_budget(&entry.caller_file, max_bytes),
18274 line: entry.call_site_line,
18275 expand: format!(
18276 "tsift explain {} --path {} --limit 0",
18277 shell_quote(&entry.callee_name),
18278 shell_quote(&entry.caller_file)
18279 ),
18280 }
18281 })
18282 .collect();
18283 let community_preview = community.map(|entry| ExplainBudgetCommunityPreview {
18284 size: entry.members.len(),
18285 members: entry
18286 .members
18287 .iter()
18288 .take(max_items)
18289 .map(|member| truncate_for_budget(&member.name, max_bytes))
18290 .collect(),
18291 });
18292
18293 ExplainBudgetReport {
18294 symbol: symbol.to_string(),
18295 max_items,
18296 max_bytes,
18297 definition_total: symbols.len(),
18298 callers_total,
18299 callers_truncated_by_limit,
18300 callees_total,
18301 callees_truncated_by_limit,
18302 truncated: symbols.len() > max_items
18303 || callers_total > callers_preview.len()
18304 || callees_total > callees_preview.len()
18305 || community
18306 .map(|entry| entry.members.len() > max_items)
18307 .unwrap_or(false),
18308 definitions,
18309 callers: callers_preview,
18310 callees: callees_preview,
18311 community: community_preview,
18312 }
18313}
18314
18315pub(crate) fn print_explain_budget_human(report: &ExplainBudgetReport) {
18316 println!(
18317 "explain-budget sym:{} defs:{}/{} crs:{}/{} ces:{}/{}",
18318 shell_quote(&report.symbol),
18319 report.definitions.len(),
18320 report.definition_total,
18321 report.callers.len(),
18322 report.callers_total,
18323 report.callees.len(),
18324 report.callees_total
18325 );
18326 for entry in &report.definitions {
18327 println!(
18328 "def {} {} {}:{} expand:{}",
18329 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18330 entry.kind,
18331 entry.file,
18332 entry.line,
18333 entry.expand
18334 );
18335 }
18336 for entry in &report.callers {
18337 println!(
18338 "caller {} {}:{} expand:{}",
18339 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18340 entry.file,
18341 entry.line,
18342 entry.expand
18343 );
18344 }
18345 for entry in &report.callees {
18346 println!(
18347 "callee {} {}:{} expand:{}",
18348 format_symbol_preview_line(&entry.handle, &entry.name, entry.tag_alias.as_deref()),
18349 entry.file,
18350 entry.line,
18351 entry.expand
18352 );
18353 }
18354 if let Some(community) = &report.community {
18355 println!(
18356 "community size:{} members:{}",
18357 community.size,
18358 community.members.join(", ")
18359 );
18360 }
18361 if report.truncated {
18362 println!(
18363 "budget truncated items:{} bytes:{}",
18364 report.max_items, report.max_bytes
18365 );
18366 }
18367}
18368
18369const TAGPATH_AUDIT_SKIP_DIRS: &[&str] = &[
18379 ".git",
18380 "node_modules",
18381 "target",
18382 "__pycache__",
18383 ".venv",
18384 "vendor",
18385];
18386
18387const TAGPATH_AUDIT_SOURCE_EXTENSIONS: &[&str] = &[
18388 "rs", "py", "ts", "js", "go", "java", "rb", "c", "cpp", "h", "hpp", "cs", "swift", "kt",
18389 "scala", "zig", "nim", "ex", "exs", "erl", "hs", "ml", "clj", "r", "lua", "php", "pl", "d",
18390 "cr", "dart", "jl", "v", "odin", "gleam", "rkt", "scm", "lisp", "lsp", "f", "fs", "fsi", "fsx",
18391 "sh", "bash", "zsh", "sql", "css", "tsx",
18392];
18393
18394pub(crate) fn tagpath_audit_supported_extensions(root: &Path) -> BTreeSet<String> {
18395 let mut extensions = TAGPATH_AUDIT_SOURCE_EXTENSIONS
18396 .iter()
18397 .map(|ext| (*ext).to_string())
18398 .collect::<BTreeSet<_>>();
18399
18400 let config_path = root.join(".naming.toml");
18401 if !config_path.exists() {
18402 return extensions;
18403 }
18404
18405 match tagpath::config::resolve(&config_path) {
18406 Ok(config) => {
18407 if let Some(grammars) = config.grammars {
18408 for grammar in grammars.languages.values() {
18409 for ext in &grammar.extensions {
18410 if let Some(normalized) = normalize_extension(ext) {
18411 extensions.insert(normalized);
18412 }
18413 }
18414 }
18415 }
18416 }
18417 Err(err) => {
18418 eprintln!("tagpath_policy_hint_config_unreadable: {err}");
18419 }
18420 }
18421 extensions
18422}
18423
18424pub(crate) fn tagpath_audit_policy_hints(
18425 rel_path: &str,
18426 supported_extensions: &BTreeSet<String>,
18427) -> Vec<String> {
18428 let path = Path::new(rel_path);
18429 let mut hints = BTreeSet::new();
18430 if let Some(parent) = path.parent() {
18431 for component in parent.components() {
18432 if let std::path::Component::Normal(name) = component {
18433 let name = name.to_string_lossy();
18434 if TAGPATH_AUDIT_SKIP_DIRS.contains(&name.as_ref()) {
18435 hints.insert(format!("skip_dir:{name}"));
18436 }
18437 }
18438 }
18439 }
18440 if path
18441 .extension()
18442 .and_then(|ext| ext.to_str())
18443 .and_then(normalize_extension)
18444 .is_some_and(|ext| !supported_extensions.contains(&ext))
18445 {
18446 hints.insert("extension_unsupported".to_string());
18447 }
18448 hints.into_iter().collect()
18449}
18450
18451fn normalize_extension(ext: &str) -> Option<String> {
18452 let normalized = ext.trim().trim_start_matches('.').to_ascii_lowercase();
18453 if normalized.is_empty() {
18454 None
18455 } else {
18456 Some(normalized)
18457 }
18458}
18459
18460pub(crate) fn diff_digest_status_label(status: diff_digest::DiffDigestFileStatus) -> &'static str {
18461 match status {
18462 diff_digest::DiffDigestFileStatus::Added => "added",
18463 diff_digest::DiffDigestFileStatus::Modified => "modified",
18464 diff_digest::DiffDigestFileStatus::Deleted => "deleted",
18465 }
18466}
18467
18468pub(crate) fn diff_digest_summary_label(
18469 state: diff_digest::DiffDigestSummaryState,
18470) -> &'static str {
18471 match state {
18472 diff_digest::DiffDigestSummaryState::Current => "current",
18473 diff_digest::DiffDigestSummaryState::Stale => "stale",
18474 diff_digest::DiffDigestSummaryState::Missing => "missing",
18475 diff_digest::DiffDigestSummaryState::Unavailable => "unavailable",
18476 }
18477}
18478
18479fn test_digest_summary_label(state: test_digest::TestDigestSummaryState) -> &'static str {
18480 match state {
18481 test_digest::TestDigestSummaryState::Current => "current",
18482 test_digest::TestDigestSummaryState::Stale => "stale",
18483 test_digest::TestDigestSummaryState::Missing => "missing",
18484 test_digest::TestDigestSummaryState::Unavailable => "unavailable",
18485 }
18486}
18487
18488fn log_digest_summary_label(state: log_digest::LogDigestSummaryState) -> &'static str {
18489 match state {
18490 log_digest::LogDigestSummaryState::Current => "current",
18491 log_digest::LogDigestSummaryState::Stale => "stale",
18492 log_digest::LogDigestSummaryState::Missing => "missing",
18493 log_digest::LogDigestSummaryState::Unavailable => "unavailable",
18494 }
18495}
18496
18497pub(crate) fn diff_digest_mode_label(mode: diff_digest::DiffDigestMode) -> &'static str {
18498 match mode {
18499 diff_digest::DiffDigestMode::WorkingTree => "worktree",
18500 diff_digest::DiffDigestMode::Cached => "cached",
18501 diff_digest::DiffDigestMode::Revision => "revision",
18502 }
18503}
18504
18505pub(crate) fn diff_digest_mode_display(report: &diff_digest::DiffDigestReport) -> String {
18506 match (&report.mode, &report.revision) {
18507 (diff_digest::DiffDigestMode::WorkingTree, _) => "working tree".to_string(),
18508 (diff_digest::DiffDigestMode::Cached, _) => "staged index".to_string(),
18509 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18510 format!("revision {revision}")
18511 }
18512 (diff_digest::DiffDigestMode::Revision, None) => "revision".to_string(),
18513 }
18514}
18515
18516pub(crate) fn diff_digest_empty_message(report: &diff_digest::DiffDigestReport) -> String {
18517 match (&report.mode, &report.revision) {
18518 (diff_digest::DiffDigestMode::WorkingTree, _) => "No git changes found.".to_string(),
18519 (diff_digest::DiffDigestMode::Cached, _) => "No staged git changes found.".to_string(),
18520 (diff_digest::DiffDigestMode::Revision, Some(revision)) => {
18521 format!("No diff found for revision {revision}.")
18522 }
18523 (diff_digest::DiffDigestMode::Revision, None) => "No revision diff found.".to_string(),
18524 }
18525}
18526
18527fn cmd_impact(
18528 path: &Path,
18529 cached: bool,
18530 revision: Option<&str>,
18531 scope: Option<&str>,
18532 limit: usize,
18533 format: OutputFormat,
18534) -> Result<()> {
18535 let report = impact::compute(
18536 path,
18537 impact::ImpactOptions {
18538 cached,
18539 revision,
18540 scope,
18541 limit,
18542 },
18543 )?;
18544 if format.json_output {
18545 println!(
18546 "{}",
18547 to_json_schema(
18548 &report,
18549 format.pretty,
18550 format.terse,
18551 format.ultra_terse,
18552 format.schema
18553 )?
18554 );
18555 return Ok(());
18556 }
18557
18558 if format.compact {
18559 println!(
18560 "impact mode:{} changed:{} symbols:{} tests:{}/{}",
18561 diff_digest_mode_label(report.mode),
18562 report.changed_files.len(),
18563 report.changed_symbols.len(),
18564 report.affected_tests.len(),
18565 report.affected_tests_total
18566 );
18567 for target in &report.affected_tests {
18568 println!(
18569 "{} reasons:{} command:{}",
18570 target.path,
18571 target.reasons.len(),
18572 target.commands.join(" && ")
18573 );
18574 }
18575 for warning in &report.warnings {
18576 println!("warning {warning}");
18577 }
18578 return Ok(());
18579 }
18580
18581 println!("Impact ({})", diff_digest_mode_label(report.mode));
18582 println!(" changed files: {}", report.changed_files.len());
18583 println!(" changed symbols: {}", report.changed_symbols.len());
18584 println!(
18585 " affected tests: {}/{}",
18586 report.affected_tests.len(),
18587 report.affected_tests_total
18588 );
18589 for target in &report.affected_tests {
18590 println!();
18591 println!("{}", target.path);
18592 for reason in &target.reasons {
18593 println!(" - {reason}");
18594 }
18595 if !target.symbols.is_empty() {
18596 println!(" symbols: {}", target.symbols.join(", "));
18597 }
18598 for command in &target.commands {
18599 println!(" run: {}", command);
18600 }
18601 }
18602 for warning in &report.warnings {
18603 println!("warning: {warning}");
18604 }
18605 Ok(())
18606}
18607
18608pub(crate) fn render_test_digest_from_input(
18609 path: &Path,
18610 input: &str,
18611 runner: Option<&str>,
18612 format: OutputFormat,
18613) -> Result<()> {
18614 let report = test_digest::compute(path, input, runner)?;
18615 if format.json_output {
18616 println!(
18617 "{}",
18618 to_json_schema(
18619 &report,
18620 format.pretty,
18621 format.terse,
18622 format.ultra_terse,
18623 format.schema
18624 )?
18625 );
18626 return Ok(());
18627 }
18628
18629 if report.failure_groups.is_empty() {
18630 println!("No failures detected (runner: {}).", report.runner);
18631 for warning in &report.warnings {
18632 println!("warning: {warning}");
18633 }
18634 return Ok(());
18635 }
18636
18637 if format.compact {
18638 println!(
18639 "test runner:{} failures:{} groups:{} passed:{} failed:{} skipped:{}",
18640 report.runner,
18641 report.failures,
18642 report.grouped_failures,
18643 report.counts.passed.unwrap_or(0),
18644 report.counts.failed.unwrap_or(report.grouped_failures),
18645 report.counts.skipped.unwrap_or(0),
18646 );
18647 for failure in &report.failure_groups {
18648 let tests = truncate_for_compact(&failure.tests.join(","), 60);
18649 let location = match (&failure.path, failure.line) {
18650 (Some(path), Some(line)) => format!("{path}:{line}"),
18651 (Some(path), None) => path.clone(),
18652 _ => "-".to_string(),
18653 };
18654 println!(
18655 "{} tests:{} count:{} summaries:{} msg:{}",
18656 location,
18657 tests,
18658 failure.occurrences,
18659 test_digest_summary_label(failure.summary_state),
18660 truncate_for_compact(&failure.message, 80)
18661 );
18662 }
18663 for warning in &report.warnings {
18664 println!("warning: {warning}");
18665 }
18666 return Ok(());
18667 }
18668
18669 println!("Test digest ({})", report.runner);
18670 println!(" failures: {}", report.failures);
18671 println!(" failure groups: {}", report.grouped_failures);
18672 if let Some(passed) = report.counts.passed {
18673 println!(" passed: {}", passed);
18674 }
18675 if let Some(failed) = report.counts.failed {
18676 println!(" failed: {}", failed);
18677 }
18678 if let Some(skipped) = report.counts.skipped {
18679 println!(" skipped: {}", skipped);
18680 }
18681
18682 for failure in &report.failure_groups {
18683 println!();
18684 match (&failure.path, failure.line, failure.column) {
18685 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
18686 (Some(path), Some(line), None) => println!("{path}:{line}"),
18687 (Some(path), None, _) => println!("{path}"),
18688 (None, _, _) => println!("(no file anchor)"),
18689 }
18690 println!(" tests: {}", failure.tests.join(", "));
18691 println!(" occurrences: {}", failure.occurrences);
18692 println!(" message: {}", failure.message);
18693 println!(
18694 " cached summaries: {}",
18695 test_digest_summary_label(failure.summary_state)
18696 );
18697 for summary in &failure.current_summaries {
18698 println!(
18699 " - {}: {}",
18700 summary.symbol,
18701 truncate_for_compact(&summary.summary, 160)
18702 );
18703 }
18704 }
18705 for warning in &report.warnings {
18706 println!("warning: {warning}");
18707 }
18708 Ok(())
18709}
18710
18711#[derive(Clone, Serialize, Deserialize)]
18712struct DispatchTraceSummary {
18713 backlog: usize,
18714 job_packet: usize,
18715 worker_result: usize,
18716 worker_context: usize,
18717 source_handle: usize,
18718 semantic_rows: usize,
18719}
18720
18721#[derive(Clone, Serialize, Deserialize)]
18722struct DispatchTraceReport {
18723 contract_version: String,
18724 root: String,
18725 #[serde(skip_serializing_if = "Option::is_none")]
18726 scope: Option<String>,
18727 targets: Vec<String>,
18728 projection_freshness: GraphDbFreshnessReport,
18729 projection_hashes: Vec<String>,
18730 evidence_packet_ids: Vec<String>,
18731 shared_preparation: ConflictMatrixSharedPreparationSummary,
18732 worker_prompt_packets: Vec<ConflictMatrixWorkerPromptPacket>,
18733 worker_feedback: Vec<ConflictMatrixWorkerFeedback>,
18734 summary: DispatchTraceSummary,
18735 nodes: Vec<SubstrateTerseGraphNode>,
18736 edges: Vec<SubstrateTerseGraphEdge>,
18737 conflict_matrix_decisions: Vec<String>,
18738 replay_commands: Vec<String>,
18739 repair_commands: Vec<String>,
18740 truncated: bool,
18741 #[serde(skip_serializing_if = "Vec::is_empty", default)]
18742 warnings: Vec<String>,
18743}
18744
18745fn dispatch_trace_allowed_node_kind(kind: &str) -> bool {
18746 matches!(
18747 kind,
18748 "session"
18749 | "backlog"
18750 | "job_packet"
18751 | "worker_result"
18752 | "worker_context"
18753 | "source_handle"
18754 | "semantic_concept"
18755 | "semantic_entity"
18756 | "file"
18757 | "symbol"
18758 | "route"
18759 )
18760}
18761
18762fn dispatch_trace_kind_rank(kind: &str) -> usize {
18763 match kind {
18764 "backlog" => 0,
18765 "job_packet" => 1,
18766 "worker_result" => 2,
18767 "worker_context" => 3,
18768 "source_handle" => 4,
18769 "file" => 5,
18770 "symbol" => 6,
18771 "route" => 7,
18772 "semantic_concept" => 8,
18773 "semantic_entity" => 9,
18774 "session" => 10,
18775 _ => 99,
18776 }
18777}
18778
18779fn dispatch_trace_summary(nodes: &[SubstrateGraphNode]) -> DispatchTraceSummary {
18780 DispatchTraceSummary {
18781 backlog: nodes.iter().filter(|node| node.kind == "backlog").count(),
18782 job_packet: nodes
18783 .iter()
18784 .filter(|node| node.kind == "job_packet")
18785 .count(),
18786 worker_result: nodes
18787 .iter()
18788 .filter(|node| node.kind == "worker_result")
18789 .count(),
18790 worker_context: nodes
18791 .iter()
18792 .filter(|node| node.kind == "worker_context")
18793 .count(),
18794 source_handle: nodes
18795 .iter()
18796 .filter(|node| node.kind == "source_handle")
18797 .count(),
18798 semantic_rows: nodes
18799 .iter()
18800 .filter(|node| matches!(node.kind.as_str(), "semantic_concept" | "semantic_entity"))
18801 .count(),
18802 }
18803}
18804
18805fn dispatch_trace_shared_preparation_summary(
18806 graph_nodes: &[SubstrateGraphNode],
18807 graph_edges: &[SubstrateGraphEdge],
18808 conflict: &ConflictMatrixReport,
18809) -> ConflictMatrixSharedPreparationSummary {
18810 ConflictMatrixSharedPreparationSummary {
18811 evidence_cache_status: conflict
18812 .inputs
18813 .shared_preparation
18814 .evidence_cache_status
18815 .clone(),
18816 graph_nodes: graph_nodes.len(),
18817 graph_edges: graph_edges.len(),
18818 evidence_packets: conflict.orchestration.evidence_packet_ids.len(),
18819 source_handles: conflict
18820 .candidates
18821 .iter()
18822 .map(|candidate| candidate.source_handles.len())
18823 .sum(),
18824 worker_context: conflict
18825 .candidates
18826 .iter()
18827 .map(|candidate| candidate.worker_context_handles.len())
18828 .sum(),
18829 worker_results: conflict
18830 .candidates
18831 .iter()
18832 .map(|candidate| candidate.worker_feedback.total)
18833 .sum(),
18834 semantic_rows: conflict
18835 .candidates
18836 .iter()
18837 .map(|candidate| candidate.semantic_related.len())
18838 .sum(),
18839 dispatch_trace_snapshot_nodes: graph_nodes.len(),
18840 dispatch_trace_snapshot_edges: graph_edges.len(),
18841 }
18842}
18843
18844fn dispatch_trace_collect_ids(
18845 targets: &[String],
18846 candidates: &[ConflictMatrixCandidate],
18847 graph_nodes: &[SubstrateGraphNode],
18848 graph_edges: &[SubstrateGraphEdge],
18849 depth: usize,
18850 limit: usize,
18851) -> (BTreeSet<String>, bool) {
18852 let target_refs = targets
18853 .iter()
18854 .map(|target| target.trim_start_matches('#').to_string())
18855 .collect::<BTreeSet<_>>();
18856 let mut ids = BTreeSet::new();
18857 for candidate in candidates {
18858 ids.insert(candidate.target_node_id.clone());
18859 for source in &candidate.source_handles {
18860 ids.insert(source.handle.clone());
18861 }
18862 for handle in &candidate.worker_context_handles {
18863 ids.insert(handle.clone());
18864 }
18865 for semantic in &candidate.semantic_related {
18866 ids.insert(semantic.handle.clone());
18867 }
18868 }
18869 for node in graph_nodes {
18870 if !dispatch_trace_allowed_node_kind(&node.kind) {
18871 continue;
18872 }
18873 if node
18874 .properties
18875 .get("ref_id")
18876 .is_some_and(|ref_id| target_refs.contains(ref_id))
18877 {
18878 ids.insert(node.id.clone());
18879 }
18880 }
18881
18882 let node_by_id = graph_nodes
18883 .iter()
18884 .map(|node| (node.id.as_str(), node))
18885 .collect::<BTreeMap<_, _>>();
18886 let max_nodes = if limit == 0 {
18887 usize::MAX
18888 } else {
18889 limit
18890 .saturating_mul(targets.len().max(1))
18891 .saturating_mul(12)
18892 .max(64)
18893 };
18894 let mut truncated = false;
18895 for _ in 0..depth.max(1) {
18896 let before = ids.len();
18897 let current_ids = ids.clone();
18898 for edge in graph_edges {
18899 if ids.len() >= max_nodes {
18900 truncated = true;
18901 break;
18902 }
18903 let touches = current_ids.contains(&edge.from_id) || current_ids.contains(&edge.to_id);
18904 if !touches {
18905 continue;
18906 }
18907 for endpoint in [&edge.from_id, &edge.to_id] {
18908 let Some(node) = node_by_id.get(endpoint.as_str()) else {
18909 continue;
18910 };
18911 if dispatch_trace_allowed_node_kind(&node.kind) {
18912 ids.insert(endpoint.clone());
18913 }
18914 }
18915 }
18916 if ids.len() == before || truncated {
18917 break;
18918 }
18919 }
18920 (ids, truncated)
18921}
18922
18923#[allow(clippy::too_many_arguments)]
18924fn build_dispatch_trace_report_from_conflict_snapshot(
18925 root: &Path,
18926 scope: Option<&str>,
18927 conflict: ConflictMatrixReport,
18928 graph_nodes: Vec<SubstrateGraphNode>,
18929 graph_edges: Vec<SubstrateGraphEdge>,
18930 depth: usize,
18931 limit: usize,
18932 extra_warnings: Vec<String>,
18933) -> Result<DispatchTraceReport> {
18934 let shared_preparation =
18935 dispatch_trace_shared_preparation_summary(&graph_nodes, &graph_edges, &conflict);
18936 let (ids, truncated) = dispatch_trace_collect_ids(
18937 &conflict.targets,
18938 &conflict.candidates,
18939 &graph_nodes,
18940 &graph_edges,
18941 depth,
18942 limit,
18943 );
18944 let mut nodes = graph_nodes
18945 .into_iter()
18946 .filter(|node| ids.contains(&node.id))
18947 .collect::<Vec<_>>();
18948 nodes.sort_by(|left, right| {
18949 dispatch_trace_kind_rank(&left.kind)
18950 .cmp(&dispatch_trace_kind_rank(&right.kind))
18951 .then(left.id.cmp(&right.id))
18952 });
18953 let node_ids = nodes
18954 .iter()
18955 .map(|node| node.id.as_str())
18956 .collect::<BTreeSet<_>>();
18957 let mut edges = graph_edges
18958 .into_iter()
18959 .filter(|edge| {
18960 node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
18961 })
18962 .collect::<Vec<_>>();
18963 edges.sort_by(|left, right| {
18964 left.from_id
18965 .cmp(&right.from_id)
18966 .then(left.kind.cmp(&right.kind))
18967 .then(left.to_id.cmp(&right.to_id))
18968 });
18969 let mut warnings = conflict.warnings;
18970 warnings.extend(extra_warnings);
18971
18972 Ok(DispatchTraceReport {
18973 contract_version: DISPATCH_TRACE_CONTRACT_VERSION.to_string(),
18974 root: conflict.root,
18975 scope: conflict.scope,
18976 targets: conflict.targets,
18977 projection_freshness: conflict.orchestration.projection_freshness,
18978 projection_hashes: conflict.orchestration.projection_hashes,
18979 evidence_packet_ids: conflict.orchestration.evidence_packet_ids,
18980 shared_preparation,
18981 worker_prompt_packets: conflict.worker_prompt_packets,
18982 worker_feedback: conflict
18983 .candidates
18984 .iter()
18985 .map(|candidate| candidate.worker_feedback.clone())
18986 .collect(),
18987 summary: dispatch_trace_summary(&nodes),
18988 nodes: nodes.into_iter().map(Into::into).collect(),
18989 edges: edges.into_iter().map(Into::into).collect(),
18990 conflict_matrix_decisions: conflict.orchestration.conflict_matrix_decisions,
18991 replay_commands: conflict.next_commands,
18992 repair_commands: graph_db_repair_commands(root, scope),
18993 truncated,
18994 warnings,
18995 })
18996}
18997
18998fn build_dispatch_trace_report(
18999 path: &Path,
19000 scope: Option<&str>,
19001 raw_targets: &[String],
19002 depth: usize,
19003 limit: usize,
19004 impact_limit: usize,
19005) -> Result<DispatchTraceReport> {
19006 let root = lint::resolve_project_root_or_canonical_path(path)?;
19007 let source_watermark = traversal_source_watermark(&root, path, scope, false)?;
19008 if graph_db_backend_eval_cached_refresh(&root, scope, source_watermark.as_deref())?.is_none() {
19009 write_traversal_graph_store(&root, path, scope)
19010 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19011 }
19012 let graph_db = graph_substrate_db_path(&root, scope);
19013 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19014 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19015 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19016 let extra_warnings = store
19017 .read_only_recovery()
19018 .map(graph_db_read_recovery_diagnostic)
19019 .into_iter()
19020 .collect::<Vec<_>>();
19021 let prepared = prepare_conflict_matrix_inputs(&root, path, scope, impact_limit)?;
19022 let graph_prepared = prepare_conflict_matrix_graph_orchestration(
19023 &root,
19024 scope,
19025 "sqlite",
19026 raw_targets,
19027 &prepared,
19028 depth,
19029 limit,
19030 &store,
19031 freshness.clone(),
19032 )?;
19033 let dt_cache_key = cycle_packet_cache::cycle_packet_watermark_key(
19034 &prepared.preparation_cache.source_watermark,
19035 &prepared.preparation_cache.document_watermark,
19036 &prepared.preparation_cache.staged_diff_watermark,
19037 &[
19038 &format!("targets:{}", raw_targets.join(",")),
19039 &format!("depth:{depth}"),
19040 &format!("limit:{limit}"),
19041 ],
19042 );
19043 if let Some(cached_report) = cycle_packet_cache::cycle_packet_read_cache::<DispatchTraceReport>(
19044 &root,
19045 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19046 &dt_cache_key,
19047 ) {
19048 return Ok(cached_report);
19049 }
19050 let conflict = build_conflict_matrix_report_from_prepared_graph(
19051 &root,
19052 path,
19053 scope,
19054 depth,
19055 limit,
19056 impact_limit,
19057 freshness,
19058 extra_warnings.clone(),
19059 &prepared,
19060 &graph_prepared,
19061 )?;
19062 let report = build_dispatch_trace_report_from_conflict_snapshot(
19063 &root,
19064 scope,
19065 conflict,
19066 graph_prepared.graph.nodes,
19067 graph_prepared.graph.edges,
19068 depth,
19069 limit,
19070 extra_warnings,
19071 )?;
19072 cycle_packet_cache::cycle_packet_write_cache(
19073 &root,
19074 cycle_packet_cache::CyclePacketKind::ConflictMatrix,
19075 &dt_cache_key,
19076 &report,
19077 );
19078 Ok(report)
19079}
19080
19081fn dispatch_trace_html(report: &DispatchTraceReport) -> Result<String> {
19082 let json = serde_json::to_string(report)?.replace("</", "<\\/");
19083 let mut html = String::new();
19084 html.push_str(
19085 "<!doctype html><html><head><meta charset=\"utf-8\"><title>tsift dispatch trace</title>",
19086 );
19087 html.push_str(
19088 r#"<style>
19089:root{color-scheme:light dark;--bg:#f7f8fb;--panel:#fff;--text:#17202a;--muted:#5c6674;--line:#d7dce3;--edge:#8b98a8;--accent:#0f766e}
19090@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1b2028;--text:#ecf1f7;--muted:#a8b3c1;--line:#323946;--edge:#667386;--accent:#2dd4bf}}
19091*{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}}
19092</style>"#,
19093 );
19094 html.push_str("</head><body><div class=\"page\">");
19095 html.push_str(&format!(
19096 "<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>",
19097 html_escape(&report.targets.join(", ")),
19098 report.evidence_packet_ids.len(),
19099 report.nodes.len(),
19100 report.worker_prompt_packets.len(),
19101 html_escape(&report.contract_version)
19102 ));
19103 html.push_str(
19104 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>"#,
19105 );
19106 html.push_str("<script id=\"trace-data\" type=\"application/json\">");
19107 html.push_str(&json);
19108 html.push_str(
19109 r##"</script><script>
19110const report = JSON.parse(document.getElementById("trace-data").textContent);
19111const svg = document.getElementById("graph-canvas");
19112const nodeList = document.getElementById("nodes");
19113const packets = document.getElementById("packets");
19114const feedback = document.getElementById("feedback");
19115const nodes = report.nodes.map((node, index) => ({...node, index}));
19116const nodeById = new Map(nodes.map(node => [node.id, node]));
19117const edges = report.edges.filter(edge => nodeById.has(edge.from_id) && nodeById.has(edge.to_id));
19118const 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"]]);
19119function color(kind){return colorByKind.get(kind)||"#6b7280";}
19120function text(value){return value == null ? "" : String(value);}
19121function escapeHtml(value){return text(value).replace(/[&<>"']/g, ch => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch]));}
19122function layout(){
19123 const rect = svg.getBoundingClientRect();
19124 const width = rect.width || 900, height = rect.height || 680, cx = width / 2, cy = height / 2;
19125 const kinds = [...new Set(nodes.map(node => node.kind))].sort();
19126 const counts = new Map();
19127 for (const node of nodes) counts.set(node.kind, (counts.get(node.kind)||0)+1);
19128 const offsets = new Map();
19129 for (const node of nodes) {
19130 const group = kinds.indexOf(node.kind);
19131 const index = offsets.get(node.kind) || 0;
19132 offsets.set(node.kind, index + 1);
19133 const total = counts.get(node.kind) || 1;
19134 const ring = Math.min(width, height) * (0.18 + ((group % 4) * 0.09));
19135 const angle = Math.PI * 2 * index / Math.max(total, 1) + group * 0.53;
19136 node.x = cx + Math.cos(angle) * ring;
19137 node.y = cy + Math.sin(angle) * ring;
19138 }
19139}
19140function draw(){
19141 svg.innerHTML = "";
19142 for (const edge of edges) {
19143 const from = nodeById.get(edge.from_id), to = nodeById.get(edge.to_id);
19144 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
19145 line.setAttribute("x1", from.x); line.setAttribute("y1", from.y);
19146 line.setAttribute("x2", to.x); line.setAttribute("y2", to.y);
19147 line.setAttribute("class", "edge");
19148 line.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = edge.kind;
19149 svg.appendChild(line);
19150 }
19151 for (const node of nodes) {
19152 const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
19153 circle.setAttribute("cx", node.x); circle.setAttribute("cy", node.y);
19154 circle.setAttribute("r", node.kind.startsWith("semantic_") ? 8 : 6);
19155 circle.setAttribute("fill", color(node.kind));
19156 circle.setAttribute("class", "node");
19157 circle.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "title")).textContent = node.kind + ": " + node.label;
19158 svg.appendChild(circle);
19159 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
19160 label.setAttribute("x", node.x + 9); label.setAttribute("y", node.y + 4);
19161 label.setAttribute("class", "node-label");
19162 label.textContent = node.label.length > 34 ? node.label.slice(0,31) + "..." : node.label;
19163 svg.appendChild(label);
19164 }
19165}
19166packets.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>";
19167feedback.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>";
19168nodeList.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("");
19169window.addEventListener("resize", () => { layout(); draw(); });
19170layout(); draw();
19171</script></div></body></html>"##,
19172 );
19173 Ok(html)
19174}
19175
19176struct DispatchTraceOptions<'a> {
19177 path: &'a Path,
19178 scope: Option<&'a str>,
19179 raw_targets: &'a [String],
19180 depth: usize,
19181 limit: usize,
19182 impact_limit: usize,
19183 trace_format: DispatchTraceFormat,
19184}
19185
19186fn cmd_dispatch_trace(
19187 options: DispatchTraceOptions<'_>,
19188 output_format: OutputFormat,
19189) -> Result<()> {
19190 let report = build_dispatch_trace_report(
19191 options.path,
19192 options.scope,
19193 options.raw_targets,
19194 options.depth,
19195 options.limit,
19196 options.impact_limit,
19197 )?;
19198 match options.trace_format {
19199 DispatchTraceFormat::Json => {
19200 if output_format.envelope {
19201 print_json_or_envelope(
19202 &report,
19203 &output_format,
19204 "dispatch-trace",
19205 "operator-review",
19206 ToolEnvelopeSummary {
19207 text: format!(
19208 "Dispatch trace for {} target(s): {} graph node(s), {} worker prompt packet(s)",
19209 report.targets.len(),
19210 report.nodes.len(),
19211 report.worker_prompt_packets.len()
19212 ),
19213 metrics: vec![
19214 envelope_metric("targets", report.targets.len()),
19215 envelope_metric("nodes", report.nodes.len()),
19216 envelope_metric("edges", report.edges.len()),
19217 envelope_metric(
19218 "worker_prompt_packets",
19219 report.worker_prompt_packets.len(),
19220 ),
19221 ],
19222 },
19223 report.truncated,
19224 report.replay_commands.clone(),
19225 )
19226 } else {
19227 println!(
19228 "{}",
19229 to_json_schema(
19230 &report,
19231 output_format.pretty,
19232 output_format.terse,
19233 output_format.ultra_terse,
19234 output_format.schema
19235 )?
19236 );
19237 Ok(())
19238 }
19239 }
19240 DispatchTraceFormat::Html => {
19241 println!("{}", dispatch_trace_html(&report)?);
19242 Ok(())
19243 }
19244 }
19245}
19246
19247#[derive(Clone, Debug)]
19248struct DependencyDagProfile {
19249 id: String,
19250 graph_node_id: String,
19251 label: String,
19252 path: Option<String>,
19253 line: Option<i64>,
19254 detail: Option<String>,
19255 source_files: BTreeSet<String>,
19256 source_symbols: BTreeSet<String>,
19257 config_files: BTreeSet<String>,
19258 expected_tests: BTreeSet<String>,
19259 semantic_refs: BTreeMap<String, ConflictMatrixSemanticRef>,
19260 worker_feedback: ConflictMatrixWorkerFeedback,
19261}
19262
19263#[derive(Clone, Debug, Serialize)]
19264struct DependencyDagNode {
19265 id: String,
19266 graph_node_id: String,
19267 label: String,
19268 #[serde(skip_serializing_if = "Option::is_none")]
19269 path: Option<String>,
19270 #[serde(skip_serializing_if = "Option::is_none")]
19271 line: Option<i64>,
19272 #[serde(skip_serializing_if = "Option::is_none")]
19273 detail: Option<String>,
19274 source_files: Vec<String>,
19275 source_symbols: Vec<String>,
19276 config_files: Vec<String>,
19277 expected_tests: Vec<String>,
19278 semantic_refs: Vec<ConflictMatrixSemanticRef>,
19279 worker_feedback: ConflictMatrixWorkerFeedback,
19280}
19281
19282#[derive(Clone, Debug, Serialize)]
19283struct DependencyDagEdge {
19284 from: String,
19285 to: String,
19286 kind: String,
19287 weight: usize,
19288 reasons: Vec<String>,
19289 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19290 shared_files: Vec<String>,
19291 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19292 shared_symbols: Vec<String>,
19293 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19294 shared_tests: Vec<String>,
19295 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19296 shared_config_files: Vec<String>,
19297 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19298 shared_semantic_refs: Vec<String>,
19299}
19300
19301#[derive(Clone, Debug, Serialize)]
19302struct DependencyDagTopoBatch {
19303 batch: usize,
19304 targets: Vec<String>,
19305}
19306
19307#[derive(Clone, Debug, Serialize)]
19308struct DependencyDagCycleDiagnostics {
19309 has_cycles: bool,
19310 blocked_nodes: Vec<String>,
19311 cycle_edges: Vec<DependencyDagEdge>,
19312}
19313
19314#[derive(Serialize)]
19315struct DependencyDagSummary {
19316 nodes: usize,
19317 edges: usize,
19318 topo_batches: usize,
19319 has_cycles: bool,
19320}
19321
19322#[derive(Serialize)]
19323struct DependencyDagReport {
19324 contract_version: &'static str,
19325 root: String,
19326 #[serde(skip_serializing_if = "Option::is_none")]
19327 scope: Option<String>,
19328 path: String,
19329 targets: Vec<String>,
19330 projection_freshness: GraphDbFreshnessReport,
19331 projection_hashes: Vec<String>,
19332 nodes: Vec<DependencyDagNode>,
19333 edges: Vec<DependencyDagEdge>,
19334 topo_batches: Vec<DependencyDagTopoBatch>,
19335 cycle_diagnostics: DependencyDagCycleDiagnostics,
19336 summary: DependencyDagSummary,
19337 replay_commands: Vec<String>,
19338 repair_commands: Vec<String>,
19339 #[serde(skip_serializing_if = "Vec::is_empty", default)]
19340 warnings: Vec<String>,
19341}
19342
19343fn dependency_dag_backlog_node_for_target(
19344 store: &impl GraphStore,
19345 target: &str,
19346) -> Result<SubstrateGraphNode> {
19347 let resolved = graph_db_resolve_evidence_target(store, target)?
19348 .with_context(|| format!("dependency-dag target not found: {target}"))?;
19349 if resolved.kind == "backlog" {
19350 return Ok(resolved);
19351 }
19352 let Some(ref_id) = resolved.properties.get("ref_id").cloned() else {
19353 bail!(
19354 "dependency-dag target {} resolved to {} without a backlog ref_id",
19355 target,
19356 resolved.kind
19357 );
19358 };
19359 store
19360 .nodes_by_kind("backlog")?
19361 .into_iter()
19362 .filter(|node| node.properties.get("ref_id") == Some(&ref_id))
19363 .min_by(|left, right| {
19364 left.properties
19365 .get("line")
19366 .and_then(|value| value.parse::<i64>().ok())
19367 .cmp(
19368 &right
19369 .properties
19370 .get("line")
19371 .and_then(|value| value.parse::<i64>().ok()),
19372 )
19373 .then(left.id.cmp(&right.id))
19374 })
19375 .with_context(|| format!("dependency-dag backlog node not found for #{ref_id}"))
19376}
19377
19378fn dependency_dag_resolve_backlog_nodes(
19379 root: &Path,
19380 path: &Path,
19381 store: &impl GraphStore,
19382 raw_targets: &[String],
19383) -> Result<Vec<SubstrateGraphNode>> {
19384 let mut nodes = Vec::new();
19385 let mut seen = BTreeSet::new();
19386 if raw_targets.is_empty() {
19387 let hinted_path = if path.is_absolute() {
19388 path.to_path_buf()
19389 } else {
19390 root.join(path)
19391 };
19392 let hinted_markdown = hinted_path
19393 .extension()
19394 .and_then(|ext| ext.to_str())
19395 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
19396 let hinted_rel = hinted_markdown.then(|| {
19397 relativize_pathbuf(&hinted_path, root)
19398 .to_string_lossy()
19399 .replace('\\', "/")
19400 });
19401 for node in store.nodes_by_kind("backlog")? {
19402 if let Some(expected_path) = &hinted_rel
19403 && node.properties.get("path") != Some(expected_path)
19404 {
19405 continue;
19406 }
19407 if seen.insert(node.id.clone()) {
19408 nodes.push(node);
19409 }
19410 }
19411 if nodes.is_empty() && hinted_rel.is_some() {
19412 for node in store.nodes_by_kind("backlog")? {
19413 if seen.insert(node.id.clone()) {
19414 nodes.push(node);
19415 }
19416 }
19417 }
19418 } else {
19419 for target in raw_targets {
19420 let normalized = normalize_conflict_target(target).unwrap_or_else(|| target.clone());
19421 let node = dependency_dag_backlog_node_for_target(store, &normalized)?;
19422 if seen.insert(node.id.clone()) {
19423 nodes.push(node);
19424 }
19425 }
19426 }
19427 if nodes.is_empty() {
19428 bail!("dependency-dag needs at least one resolvable backlog id");
19429 }
19430 nodes.sort_by(|left, right| {
19431 left.properties
19432 .get("line")
19433 .and_then(|value| value.parse::<i64>().ok())
19434 .cmp(
19435 &right
19436 .properties
19437 .get("line")
19438 .and_then(|value| value.parse::<i64>().ok()),
19439 )
19440 .then(left.id.cmp(&right.id))
19441 });
19442 Ok(nodes)
19443}
19444
19445fn dependency_dag_node_id(node: &SubstrateGraphNode) -> String {
19446 node.properties
19447 .get("ref_id")
19448 .cloned()
19449 .unwrap_or_else(|| node.label.trim_start_matches('#').to_string())
19450}
19451
19452fn dependency_dag_node_profile(
19453 root: &Path,
19454 store: &impl GraphStore,
19455 node: &SubstrateGraphNode,
19456 graph_nodes_by_id: &BTreeMap<String, SubstrateGraphNode>,
19457 graph_edges: &[SubstrateGraphEdge],
19458 depth: usize,
19459 limit: usize,
19460) -> Result<DependencyDagProfile> {
19461 let id = dependency_dag_node_id(node);
19462 let mut source_files = BTreeSet::new();
19463 let mut source_symbols = BTreeSet::new();
19464 for edge in graph_edges
19465 .iter()
19466 .filter(|edge| edge.from_id == node.id && edge.kind == "mentions")
19467 {
19468 let Some(target) = graph_nodes_by_id.get(&edge.to_id) else {
19469 continue;
19470 };
19471 match target.kind.as_str() {
19472 "file" | "route" => {
19473 if let Some(path) = target.properties.get("path") {
19474 source_files.insert(path.clone());
19475 }
19476 }
19477 "symbol" => {
19478 source_symbols.insert(target.label.clone());
19479 if let Some(path) = target.properties.get("path") {
19480 source_files.insert(path.clone());
19481 }
19482 }
19483 _ => {}
19484 }
19485 }
19486
19487 let max_rows = if limit == 0 { usize::MAX } else { limit };
19488 for (source, _) in
19489 graph_db_reachable_nodes_by_kind(store, &node.id, "source_handle", depth, max_rows)?
19490 {
19491 let terse: SubstrateTerseGraphNode = (&source).into();
19492 if let Some(handle) = conflict_matrix_source_handle(&terse) {
19493 source_files.insert(handle.file);
19494 }
19495 }
19496
19497 let worker_results = graph_nodes_by_id
19498 .values()
19499 .filter(|candidate| {
19500 candidate.kind == "worker_result"
19501 && candidate.properties.get("ref_id").map(String::as_str) == Some(id.as_str())
19502 })
19503 .map(SubstrateTerseGraphNode::from)
19504 .collect::<Vec<_>>();
19505 let worker_feedback = conflict_matrix_worker_feedback(&worker_results);
19506 let expected_tests = worker_feedback.expected_tests.iter().cloned().collect();
19507 let config_files = source_files
19508 .iter()
19509 .filter(|file| is_planner_config_path(file))
19510 .cloned()
19511 .collect();
19512
19513 let mut semantic_refs = BTreeMap::new();
19514 for kind in ["semantic_concept", "semantic_entity"] {
19515 for (semantic, _) in
19516 graph_db_reachable_nodes_by_kind(store, &node.id, kind, depth, max_rows)?
19517 {
19518 let terse: SubstrateTerseGraphNode = (&semantic).into();
19519 let item = conflict_matrix_semantic_ref(root, &terse);
19520 semantic_refs
19521 .entry(format!("{}:{}", item.kind, item.label))
19522 .or_insert(item);
19523 }
19524 }
19525
19526 Ok(DependencyDagProfile {
19527 id,
19528 graph_node_id: node.id.clone(),
19529 label: node.label.clone(),
19530 path: node.properties.get("path").cloned(),
19531 line: node
19532 .properties
19533 .get("line")
19534 .and_then(|value| value.parse::<i64>().ok()),
19535 detail: node.properties.get("detail").cloned(),
19536 source_files,
19537 source_symbols,
19538 config_files,
19539 expected_tests,
19540 semantic_refs,
19541 worker_feedback,
19542 })
19543}
19544
19545fn dependency_dag_marker_refs(text: &str, markers: &[&str]) -> Vec<String> {
19546 let lower = text.to_ascii_lowercase();
19547 let mut refs = Vec::new();
19548 for marker in markers {
19549 let mut offset = 0usize;
19550 while let Some(pos) = lower[offset..].find(marker) {
19551 let start = offset + pos + marker.len();
19552 let segment = text[start..]
19553 .split(['\n', '.'])
19554 .next()
19555 .unwrap_or(&text[start..]);
19556 refs.extend(extract_conflict_target_refs(segment));
19557 offset = start;
19558 }
19559 }
19560 dedupe_preserve_order(refs)
19561}
19562
19563fn dependency_dag_push_edge(
19564 edges: &mut Vec<DependencyDagEdge>,
19565 seen: &mut BTreeSet<(String, String, String)>,
19566 edge: DependencyDagEdge,
19567) {
19568 if edge.from == edge.to {
19569 return;
19570 }
19571 if seen.insert((edge.from.clone(), edge.to.clone(), edge.kind.clone())) {
19572 edges.push(edge);
19573 }
19574}
19575
19576fn dependency_dag_explicit_edges(
19577 profiles: &[DependencyDagProfile],
19578 target_ids: &BTreeSet<String>,
19579 edges: &mut Vec<DependencyDagEdge>,
19580 seen: &mut BTreeSet<(String, String, String)>,
19581) {
19582 for profile in profiles {
19583 let detail = profile.detail.as_deref().unwrap_or_default();
19584 for dep in dependency_dag_marker_refs(
19585 detail,
19586 &[
19587 "depends on",
19588 "depends-on",
19589 "deps:",
19590 "after",
19591 "blocked by",
19592 "requires",
19593 ],
19594 ) {
19595 if target_ids.contains(&dep) {
19596 dependency_dag_push_edge(
19597 edges,
19598 seen,
19599 DependencyDagEdge {
19600 from: dep.clone(),
19601 to: profile.id.clone(),
19602 kind: "explicit_depends_on".to_string(),
19603 weight: 1000,
19604 reasons: vec![format!("{} declares dependency on #{dep}", profile.id)],
19605 shared_files: Vec::new(),
19606 shared_symbols: Vec::new(),
19607 shared_tests: Vec::new(),
19608 shared_config_files: Vec::new(),
19609 shared_semantic_refs: Vec::new(),
19610 },
19611 );
19612 }
19613 }
19614 for downstream in dependency_dag_marker_refs(detail, &["before", "unblocks"]) {
19615 if target_ids.contains(&downstream) {
19616 dependency_dag_push_edge(
19617 edges,
19618 seen,
19619 DependencyDagEdge {
19620 from: profile.id.clone(),
19621 to: downstream.clone(),
19622 kind: "explicit_before".to_string(),
19623 weight: 900,
19624 reasons: vec![format!(
19625 "{} declares it should run before #{downstream}",
19626 profile.id
19627 )],
19628 shared_files: Vec::new(),
19629 shared_symbols: Vec::new(),
19630 shared_tests: Vec::new(),
19631 shared_config_files: Vec::new(),
19632 shared_semantic_refs: Vec::new(),
19633 },
19634 );
19635 }
19636 }
19637 }
19638}
19639
19640fn dependency_dag_worker_follow_up_edges(
19641 profiles: &[DependencyDagProfile],
19642 target_ids: &BTreeSet<String>,
19643 edges: &mut Vec<DependencyDagEdge>,
19644 seen: &mut BTreeSet<(String, String, String)>,
19645) {
19646 for profile in profiles {
19647 for follow_up in &profile.worker_feedback.follow_up_ids {
19648 if target_ids.contains(follow_up) {
19649 dependency_dag_push_edge(
19650 edges,
19651 seen,
19652 DependencyDagEdge {
19653 from: profile.id.clone(),
19654 to: follow_up.clone(),
19655 kind: "worker_result_follow_up".to_string(),
19656 weight: 700,
19657 reasons: vec![format!(
19658 "worker_result for #{} references follow-up #{}",
19659 profile.id, follow_up
19660 )],
19661 shared_files: Vec::new(),
19662 shared_symbols: Vec::new(),
19663 shared_tests: Vec::new(),
19664 shared_config_files: Vec::new(),
19665 shared_semantic_refs: Vec::new(),
19666 },
19667 );
19668 }
19669 }
19670 }
19671}
19672
19673fn dependency_dag_overlap_edges(
19674 profiles: &[DependencyDagProfile],
19675 edges: &mut Vec<DependencyDagEdge>,
19676 seen: &mut BTreeSet<(String, String, String)>,
19677) {
19678 for left_idx in 0..profiles.len() {
19679 for right_idx in (left_idx + 1)..profiles.len() {
19680 let left = &profiles[left_idx];
19681 let right = &profiles[right_idx];
19682 let shared_files = sorted_intersection(&left.source_files, &right.source_files);
19683 let shared_symbols = sorted_intersection(&left.source_symbols, &right.source_symbols);
19684 let shared_tests = sorted_intersection(&left.expected_tests, &right.expected_tests);
19685 let shared_config_files = sorted_intersection(&left.config_files, &right.config_files);
19686 let left_semantic = left.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19687 let right_semantic = right.semantic_refs.keys().cloned().collect::<BTreeSet<_>>();
19688 let shared_semantic_refs = sorted_intersection(&left_semantic, &right_semantic);
19689 if shared_files.is_empty()
19690 && shared_symbols.is_empty()
19691 && shared_tests.is_empty()
19692 && shared_config_files.is_empty()
19693 && shared_semantic_refs.is_empty()
19694 {
19695 continue;
19696 }
19697 let kind = if shared_files.is_empty()
19698 && shared_symbols.is_empty()
19699 && shared_tests.is_empty()
19700 && shared_config_files.is_empty()
19701 {
19702 "semantic_relation"
19703 } else {
19704 "shared_resource"
19705 };
19706 let mut reasons = Vec::new();
19707 if !shared_files.is_empty() {
19708 reasons.push(format!("shared files: {}", shared_files.join(", ")));
19709 }
19710 if !shared_symbols.is_empty() {
19711 reasons.push(format!("shared symbols: {}", shared_symbols.join(", ")));
19712 }
19713 if !shared_tests.is_empty() {
19714 reasons.push(format!("shared tests: {}", shared_tests.join(" && ")));
19715 }
19716 if !shared_config_files.is_empty() {
19717 reasons.push(format!(
19718 "shared config files: {}",
19719 shared_config_files.join(", ")
19720 ));
19721 }
19722 if !shared_semantic_refs.is_empty() {
19723 reasons.push(format!(
19724 "shared semantic refs: {}",
19725 shared_semantic_refs.join(", ")
19726 ));
19727 }
19728 let weight = shared_files.len() * 100
19729 + shared_config_files.len() * 100
19730 + shared_symbols.len() * 40
19731 + shared_tests.len() * 10
19732 + shared_semantic_refs.len() * 5;
19733 dependency_dag_push_edge(
19734 edges,
19735 seen,
19736 DependencyDagEdge {
19737 from: left.id.clone(),
19738 to: right.id.clone(),
19739 kind: kind.to_string(),
19740 weight,
19741 reasons,
19742 shared_files,
19743 shared_symbols,
19744 shared_tests,
19745 shared_config_files,
19746 shared_semantic_refs,
19747 },
19748 );
19749 }
19750 }
19751}
19752
19753fn dependency_dag_topo_batches(
19754 targets: &[String],
19755 edges: &[DependencyDagEdge],
19756) -> (Vec<DependencyDagTopoBatch>, DependencyDagCycleDiagnostics) {
19757 let target_set = targets.iter().cloned().collect::<BTreeSet<_>>();
19758 let order = targets
19759 .iter()
19760 .enumerate()
19761 .map(|(idx, id)| (id.clone(), idx))
19762 .collect::<BTreeMap<_, _>>();
19763 let mut indegree = targets
19764 .iter()
19765 .map(|id| (id.clone(), 0usize))
19766 .collect::<BTreeMap<_, _>>();
19767 let mut outgoing = BTreeMap::<String, Vec<String>>::new();
19768 let mut seen_pairs = BTreeSet::<(String, String)>::new();
19769 for edge in edges {
19770 if !target_set.contains(&edge.from) || !target_set.contains(&edge.to) {
19771 continue;
19772 }
19773 if !seen_pairs.insert((edge.from.clone(), edge.to.clone())) {
19774 continue;
19775 }
19776 *indegree.entry(edge.to.clone()).or_default() += 1;
19777 outgoing
19778 .entry(edge.from.clone())
19779 .or_default()
19780 .push(edge.to.clone());
19781 }
19782 for values in outgoing.values_mut() {
19783 values.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19784 values.dedup();
19785 }
19786
19787 let mut processed = BTreeSet::new();
19788 let mut batches = Vec::new();
19789 loop {
19790 let mut ready = targets
19791 .iter()
19792 .filter(|id| !processed.contains(*id))
19793 .filter(|id| indegree.get(*id).copied().unwrap_or(0) == 0)
19794 .cloned()
19795 .collect::<Vec<_>>();
19796 ready.sort_by_key(|id| order.get(id).copied().unwrap_or(usize::MAX));
19797 if ready.is_empty() {
19798 break;
19799 }
19800 for id in &ready {
19801 processed.insert(id.clone());
19802 for next in outgoing.get(id).into_iter().flatten() {
19803 if let Some(value) = indegree.get_mut(next) {
19804 *value = value.saturating_sub(1);
19805 }
19806 }
19807 }
19808 batches.push(DependencyDagTopoBatch {
19809 batch: batches.len() + 1,
19810 targets: ready,
19811 });
19812 }
19813
19814 let blocked_nodes = targets
19815 .iter()
19816 .filter(|id| !processed.contains(*id))
19817 .cloned()
19818 .collect::<Vec<_>>();
19819 let blocked_set = blocked_nodes.iter().cloned().collect::<BTreeSet<_>>();
19820 let cycle_edges = edges
19821 .iter()
19822 .filter(|edge| blocked_set.contains(&edge.from) && blocked_set.contains(&edge.to))
19823 .cloned()
19824 .collect::<Vec<_>>();
19825 (
19826 batches,
19827 DependencyDagCycleDiagnostics {
19828 has_cycles: !blocked_nodes.is_empty(),
19829 blocked_nodes,
19830 cycle_edges,
19831 },
19832 )
19833}
19834
19835fn dependency_dag_replay_commands(
19836 path: &Path,
19837 scope: Option<&str>,
19838 targets: &[String],
19839 depth: usize,
19840 limit: usize,
19841) -> Vec<String> {
19842 let target_args = targets
19843 .iter()
19844 .map(|target| shell_quote(target))
19845 .collect::<Vec<_>>()
19846 .join(" ");
19847 let mut command = format!(
19848 "tsift dependency-dag --path {}{} --depth {} --limit {} --json",
19849 shell_quote(path.to_string_lossy().as_ref()),
19850 scope
19851 .map(|scope| format!(" --scope {}", shell_quote(scope)))
19852 .unwrap_or_default(),
19853 depth,
19854 limit
19855 );
19856 if !target_args.is_empty() {
19857 command.push(' ');
19858 command.push_str(&target_args);
19859 }
19860 vec![command]
19861}
19862
19863fn build_dependency_dag_report(
19864 path: &Path,
19865 scope: Option<&str>,
19866 raw_targets: &[String],
19867 depth: usize,
19868 limit: usize,
19869) -> Result<DependencyDagReport> {
19870 let root = lint::resolve_project_root_or_canonical_path(path)?;
19871 write_traversal_graph_store(&root, path, scope)
19872 .with_context(|| format!("refreshing graph-db projection for {}", root.display()))?;
19873 let graph_db = graph_substrate_db_path(&root, scope);
19874 let store = SqliteGraphStore::open_read_only_resilient(&graph_db)
19875 .with_context(|| format!("opening graph-db projection: {}", graph_db.display()))?;
19876 let mut warnings = Vec::new();
19877 if let Some(recovery) = store.read_only_recovery() {
19878 warnings.push(graph_db_read_recovery_diagnostic(recovery));
19879 }
19880 let freshness = sqlite_graph_freshness(&store, scope.unwrap_or("root"))?;
19881 if freshness.fail_closed {
19882 bail!(
19883 "dependency-dag graph projection failed closed: {}; repair: {}",
19884 freshness.diagnostics.join("; "),
19885 graph_db_repair_commands(&root, scope).join("; ")
19886 );
19887 }
19888
19889 let target_nodes = dependency_dag_resolve_backlog_nodes(&root, path, &store, raw_targets)?;
19890 let graph_nodes = store.all_nodes()?;
19891 let graph_edges = store.all_edges()?;
19892 let graph_nodes_by_id = graph_nodes
19893 .into_iter()
19894 .map(|node| (node.id.clone(), node))
19895 .collect::<BTreeMap<_, _>>();
19896 let profiles = target_nodes
19897 .iter()
19898 .map(|node| {
19899 dependency_dag_node_profile(
19900 &root,
19901 &store,
19902 node,
19903 &graph_nodes_by_id,
19904 &graph_edges,
19905 depth,
19906 limit,
19907 )
19908 })
19909 .collect::<Result<Vec<_>>>()?;
19910 let targets = profiles
19911 .iter()
19912 .map(|profile| profile.id.clone())
19913 .collect::<Vec<_>>();
19914 let target_ids = targets.iter().cloned().collect::<BTreeSet<_>>();
19915
19916 let mut edges = Vec::new();
19917 let mut seen_edges = BTreeSet::new();
19918 dependency_dag_explicit_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19919 dependency_dag_worker_follow_up_edges(&profiles, &target_ids, &mut edges, &mut seen_edges);
19920 dependency_dag_overlap_edges(&profiles, &mut edges, &mut seen_edges);
19921 edges.sort_by(|left, right| {
19922 left.from
19923 .cmp(&right.from)
19924 .then(left.to.cmp(&right.to))
19925 .then(left.kind.cmp(&right.kind))
19926 });
19927 let (topo_batches, cycle_diagnostics) = dependency_dag_topo_batches(&targets, &edges);
19928
19929 let nodes = profiles
19930 .into_iter()
19931 .map(|profile| DependencyDagNode {
19932 id: profile.id,
19933 graph_node_id: profile.graph_node_id,
19934 label: profile.label,
19935 path: profile.path,
19936 line: profile.line,
19937 detail: profile.detail,
19938 source_files: sorted_set(&profile.source_files),
19939 source_symbols: sorted_set(&profile.source_symbols),
19940 config_files: sorted_set(&profile.config_files),
19941 expected_tests: sorted_set(&profile.expected_tests),
19942 semantic_refs: profile.semantic_refs.into_values().collect(),
19943 worker_feedback: profile.worker_feedback,
19944 })
19945 .collect::<Vec<_>>();
19946 let projection_hashes = freshness
19947 .content_hash
19948 .clone()
19949 .into_iter()
19950 .collect::<Vec<_>>();
19951 let replay_commands = dependency_dag_replay_commands(path, scope, &targets, depth, limit);
19952 let repair_commands = graph_db_repair_commands(&root, scope);
19953 let summary = DependencyDagSummary {
19954 nodes: nodes.len(),
19955 edges: edges.len(),
19956 topo_batches: topo_batches.len(),
19957 has_cycles: cycle_diagnostics.has_cycles,
19958 };
19959
19960 Ok(DependencyDagReport {
19961 contract_version: DEPENDENCY_DAG_CONTRACT_VERSION,
19962 root: root.to_string_lossy().to_string(),
19963 scope: scope.map(str::to_string),
19964 path: path.to_string_lossy().to_string(),
19965 targets,
19966 projection_freshness: freshness,
19967 projection_hashes,
19968 nodes,
19969 edges,
19970 topo_batches,
19971 cycle_diagnostics,
19972 summary,
19973 replay_commands,
19974 repair_commands,
19975 warnings,
19976 })
19977}
19978
19979fn print_dependency_dag_human(report: &DependencyDagReport, compact: bool) {
19980 if compact {
19981 println!(
19982 "dependency-dag targets:{} edges:{} batches:{} cycles:{}",
19983 report.targets.len(),
19984 report.edges.len(),
19985 report.topo_batches.len(),
19986 report.cycle_diagnostics.has_cycles
19987 );
19988 } else {
19989 println!("Dependency DAG");
19990 println!(" targets: {}", report.targets.join(", "));
19991 println!(" edges: {}", report.edges.len());
19992 println!(" cycles: {}", report.cycle_diagnostics.has_cycles);
19993 }
19994 for batch in &report.topo_batches {
19995 println!("batch #{}: {}", batch.batch, batch.targets.join(", "));
19996 }
19997 for edge in &report.edges {
19998 println!(
19999 "edge {} -> {} kind:{} weight:{}",
20000 edge.from, edge.to, edge.kind, edge.weight
20001 );
20002 for reason in &edge.reasons {
20003 println!(" reason: {reason}");
20004 }
20005 }
20006 if report.cycle_diagnostics.has_cycles {
20007 println!(
20008 "cycle blocked nodes: {}",
20009 report.cycle_diagnostics.blocked_nodes.join(", ")
20010 );
20011 }
20012 for command in &report.replay_commands {
20013 println!("replay: {command}");
20014 }
20015 for command in &report.repair_commands {
20016 println!("repair: {command}");
20017 }
20018 for warning in &report.warnings {
20019 println!("warning: {warning}");
20020 }
20021}
20022
20023fn cmd_dependency_dag(
20024 path: &Path,
20025 scope: Option<&str>,
20026 raw_targets: &[String],
20027 depth: usize,
20028 limit: usize,
20029 format: OutputFormat,
20030) -> Result<()> {
20031 let report = build_dependency_dag_report(path, scope, raw_targets, depth, limit)?;
20032 if format.json_output {
20033 print_json_or_envelope(
20034 &report,
20035 &format,
20036 "dependency-dag",
20037 "topological-planning",
20038 ToolEnvelopeSummary {
20039 text: format!(
20040 "Dependency DAG for {} target(s): edges={} batches={} cycles={}",
20041 report.targets.len(),
20042 report.edges.len(),
20043 report.topo_batches.len(),
20044 report.cycle_diagnostics.has_cycles
20045 ),
20046 metrics: vec![
20047 envelope_metric("targets", report.targets.len()),
20048 envelope_metric("edges", report.edges.len()),
20049 envelope_metric("topo_batches", report.topo_batches.len()),
20050 envelope_metric("has_cycles", report.cycle_diagnostics.has_cycles),
20051 ],
20052 },
20053 report.cycle_diagnostics.has_cycles,
20054 report.replay_commands.clone(),
20055 )
20056 } else {
20057 print_dependency_dag_human(&report, format.compact);
20058 Ok(())
20059 }
20060}
20061
20062fn maybe_attach_log_digest_raw_artifact(
20067 root: &Path,
20068 report: &mut log_digest::LogDigestReport,
20069 input: &str,
20070) -> Result<()> {
20071 if input.trim().is_empty() || !log_digest::raw_log_artifact_recommended(report, input.len()) {
20072 return Ok(());
20073 }
20074 let key = format!("logdigest:{}:{}", report.total_lines, input.len());
20075 let artifact_path = root
20076 .join(".tsift/artifacts")
20077 .join(format!("{}.log", stable_handle("logdg", &key)));
20078 let expand = format!(
20079 "tsift log-digest --path {} --input {} --json",
20080 shell_quote(root.to_string_lossy().as_ref()),
20081 shell_quote(artifact_path.to_string_lossy().as_ref())
20082 );
20083 let artifact = persist_transcript_artifact(root, "logdg", "log", &key, input, expand)?;
20084 report.raw_log_artifact = Some(log_digest::LogDigestArtifactRef {
20085 handle: artifact.handle,
20086 path: artifact.path,
20087 bytes: artifact.bytes,
20088 lines: artifact.lines,
20089 expand: artifact.expand,
20090 });
20091 Ok(())
20092}
20093
20094pub(crate) fn render_log_digest_fixture(
20098 path: &Path,
20099 fixture_path: &Path,
20100 fail_under: bool,
20101 format: OutputFormat,
20102) -> Result<()> {
20103 let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20104 let fixture_body = fs::read_to_string(fixture_path)
20105 .with_context(|| format!("reading log-digest fixture: {}", fixture_path.display()))?;
20106 let fixture: log_digest::LogDigestFixture = serde_json::from_str(&fixture_body)
20107 .with_context(|| format!("parsing log-digest fixture: {}", fixture_path.display()))?;
20108 let report = log_digest::evaluate_fixture(&root, &fixture)?;
20109
20110 if format.json_output {
20111 print_json_or_envelope(
20112 &report,
20113 &format,
20114 "log-digest-fixture",
20115 "report",
20116 ToolEnvelopeSummary {
20117 text: if report.passed {
20118 format!("log-digest gate passed for {} case(s)", report.total_cases)
20119 } else {
20120 format!("log-digest gate failed {} case(s)", report.failed_cases)
20121 },
20122 metrics: vec![
20123 envelope_metric("cases", report.total_cases),
20124 envelope_metric("failed", report.failed_cases),
20125 envelope_metric("passed", report.passed),
20126 ],
20127 },
20128 false,
20129 vec![],
20130 )?;
20131 } else {
20132 println!("Log digest fixture gate");
20133 println!(" cases: {}", report.total_cases);
20134 println!(" failed: {}", report.failed_cases);
20135 println!(" status: {}", if report.passed { "pass" } else { "fail" });
20136 for case in &report.cases {
20137 println!(
20138 " [{}] {} ({}): savings {:.1}% (min {:.1}%) raw_tok {} digest_tok {}",
20139 if case.passed { "pass" } else { "FAIL" },
20140 case.name,
20141 case.ecosystem,
20142 case.savings_percent,
20143 case.minimum_savings_percent,
20144 case.raw_tokens,
20145 case.digest_tokens
20146 );
20147 if !case.missing_required_signals.is_empty() {
20148 println!(
20149 " missing required signals: {}",
20150 case.missing_required_signals.join(", ")
20151 );
20152 }
20153 if !case.present_forbidden_signals.is_empty() {
20154 println!(
20155 " present forbidden signals: {}",
20156 case.present_forbidden_signals.join(", ")
20157 );
20158 }
20159 }
20160 }
20161
20162 if fail_under && !report.passed {
20163 bail!("log-digest fixture gate failed");
20164 }
20165 Ok(())
20166}
20167
20168pub(crate) fn render_log_digest_from_input(
20169 path: &Path,
20170 input: &str,
20171 format: OutputFormat,
20172) -> Result<()> {
20173 let mut report = log_digest::compute(path, input)?;
20174 let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
20175 maybe_attach_log_digest_raw_artifact(&root, &mut report, input)?;
20176 if format.json_output {
20177 println!(
20178 "{}",
20179 to_json_schema(
20180 &report,
20181 format.pretty,
20182 format.terse,
20183 format.ultra_terse,
20184 format.schema
20185 )?
20186 );
20187 return Ok(());
20188 }
20189
20190 if format.compact {
20191 println!(
20192 "log lines:{} signals:{} repeats:{} files:{} syms:{} stacks:{}",
20193 report.non_empty_lines,
20194 report.signal_groups,
20195 report.repeated_line_groups,
20196 report.file_ref_groups,
20197 report.symbol_ref_groups,
20198 report.stack_groups
20199 );
20200 for signal in &report.signals {
20201 let location = match (&signal.path, signal.line) {
20202 (Some(path), Some(line)) => format!("{path}:{line}"),
20203 (Some(path), None) => path.clone(),
20204 _ => "-".to_string(),
20205 };
20206 println!(
20207 "{} sev:{} count:{} sums:{} msg:{}",
20208 location,
20209 signal.severity,
20210 signal.occurrences,
20211 log_digest_summary_label(signal.summary_state),
20212 truncate_for_compact(&signal.message, 80)
20213 );
20214 }
20215 for repeated in &report.repeated_lines {
20216 println!(
20217 "repeat count:{} line:{}",
20218 repeated.occurrences,
20219 truncate_for_compact(&repeated.line, 80)
20220 );
20221 }
20222 for family in &report.line_families {
20223 println!(
20224 "family count:{} variants:{} template:{}",
20225 family.occurrences,
20226 family.variants,
20227 truncate_for_compact(&family.template, 80)
20228 );
20229 }
20230 for symbol in &report.symbol_refs {
20231 println!(
20232 "sym:{} count:{} sums:{}",
20233 symbol.symbol,
20234 symbol.occurrences,
20235 log_digest_summary_label(symbol.summary_state)
20236 );
20237 }
20238 if let Some(artifact) = &report.raw_log_artifact {
20239 println!(
20240 "raw-artifact handle:{} lines:{} bytes:{} expand:{}",
20241 artifact.handle, artifact.lines, artifact.bytes, artifact.expand
20242 );
20243 }
20244 for warning in &report.warnings {
20245 println!("warning: {warning}");
20246 }
20247 return Ok(());
20248 }
20249
20250 println!("Log digest");
20251 println!(" lines: {}", report.total_lines);
20252 println!(" non-empty lines: {}", report.non_empty_lines);
20253 println!(" signal groups: {}", report.signal_groups);
20254 println!(
20255 " repeated lines: {}",
20256 report.repeated_line_groups
20257 );
20258 println!(
20259 " repeated line instances: {}",
20260 report.repeated_line_occurrences
20261 );
20262 println!(" line families: {}", report.line_family_groups);
20263 println!(" file refs: {}", report.file_ref_groups);
20264 println!(" symbol refs: {}", report.symbol_ref_groups);
20265 println!(" stack groups: {}", report.stack_groups);
20266
20267 if !report.signals.is_empty() {
20268 println!();
20269 println!("Signals:");
20270 for signal in &report.signals {
20271 match (&signal.path, signal.line, signal.column) {
20272 (Some(path), Some(line), Some(column)) => println!("{path}:{line}:{column}"),
20273 (Some(path), Some(line), None) => println!("{path}:{line}"),
20274 (Some(path), None, _) => println!("{path}"),
20275 (None, _, _) => println!("(no file anchor)"),
20276 }
20277 println!(" severity: {}", signal.severity);
20278 println!(" occurrences: {}", signal.occurrences);
20279 println!(" message: {}", signal.message);
20280 println!(
20281 " cached summaries: {}",
20282 log_digest_summary_label(signal.summary_state)
20283 );
20284 for summary in &signal.current_summaries {
20285 println!(
20286 " - {}: {}",
20287 summary.symbol,
20288 truncate_for_compact(&summary.summary, 160)
20289 );
20290 }
20291 }
20292 }
20293
20294 if !report.repeated_lines.is_empty() {
20295 println!();
20296 println!("Repeated lines:");
20297 for repeated in &report.repeated_lines {
20298 println!(
20299 " {}x {}",
20300 repeated.occurrences,
20301 truncate_for_compact(&repeated.line, 180)
20302 );
20303 }
20304 }
20305
20306 if !report.line_families.is_empty() {
20307 println!();
20308 println!("Line families (near-duplicate folds):");
20309 for family in &report.line_families {
20310 println!(
20311 " {}x ({} variants) {}",
20312 family.occurrences,
20313 family.variants,
20314 truncate_for_compact(&family.template, 180)
20315 );
20316 println!(
20317 " first: {}",
20318 truncate_for_compact(&family.first_sample, 180)
20319 );
20320 println!(
20321 " last: {}",
20322 truncate_for_compact(&family.last_sample, 180)
20323 );
20324 }
20325 }
20326
20327 if !report.file_refs.is_empty() {
20328 println!();
20329 println!("Anchored files:");
20330 for file_ref in &report.file_refs {
20331 match (file_ref.line, file_ref.column) {
20332 (Some(line), Some(column)) => println!("{}:{}:{}", file_ref.path, line, column),
20333 (Some(line), None) => println!("{}:{}", file_ref.path, line),
20334 (None, _) => println!("{}", file_ref.path),
20335 }
20336 println!(" occurrences: {}", file_ref.occurrences);
20337 println!(
20338 " cached summaries: {}",
20339 log_digest_summary_label(file_ref.summary_state)
20340 );
20341 for summary in &file_ref.current_summaries {
20342 println!(
20343 " - {}: {}",
20344 summary.symbol,
20345 truncate_for_compact(&summary.summary, 160)
20346 );
20347 }
20348 }
20349 }
20350
20351 if !report.symbol_refs.is_empty() {
20352 println!();
20353 println!("Symbol candidates:");
20354 for symbol in &report.symbol_refs {
20355 println!("{}", symbol.symbol);
20356 println!(" occurrences: {}", symbol.occurrences);
20357 println!(
20358 " cached summaries: {}",
20359 log_digest_summary_label(symbol.summary_state)
20360 );
20361 for summary in &symbol.current_summaries {
20362 println!(
20363 " - {}: {}",
20364 summary.symbol,
20365 truncate_for_compact(&summary.summary, 160)
20366 );
20367 }
20368 }
20369 }
20370
20371 if !report.stack_traces.is_empty() {
20372 println!();
20373 println!("Stack groups:");
20374 for stack in &report.stack_traces {
20375 println!(" occurrences: {}", stack.occurrences);
20376 for frame in &stack.frames {
20377 println!(" - {}", frame);
20378 }
20379 }
20380 }
20381
20382 if let Some(artifact) = &report.raw_log_artifact {
20383 println!();
20384 println!("Raw log artifact:");
20385 println!(" handle: {}", artifact.handle);
20386 println!(" path: {}", artifact.path);
20387 println!(" lines: {}", artifact.lines);
20388 println!(" bytes: {}", artifact.bytes);
20389 println!(" expand: {}", artifact.expand);
20390 }
20391
20392 for warning in &report.warnings {
20393 println!("warning: {warning}");
20394 }
20395 Ok(())
20396}
20397
20398pub(crate) fn metric_digest_trend_label(trend: metric_digest::MetricDigestTrend) -> &'static str {
20399 match trend {
20400 metric_digest::MetricDigestTrend::Improved => "improved",
20401 metric_digest::MetricDigestTrend::Regressed => "regressed",
20402 metric_digest::MetricDigestTrend::Flat => "flat",
20403 metric_digest::MetricDigestTrend::Unknown => "changed",
20404 }
20405}
20406
20407pub(crate) fn metric_digest_gate_label(
20408 decision: metric_digest::CommunitySearchGateDecision,
20409) -> &'static str {
20410 match decision {
20411 metric_digest::CommunitySearchGateDecision::Pass => "pass",
20412 metric_digest::CommunitySearchGateDecision::Block => "block",
20413 }
20414}
20415
20416pub(crate) fn memgraphrag_metric_digest_gate_label(
20417 decision: metric_digest::MemGraphRagPerformanceGateDecision,
20418) -> &'static str {
20419 match decision {
20420 metric_digest::MemGraphRagPerformanceGateDecision::Pass => "pass",
20421 metric_digest::MemGraphRagPerformanceGateDecision::Block => "block",
20422 }
20423}
20424
20425fn cmd_dci_benchmark(fixture_path: &Path, format: OutputFormat) -> Result<()> {
20426 let input = fs::read_to_string(fixture_path)
20427 .with_context(|| format!("reading dci-benchmark fixture: {}", fixture_path.display()))?;
20428 let report = dci_benchmark::compute(&input)?;
20429
20430 if format.json_output {
20431 println!(
20432 "{}",
20433 to_json_schema(
20434 &report,
20435 format.pretty,
20436 format.terse,
20437 format.ultra_terse,
20438 format.schema
20439 )?
20440 );
20441 return Ok(());
20442 }
20443
20444 if format.compact {
20445 println!(
20446 "dci tasks:{} strategies:{} warnings:{}",
20447 report.tasks_loaded,
20448 report.strategies_compared,
20449 report.warnings.len()
20450 );
20451 for summary in &report.strategy_summaries {
20452 println!(
20453 "{} rank:{} loc:{}/{} rate:{} useful_hits:{} zero_output:{} calls:{} latency_ms:{} tokens:{} output_tokens:{}",
20454 summary.strategy,
20455 summary.rank,
20456 summary.localized,
20457 summary.task_runs,
20458 dci_benchmark::format_number(summary.localization_rate * 100.0),
20459 dci_benchmark::format_number(summary.avg_useful_hits),
20460 dci_benchmark::format_number(summary.zero_output_rate * 100.0),
20461 dci_benchmark::format_number(summary.avg_tool_calls),
20462 dci_benchmark::format_number(summary.avg_latency_ms),
20463 dci_benchmark::format_number(summary.avg_estimated_tokens),
20464 dci_benchmark::format_number(summary.avg_output_tokens)
20465 );
20466 }
20467 if let Some(gate) = &report.memory_retrieval_gate {
20468 println!(
20469 "memory_retrieval_gate decision:{} baseline:{} min_avg_useful_hits:{} max_zero_output_failures:{} diagnostics:{}",
20470 gate.decision,
20471 gate.baseline_strategy,
20472 dci_benchmark::format_number(gate.min_avg_useful_hits),
20473 gate.max_zero_output_failures,
20474 gate.diagnostics.len()
20475 );
20476 }
20477 for warning in &report.warnings {
20478 println!("warning: {warning}");
20479 }
20480 return Ok(());
20481 }
20482
20483 println!("DCI benchmark");
20484 if let Some(description) = &report.description {
20485 println!(" description: {}", description);
20486 }
20487 println!(" tasks loaded: {}", report.tasks_loaded);
20488 println!(" strategies compared: {}", report.strategies_compared);
20489
20490 println!();
20491 println!("Strategy summary:");
20492 for summary in &report.strategy_summaries {
20493 println!(
20494 " #{} {}: localization {}/{} ({:.1}%), avg useful hits {}, zero output {:.1}%, avg calls {}, avg latency {}ms, avg tokens {}, avg output tokens {}",
20495 summary.rank,
20496 summary.strategy,
20497 summary.localized,
20498 summary.task_runs,
20499 summary.localization_rate * 100.0,
20500 dci_benchmark::format_number(summary.avg_useful_hits),
20501 summary.zero_output_rate * 100.0,
20502 dci_benchmark::format_number(summary.avg_tool_calls),
20503 dci_benchmark::format_number(summary.avg_latency_ms),
20504 dci_benchmark::format_number(summary.avg_estimated_tokens),
20505 dci_benchmark::format_number(summary.avg_output_tokens)
20506 );
20507 }
20508
20509 if let Some(gate) = &report.memory_retrieval_gate {
20510 println!();
20511 println!("Memory retrieval gate:");
20512 println!(" decision: {}", gate.decision);
20513 println!(
20514 " baseline: {}, min avg useful hits {}, max zero-output failures {}",
20515 gate.baseline_strategy,
20516 dci_benchmark::format_number(gate.min_avg_useful_hits),
20517 gate.max_zero_output_failures
20518 );
20519 for row in &gate.rows {
20520 println!(
20521 " {}: status {}, avg useful hits {}, zero-output failures {}",
20522 row.strategy,
20523 row.status,
20524 dci_benchmark::format_number(row.avg_useful_hits),
20525 row.zero_output_failures
20526 );
20527 }
20528 for diagnostic in &gate.diagnostics {
20529 println!(" diagnostic: {diagnostic}");
20530 }
20531 }
20532
20533 println!();
20534 println!("Task winners:");
20535 for row in &report.task_rows {
20536 let label = row
20537 .label
20538 .as_ref()
20539 .map(|value| format!(" ({value})"))
20540 .unwrap_or_default();
20541 println!(" {}{}", row.task_id, label);
20542 println!(" localized: {}", row.best_localization.join(", "));
20543 println!(" most useful hits: {}", row.most_useful_hits.join(", "));
20544 println!(
20545 " lowest calls: {}, lowest latency: {}, lowest tokens: {}, lowest output tokens: {}",
20546 row.lowest_tool_calls.as_deref().unwrap_or("-"),
20547 row.lowest_latency.as_deref().unwrap_or("-"),
20548 row.lowest_token_budget.as_deref().unwrap_or("-"),
20549 row.lowest_output_tokens.as_deref().unwrap_or("-")
20550 );
20551 if !row.zero_output_failures.is_empty() {
20552 println!(" zero output: {}", row.zero_output_failures.join(", "));
20553 }
20554 }
20555
20556 for warning in &report.warnings {
20557 println!("warning: {warning}");
20558 }
20559 Ok(())
20560}
20561
20562pub(crate) fn format_compact_count(value: u64) -> String {
20563 if value >= 1_000_000 {
20564 format!("{:.1}M", value as f64 / 1_000_000.0)
20565 } else if value >= 1_000 {
20566 format!("{:.1}K", value as f64 / 1_000.0)
20567 } else {
20568 value.to_string()
20569 }
20570}
20571
20572fn cmd_digest_runner(
20573 kind: &str,
20574 path: &Path,
20575 runner: Option<&str>,
20576 shell_command: &str,
20577 format: OutputFormat,
20578) -> Result<()> {
20579 let digest_kind = DigestRunnerKind::parse(kind)?;
20580 let root = transcript_artifact_root(path)?;
20581 let execution = run_digest_runner_command(shell_command)?;
20582 let output = &execution.output;
20583 let captured = String::from_utf8_lossy(&output.stdout).into_owned();
20584 let exit_code = output.status.code().unwrap_or(-1);
20585 if format.json_output && format.envelope {
20586 let artifact_key = format!(
20587 "{}:{}:{}:{}",
20588 digest_kind.as_str(),
20589 shell_command,
20590 execution.executed_command,
20591 captured
20592 );
20593 let artifact = if captured.trim().is_empty() {
20594 None
20595 } else {
20596 let (suffix, expand) = match digest_kind {
20597 DigestRunnerKind::Test => (
20598 "test.log",
20599 format!(
20600 "tsift test-digest --path {} --input {}{} --json",
20601 shell_quote(root.to_string_lossy().as_ref()),
20602 shell_quote(
20603 root.join(".tsift/artifacts")
20604 .join(format!("{}.test.log", stable_handle("tart", &artifact_key)))
20605 .to_string_lossy()
20606 .as_ref()
20607 ),
20608 runner
20609 .map(|value| format!(" --runner {}", shell_quote(value)))
20610 .unwrap_or_default()
20611 ),
20612 ),
20613 DigestRunnerKind::Log => (
20614 "log",
20615 format!(
20616 "tsift log-digest --path {} --input {} --json",
20617 shell_quote(root.to_string_lossy().as_ref()),
20618 shell_quote(
20619 root.join(".tsift/artifacts")
20620 .join(format!("{}.log", stable_handle("tart", &artifact_key)))
20621 .to_string_lossy()
20622 .as_ref()
20623 )
20624 ),
20625 ),
20626 };
20627 Some(persist_transcript_artifact(
20628 &root,
20629 "tart",
20630 suffix,
20631 &artifact_key,
20632 &captured,
20633 expand,
20634 )?)
20635 };
20636 let filter_report = execution.filter.as_ref().map(DigestRunnerFilter::to_json);
20637
20638 match digest_kind {
20639 DigestRunnerKind::Test => {
20640 let digest_report = test_digest::compute(path, &captured, runner)?;
20641 let report = serde_json::json!({
20642 "kind": digest_kind.as_str(),
20643 "command": shell_command,
20644 "executed_command": execution.executed_command,
20645 "exit_code": exit_code,
20646 "success": output.status.success(),
20647 "filter": filter_report,
20648 "artifact": artifact,
20649 "digest": digest_report,
20650 });
20651 let mut follow_up = artifact
20652 .as_ref()
20653 .map(|entry| vec![entry.expand.clone()])
20654 .unwrap_or_default();
20655 follow_up.push(format!(
20656 "tsift rewrite --run {}",
20657 shell_quote(shell_command)
20658 ));
20659 let summary_text = if output.status.success() && digest_report.failures == 0 {
20660 format!("test run passed for {}", runner.unwrap_or("auto"))
20661 } else {
20662 format!("test run captured {} failure(s)", digest_report.failures)
20663 };
20664 print_json_or_envelope(
20665 &report,
20666 &format,
20667 "digest-runner",
20668 "test-run",
20669 ToolEnvelopeSummary {
20670 text: summary_text,
20671 metrics: vec![
20672 envelope_metric("runner", &digest_report.runner),
20673 envelope_metric("exit_code", exit_code),
20674 envelope_metric("filter", execution.filter_label()),
20675 envelope_metric("failures", digest_report.failures),
20676 envelope_metric("groups", digest_report.grouped_failures),
20677 envelope_metric(
20678 "artifact",
20679 artifact
20680 .as_ref()
20681 .map(|entry| entry.handle.as_str())
20682 .unwrap_or("-"),
20683 ),
20684 ],
20685 },
20686 false,
20687 follow_up,
20688 )?;
20689 }
20690 DigestRunnerKind::Log => {
20691 let digest_report = log_digest::compute(path, &captured)?;
20692 let report = serde_json::json!({
20693 "kind": digest_kind.as_str(),
20694 "command": shell_command,
20695 "executed_command": execution.executed_command,
20696 "exit_code": exit_code,
20697 "success": output.status.success(),
20698 "filter": filter_report,
20699 "artifact": artifact,
20700 "digest": digest_report,
20701 });
20702 let mut follow_up = artifact
20703 .as_ref()
20704 .map(|entry| vec![entry.expand.clone()])
20705 .unwrap_or_default();
20706 follow_up.push(format!(
20707 "tsift rewrite --run {}",
20708 shell_quote(shell_command)
20709 ));
20710 let summary_text = if output.status.success() && digest_report.signal_groups == 0 {
20711 "command finished without log signals".to_string()
20712 } else {
20713 format!(
20714 "command emitted {} log signal group(s)",
20715 digest_report.signal_groups
20716 )
20717 };
20718 print_json_or_envelope(
20719 &report,
20720 &format,
20721 "digest-runner",
20722 "command-run",
20723 ToolEnvelopeSummary {
20724 text: summary_text,
20725 metrics: vec![
20726 envelope_metric("exit_code", exit_code),
20727 envelope_metric("filter", execution.filter_label()),
20728 envelope_metric("signals", digest_report.signal_groups),
20729 envelope_metric("file_refs", digest_report.file_ref_groups),
20730 envelope_metric(
20731 "artifact",
20732 artifact
20733 .as_ref()
20734 .map(|entry| entry.handle.as_str())
20735 .unwrap_or("-"),
20736 ),
20737 ],
20738 },
20739 false,
20740 follow_up,
20741 )?;
20742 }
20743 }
20744
20745 if output.status.success() {
20746 return Ok(());
20747 }
20748 if let Some(code) = output.status.code() {
20749 std::process::exit(code);
20750 }
20751 bail!("digest-wrapped command terminated by signal: {shell_command}");
20752 }
20753
20754 if captured.trim().is_empty() {
20755 let label = match digest_kind {
20756 DigestRunnerKind::Test => "test",
20757 DigestRunnerKind::Log => "log",
20758 };
20759 println!("No {label} output captured.");
20760 } else {
20761 match digest_kind {
20762 DigestRunnerKind::Test => {
20763 render_test_digest_from_input(path, &captured, runner, format)?
20764 }
20765 DigestRunnerKind::Log => render_log_digest_from_input(path, &captured, format)?,
20766 }
20767 }
20768
20769 if output.status.success() {
20770 return Ok(());
20771 }
20772 if let Some(code) = output.status.code() {
20773 std::process::exit(code);
20774 }
20775 bail!("digest-wrapped command terminated by signal: {shell_command}");
20776}
20777
20778struct DigestRunnerExecution {
20779 output: std::process::Output,
20780 executed_command: String,
20781 filter: Option<DigestRunnerFilter>,
20782}
20783
20784impl DigestRunnerExecution {
20785 fn filter_label(&self) -> &'static str {
20786 self.filter
20787 .as_ref()
20788 .map(|filter| filter.tool)
20789 .unwrap_or("none")
20790 }
20791}
20792
20793struct DigestRunnerFilter {
20794 tool: &'static str,
20795 command: String,
20796}
20797
20798impl DigestRunnerFilter {
20799 fn to_json(&self) -> serde_json::Value {
20800 serde_json::json!({
20801 "tool": self.tool,
20802 "command": self.command,
20803 })
20804 }
20805}
20806
20807fn run_digest_runner_command(shell_command: &str) -> Result<DigestRunnerExecution> {
20808 let filter = rtk_rewrite_for_digest_runner(shell_command);
20809 let executed_command = filter
20810 .as_ref()
20811 .map(|filter| filter.command.as_str())
20812 .unwrap_or(shell_command);
20813 let output = Command::new("sh")
20814 .arg("-lc")
20815 .arg(format!("({executed_command}) 2>&1"))
20816 .stdout(Stdio::piped())
20817 .output()
20818 .with_context(|| format!("running digest-wrapped command: {executed_command}"))?;
20819
20820 Ok(DigestRunnerExecution {
20821 output,
20822 executed_command: executed_command.to_string(),
20823 filter,
20824 })
20825}
20826
20827fn rtk_rewrite_for_digest_runner(shell_command: &str) -> Option<DigestRunnerFilter> {
20828 if shell_command.trim_start().starts_with("rtk ") || find_command_on_path("rtk").is_none() {
20829 return None;
20830 }
20831 let output = Command::new("rtk")
20832 .arg("rewrite")
20833 .arg(shell_command)
20834 .output()
20835 .ok()?;
20836 if !output.status.success() {
20837 return None;
20838 }
20839 let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
20840 if rewritten.is_empty() || rewritten == shell_command {
20841 return None;
20842 }
20843 Some(DigestRunnerFilter {
20844 tool: "rtk",
20845 command: rewritten,
20846 })
20847}
20848
20849fn find_command_on_path(command: &str) -> Option<PathBuf> {
20850 let path_var = std::env::var_os("PATH")?;
20851 std::env::split_paths(&path_var)
20852 .map(|dir| dir.join(command))
20853 .find(|candidate| candidate.is_file())
20854}
20855
20856pub(crate) fn open_existing_summary_db_read_only(db_path: &Path) -> Result<summarize::SummaryDb> {
20857 if !db_path.exists() {
20858 bail!("no summaries.db found — run `tsift summarize --extract <path>` first");
20859 }
20860 summarize::SummaryDb::open_read_only_resilient(db_path)
20861}
20862
20863fn status_index_needs_fix(report: &status::StatusReport) -> bool {
20864 !matches!(report.index, status::IndexStatus::Fresh { .. })
20865}
20866
20867fn status_instructions_need_fix(report: &status::StatusReport) -> bool {
20868 !matches!(report.instructions, init::InstructionStatus::Current { .. })
20869}
20870
20871pub(crate) fn apply_status_fixes(root: &Path, report: &status::StatusReport) -> Result<()> {
20872 if status_instructions_need_fix(report) {
20873 eprintln!("status fix: refreshing tsift instructions");
20874 init::init(root, false, false)?;
20875 }
20876
20877 let eviction = cycle_packet_cache::cycle_packet_cache_evict(
20878 root,
20879 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_TTL_SECS,
20880 cycle_packet_cache::CYCLE_PACKET_CACHE_DEFAULT_MAX_BYTES,
20881 );
20882 if eviction.evicted_entries > 0 {
20883 eprintln!(
20884 "status fix: evicted {} cycle packet cache entry/entries ({} bytes, {} remaining)",
20885 eviction.evicted_entries, eviction.evicted_bytes, eviction.remaining_entries
20886 );
20887 }
20888
20889 if !status_index_needs_fix(report) {
20890 return Ok(());
20891 }
20892
20893 let scopes = config::Config::submodule_dirs(root)?;
20894 if scopes.is_empty() {
20895 eprintln!("status fix: refreshing index");
20896 run_index_update(
20897 &root.join(".tsift/index.db"),
20898 root,
20899 "status --fix refreshing index".to_string(),
20900 root,
20901 None,
20902 false,
20903 false,
20904 )?;
20905 return Ok(());
20906 }
20907
20908 let cfg = config::Config::load(root)?;
20909 for scope in scopes {
20910 if !scope.source_root.exists() {
20911 eprintln!(
20912 "status fix: skipping missing submodule `{}` ({})",
20913 scope.id,
20914 scope.source_root.display()
20915 );
20916 continue;
20917 }
20918 eprintln!("status fix: refreshing submodule `{}` index", scope.id);
20919 run_index_update(
20920 &cfg.db_path_for(root, &scope.id),
20921 &scope.source_root,
20922 format!("status --fix refreshing submodule `{}` index", scope.id),
20923 root,
20924 Some(scope.id.as_str()),
20925 false,
20926 false,
20927 )?;
20928 }
20929
20930 Ok(())
20931}
20932
20933pub(crate) fn status_missing_workspace_scopes(report: &status::StatusReport) -> bool {
20934 match &report.index {
20935 status::IndexStatus::Fresh { missing_scopes, .. }
20936 | status::IndexStatus::Stale { missing_scopes, .. }
20937 | status::IndexStatus::Missing { missing_scopes } => !missing_scopes.is_empty(),
20938 }
20939}
20940
20941pub(crate) fn autoindex_missing_workspace_scopes(
20942 root: &Path,
20943 report: &status::StatusReport,
20944) -> Result<()> {
20945 let missing_scopes = match &report.index {
20946 status::IndexStatus::Fresh { missing_scopes, .. }
20947 | status::IndexStatus::Stale { missing_scopes, .. }
20948 | status::IndexStatus::Missing { missing_scopes } => missing_scopes,
20949 };
20950 if missing_scopes.is_empty() {
20951 return Ok(());
20952 }
20953
20954 let missing_scope_ids = missing_scopes
20955 .iter()
20956 .map(|scope| scope.scope.as_str())
20957 .collect::<std::collections::HashSet<_>>();
20958 let cfg = config::Config::load(root)?;
20959 for scope in config::Config::submodule_dirs(root)? {
20960 if !missing_scope_ids.contains(scope.id.as_str()) || !scope.source_root.exists() {
20961 continue;
20962 }
20963 let db_path = cfg.db_path_for(root, &scope.id);
20964 run_index_update(
20965 &db_path,
20966 &scope.source_root,
20967 format!(
20968 "autoindexing missing submodule `{}` during status",
20969 scope.id
20970 ),
20971 root,
20972 Some(scope.id.as_str()),
20973 false,
20974 false,
20975 )?;
20976 }
20977 Ok(())
20978}
20979
20980pub(crate) fn emit_summary_stats_warnings(stats: &summarize::SummaryStats, root: &Path) {
20981 for warning in &stats.warnings {
20982 let rel_path = relativize_pathbuf(&warning.path, root);
20983 eprintln!(
20984 "warning: summarize stats {}: {}",
20985 rel_path.display(),
20986 warning.message
20987 );
20988 }
20989}
20990
20991fn contextualize_error(err: anyhow::Error, context: String) -> anyhow::Error {
20992 Result::<(), anyhow::Error>::Err(err)
20993 .context(context)
20994 .unwrap_err()
20995}
20996
20997fn should_attach_lock_diagnostics(err: &anyhow::Error) -> bool {
20998 let message = err.to_string();
20999 message.contains("another tsift index writer is already active")
21000 || substrate::error_mentions_locked_db(err)
21001}
21002
21003fn add_write_lock_context(
21004 err: anyhow::Error,
21005 action: String,
21006 root: &std::path::Path,
21007 scope: Option<&str>,
21008) -> anyhow::Error {
21009 if !should_attach_lock_diagnostics(&err) {
21010 return contextualize_error(err, action);
21011 }
21012
21013 let Ok(report) = status::check_locks(root, None, scope) else {
21014 return contextualize_error(err, action);
21015 };
21016
21017 contextualize_error(
21018 err,
21019 format!(
21020 "{}\n\nlock diagnostics:\n{}",
21021 action,
21022 status::format_locks_human(&report, false).trim_end()
21023 ),
21024 )
21025}
21026
21027pub(crate) fn run_index_update(
21028 db_path: &std::path::Path,
21029 source_root: &std::path::Path,
21030 action: String,
21031 root: &std::path::Path,
21032 scope: Option<&str>,
21033 rebuild: bool,
21034 prune: bool,
21035) -> Result<index::IndexSummary> {
21036 let result = (|| {
21037 let db = index::IndexDb::open(db_path)?;
21038 if rebuild {
21039 db.rebuild(source_root)
21040 } else if prune {
21041 db.apply_changes_pruned(source_root)
21042 } else {
21043 db.apply_changes(source_root)
21044 }
21045 })();
21046
21047 let summary = result.map_err(|err| add_write_lock_context(err, action, root, scope))?;
21048 emit_index_warnings(&summary, source_root, scope);
21049 Ok(summary)
21050}
21051
21052pub(crate) fn relativize_index_summary(summary: &mut index::IndexSummary, root: &Path) {
21053 for change in &mut summary.changes {
21054 change.path = relativize_pathbuf(&change.path, root);
21055 }
21056 for warning in &mut summary.warnings {
21057 warning.path = relativize_pathbuf(&warning.path, root);
21058 }
21059}
21060
21061fn emit_index_warnings(summary: &index::IndexSummary, root: &Path, scope: Option<&str>) {
21062 for warning in &summary.warnings {
21063 let rel_path = relativize_pathbuf(&warning.path, root);
21064 let stage = match warning.stage {
21065 index::IndexWarningStage::ReadSource => "read failed",
21066 index::IndexWarningStage::ExtractSymbols => "symbol extraction failed",
21067 index::IndexWarningStage::ExtractCallSites => "call extraction failed",
21068 index::IndexWarningStage::ExtractRoutes => "route extraction failed",
21069 };
21070 let scope_prefix = scope.map(|name| format!("[{}] ", name)).unwrap_or_default();
21071 let lang_suffix = warning
21072 .language
21073 .as_deref()
21074 .map(|lang| format!(" [{}]", lang))
21075 .unwrap_or_default();
21076 eprintln!(
21077 "warning: {}{}{}: {}: {}",
21078 scope_prefix,
21079 rel_path.display(),
21080 lang_suffix,
21081 stage,
21082 warning.message
21083 );
21084 }
21085}
21086
21087pub(crate) fn load_summarize_config(root: &std::path::Path) -> summarize::SummarizeConfig {
21088 let config_path = root.join(".tsift/config.toml");
21089 if !config_path.exists() {
21090 return summarize::SummarizeConfig::default();
21091 }
21092 #[derive(serde::Deserialize, Default)]
21093 struct RawConfig {
21094 #[serde(default)]
21095 summarize: Option<RawSummarize>,
21096 }
21097 #[derive(serde::Deserialize)]
21098 struct RawSummarize {
21099 model: Option<String>,
21100 max_file_tokens: Option<usize>,
21101 api_key_env: Option<String>,
21102 }
21103 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
21104 let raw: RawConfig = toml::from_str(&content).unwrap_or_default();
21105 let defaults = summarize::SummarizeConfig::default();
21106 match raw.summarize {
21107 Some(s) => summarize::SummarizeConfig {
21108 model: s.model.unwrap_or(defaults.model),
21109 max_file_tokens: s.max_file_tokens.unwrap_or(defaults.max_file_tokens),
21110 api_key_env: s.api_key_env.unwrap_or(defaults.api_key_env),
21111 },
21112 None => defaults,
21113 }
21114}
21115
21116#[derive(Debug, Clone, PartialEq, Eq)]
21117struct ExtractSymbolContext {
21118 db_path: PathBuf,
21119 source_root: PathBuf,
21120}
21121
21122pub(crate) fn find_symbols_db_for_file(
21123 root: &Path,
21124 file_path: &Path,
21125) -> Result<Option<ExtractSymbolContext>> {
21126 let cfg = config::Config::load(root)?;
21127 let mut submodules = config::Config::submodule_dirs(root)?;
21128 submodules.sort_by(|left, right| {
21129 right
21130 .source_root
21131 .components()
21132 .count()
21133 .cmp(&left.source_root.components().count())
21134 });
21135
21136 for scope in submodules {
21137 if !file_path.starts_with(&scope.source_root) {
21138 continue;
21139 }
21140 let db_path = cfg.db_path_for(root, &scope.id);
21141 if db_path.exists() {
21142 return Ok(Some(ExtractSymbolContext {
21143 db_path,
21144 source_root: scope.source_root,
21145 }));
21146 }
21147 }
21148
21149 let single = root.join(".tsift/index.db");
21150 if single.exists() && file_path.starts_with(root) {
21151 return Ok(Some(ExtractSymbolContext {
21152 db_path: single,
21153 source_root: root.to_path_buf(),
21154 }));
21155 }
21156
21157 Ok(None)
21158}
21159
21160pub(crate) fn resolve_extract_base(path: &Path) -> Result<PathBuf> {
21161 let canonical = path
21162 .canonicalize()
21163 .with_context(|| format!("canonicalizing {}", path.display()))?;
21164
21165 Ok(if canonical.is_dir() {
21166 canonical
21167 } else {
21168 canonical
21169 .parent()
21170 .map(Path::to_path_buf)
21171 .unwrap_or(canonical)
21172 })
21173}
21174
21175fn normalize_extract_scope_path(path: &Path) -> Result<PathBuf> {
21176 if path.exists() {
21177 return path
21178 .canonicalize()
21179 .with_context(|| format!("canonicalizing extract scope {}", path.display()));
21180 }
21181
21182 Ok(summarize::normalize_lexical_path(path))
21183}
21184
21185pub(crate) fn resolve_extract_scope(root: &Path, extract_path: &Path) -> Result<PathBuf> {
21186 let scope = if extract_path.is_absolute() {
21187 extract_path.to_path_buf()
21188 } else {
21189 root.join(extract_path)
21190 };
21191 normalize_extract_scope_path(&scope)
21192}
21193
21194pub(crate) fn summarize_diff_matches_scope(changed_path: &Path, extract_scope: &Path) -> bool {
21195 normalize_extract_scope_path(changed_path)
21196 .unwrap_or_else(|_| summarize::normalize_lexical_path(changed_path))
21197 .starts_with(extract_scope)
21198}
21199
21200pub(crate) fn summarize_relative_file_path(root: &Path, file_path: &Path) -> String {
21201 summarize::normalize_summary_file_key(file_path.strip_prefix(root).unwrap_or(file_path))
21202}
21203
21204pub(crate) fn summarize_full_extract_deleted_summary_paths(
21205 summary_db: &summarize::SummaryDb,
21206 root: &Path,
21207 extract_scope: &Path,
21208 files_to_extract: &[PathBuf],
21209) -> Result<BTreeSet<String>> {
21210 let live_paths = files_to_extract
21211 .iter()
21212 .map(|file_path| summarize_relative_file_path(root, file_path))
21213 .collect::<BTreeSet<_>>();
21214 let mut deleted = BTreeSet::new();
21215
21216 for cached_path in summary_db.cached_file_paths()? {
21217 if !summarize_diff_matches_scope(&root.join(&cached_path), extract_scope) {
21218 continue;
21219 }
21220 if !live_paths.contains(&cached_path) {
21221 deleted.insert(cached_path);
21222 }
21223 }
21224
21225 Ok(deleted)
21226}
21227
21228#[derive(Debug, Clone)]
21229struct SearchIndexTarget {
21230 label: String,
21231 db_path: PathBuf,
21232 source_root: PathBuf,
21233 scope_name: Option<String>,
21234 reindex_cmd: String,
21235}
21236
21237fn cargo_package_index_target(
21238 root: &Path,
21239 package: multiplicity::CargoPackageInfo,
21240) -> SearchIndexTarget {
21241 SearchIndexTarget {
21242 label: format!("cargo package `{}` index", package.scope_id),
21243 db_path: multiplicity::cargo_package_db_path(root, &package.scope_id),
21244 source_root: package.package_root.clone(),
21245 scope_name: Some(package.scope_id.clone()),
21246 reindex_cmd: format!(
21247 "tsift index --submodule {} {}",
21248 package.scope_id,
21249 root.display()
21250 ),
21251 }
21252}
21253
21254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21255enum SearchIndexState {
21256 Missing,
21257 Fresh,
21258 Stale { stale_files: usize },
21259}
21260
21261fn resolve_search_index_targets(
21262 root: &Path,
21263 path_hint: &Path,
21264 scope: Option<&str>,
21265 federated: bool,
21266) -> Result<Vec<SearchIndexTarget>> {
21267 if let Some(scope_name) = scope {
21268 if let Some(scope) = config::Config::find_submodule(root, scope_name)? {
21269 let cfg = config::Config::load(root)?;
21270 return Ok(vec![SearchIndexTarget {
21271 label: format!("submodule `{}` index", scope.id),
21272 db_path: cfg.db_path_for(root, &scope.id),
21273 source_root: scope.source_root.clone(),
21274 scope_name: Some(scope.id.clone()),
21275 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21276 }]);
21277 }
21278 if let Some(package) = multiplicity::find_cargo_package(root, scope_name)? {
21279 return Ok(vec![cargo_package_index_target(root, package)]);
21280 }
21281 config::Config::resolve_submodule(root, scope_name)?;
21282 }
21283
21284 if federated {
21285 let cfg = config::Config::load(root)?;
21286 let mut targets = Vec::new();
21287 for scope in config::Config::submodule_dirs(root)? {
21288 if !cfg.federation_for_scope(&scope) {
21289 continue;
21290 }
21291 targets.push(SearchIndexTarget {
21292 label: format!("submodule `{}` index", scope.id),
21293 db_path: cfg.db_path_for(root, &scope.id),
21294 source_root: scope.source_root.clone(),
21295 scope_name: Some(scope.id.clone()),
21296 reindex_cmd: format!("tsift index --workspace {}", root.display()),
21297 });
21298 }
21299 return Ok(targets);
21300 }
21301
21302 if let Some(scope) = config::Config::infer_submodule_from_path(root, path_hint)? {
21303 let cfg = config::Config::load(root)?;
21304 return Ok(vec![SearchIndexTarget {
21305 label: format!("submodule `{}` index", scope.id),
21306 db_path: cfg.db_path_for(root, &scope.id),
21307 source_root: scope.source_root.clone(),
21308 scope_name: Some(scope.id.clone()),
21309 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21310 }]);
21311 }
21312
21313 if let Some(package) = multiplicity::infer_cargo_package_from_path(root, path_hint)? {
21314 return Ok(vec![cargo_package_index_target(root, package)]);
21315 }
21316
21317 if let Some(scope) = infer_agent_doc_task_submodule(root, path_hint)? {
21318 let cfg = config::Config::load(root)?;
21319 return Ok(vec![SearchIndexTarget {
21320 label: format!("submodule `{}` index", scope.id),
21321 db_path: cfg.db_path_for(root, &scope.id),
21322 source_root: scope.source_root.clone(),
21323 scope_name: Some(scope.id.clone()),
21324 reindex_cmd: format!("tsift index --submodule {} {}", scope.id, root.display()),
21325 }]);
21326 }
21327
21328 let scopes = config::Config::submodule_dirs(root)?;
21329 if !scopes.is_empty() {
21330 let root_db = root.join(".tsift/index.db");
21331 if !root_db.exists() {
21332 let available_scopes = scopes
21333 .iter()
21334 .map(|scope| scope.id.as_str())
21335 .collect::<Vec<_>>()
21336 .join(", ");
21337 let cfg = config::Config::load(root)?;
21338 let indexed_scopes = scopes
21339 .iter()
21340 .filter(|scope| cfg.db_path_for(root, &scope.id).exists())
21341 .map(|scope| scope.id.as_str())
21342 .collect::<Vec<_>>();
21343 let indexed_label = if indexed_scopes.is_empty() {
21344 "none".to_string()
21345 } else {
21346 indexed_scopes.join(", ")
21347 };
21348 bail!(
21349 "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: {}.",
21350 root.display(),
21351 root_db.display(),
21352 available_scopes,
21353 indexed_label,
21354 );
21355 }
21356 }
21357
21358 Ok(vec![SearchIndexTarget {
21359 label: "index".to_string(),
21360 db_path: root.join(".tsift/index.db"),
21361 source_root: root.to_path_buf(),
21362 scope_name: None,
21363 reindex_cmd: format!("tsift index {}", root.display()),
21364 }])
21365}
21366
21367fn inspect_search_index(target: &SearchIndexTarget) -> Result<SearchIndexState> {
21368 if !target.source_root.exists() || !target.db_path.exists() {
21369 return Ok(SearchIndexState::Missing);
21370 }
21371
21372 let inspection =
21373 index::IndexDb::inspect_read_only(&target.db_path, &target.source_root, false)?;
21374 let stale_files =
21375 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted;
21376 if stale_files == 0 {
21377 Ok(SearchIndexState::Fresh)
21378 } else {
21379 Ok(SearchIndexState::Stale { stale_files })
21380 }
21381}
21382
21383#[derive(Debug, Clone, PartialEq, Eq)]
21384struct RebuildSearchTarget {
21385 label: String,
21386 reason: RebuildSearchReason,
21387 reindex_cmd: String,
21388}
21389
21390#[derive(Debug, Clone, PartialEq, Eq)]
21391enum RebuildSearchReason {
21392 Missing,
21393 Stale { stale_files: usize },
21394}
21395
21396#[derive(Debug, Clone, PartialEq, Eq)]
21397struct DegradedSearchTarget {
21398 label: String,
21399 reason: RebuildSearchReason,
21400 reindex_cmd: String,
21401}
21402
21403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21404pub(crate) enum DegradedSearchMode {
21405 ReadOnly,
21406 Exact,
21407}
21408
21409#[derive(Debug)]
21410struct SearchPrecheck {
21411 targets: Vec<SearchIndexTarget>,
21412 degraded_targets: Vec<DegradedSearchTarget>,
21413}
21414
21415fn is_active_writer_lock_error(err: &anyhow::Error) -> bool {
21416 err.chain().any(|cause| {
21417 cause
21418 .to_string()
21419 .contains("another tsift index writer is already active")
21420 })
21421}
21422
21423fn infer_agent_doc_task_submodule(
21424 root: &Path,
21425 path_hint: &Path,
21426) -> Result<Option<config::WorkspaceScope>> {
21427 let hinted_path = if path_hint.is_absolute() {
21428 path_hint.to_path_buf()
21429 } else {
21430 root.join(path_hint)
21431 };
21432 let Ok(relative) = hinted_path.strip_prefix(root) else {
21433 return Ok(None);
21434 };
21435 let mut components = relative.components();
21436 let Some(std::path::Component::Normal(first)) = components.next() else {
21437 return Ok(None);
21438 };
21439 if first != "tasks" {
21440 return Ok(None);
21441 }
21442 let Some(file_stem) = relative.file_stem().and_then(|stem| stem.to_str()) else {
21443 return Ok(None);
21444 };
21445 config::Config::find_submodule(root, file_stem)
21446}
21447
21448fn degraded_search_target(
21449 target: &SearchIndexTarget,
21450 reason: RebuildSearchReason,
21451) -> DegradedSearchTarget {
21452 DegradedSearchTarget {
21453 label: target.label.clone(),
21454 reason,
21455 reindex_cmd: target.reindex_cmd.clone(),
21456 }
21457}
21458
21459fn apply_search_index_update(
21460 root: &Path,
21461 target: &SearchIndexTarget,
21462) -> Result<index::IndexSummary> {
21463 run_index_update(
21464 &target.db_path,
21465 &target.source_root,
21466 format!("autoindexing {}", target.label),
21467 root,
21468 target.scope_name.as_deref(),
21469 false,
21470 false,
21471 )
21472}
21473
21474fn collect_rebuild_search_targets(
21475 targets: &[SearchIndexTarget],
21476) -> Result<Vec<RebuildSearchTarget>> {
21477 let mut rebuild_targets = Vec::new();
21478 for target in targets {
21479 let reason = match inspect_search_index(target)? {
21480 SearchIndexState::Missing => RebuildSearchReason::Missing,
21481 SearchIndexState::Fresh => continue,
21482 SearchIndexState::Stale { stale_files } => RebuildSearchReason::Stale { stale_files },
21483 };
21484 rebuild_targets.push(RebuildSearchTarget {
21485 label: target.label.clone(),
21486 reason,
21487 reindex_cmd: target.reindex_cmd.clone(),
21488 });
21489 }
21490 Ok(rebuild_targets)
21491}
21492
21493fn rebuild_search_target_detail(target: &RebuildSearchTarget) -> String {
21494 match target.reason {
21495 RebuildSearchReason::Missing => format!("{} is missing", target.label),
21496 RebuildSearchReason::Stale { stale_files } => {
21497 let file_suffix = if stale_files == 1 { "" } else { "s" };
21498 format!(
21499 "{} is stale ({} file{})",
21500 target.label, stale_files, file_suffix
21501 )
21502 }
21503 }
21504}
21505
21506fn rebuild_search_targets_message(rebuild_targets: &[RebuildSearchTarget]) -> String {
21507 if rebuild_targets.len() == 1 {
21508 let target = &rebuild_targets[0];
21509 return format!(
21510 "{}. Run `{}` to rebuild before retrying.",
21511 rebuild_search_target_detail(target),
21512 target.reindex_cmd
21513 );
21514 }
21515
21516 let summary: Vec<String> = rebuild_targets
21517 .iter()
21518 .take(3)
21519 .map(rebuild_search_target_detail)
21520 .collect();
21521 let overflow = rebuild_targets.len().saturating_sub(summary.len());
21522 let mut details = summary.join(", ");
21523 if overflow > 0 {
21524 details.push_str(&format!(", +{} more", overflow));
21525 }
21526 let reindex_cmd = rebuild_targets[0].reindex_cmd.clone();
21527 format!(
21528 "{} indexes need rebuild: {}. Run `{}` to rebuild before retrying.",
21529 rebuild_targets.len(),
21530 details,
21531 reindex_cmd
21532 )
21533}
21534
21535pub(crate) fn precheck_search_indexes(
21536 root: &Path,
21537 path_hint: &Path,
21538 scope: Option<&str>,
21539 federated: bool,
21540 autoindex: bool,
21541) -> Result<SearchPrecheck> {
21542 let targets = resolve_search_index_targets(root, path_hint, scope, federated)?;
21543 let mut stale_targets = Vec::new();
21544 let mut degraded_targets = Vec::new();
21545
21546 for target in &targets {
21547 match inspect_search_index(target)? {
21548 SearchIndexState::Missing => {
21549 if autoindex && let Err(err) = apply_search_index_update(root, target) {
21550 if is_active_writer_lock_error(&err) {
21551 degraded_targets
21552 .push(degraded_search_target(target, RebuildSearchReason::Missing));
21553 } else {
21554 return Err(err);
21555 }
21556 }
21557 }
21558 SearchIndexState::Fresh => {}
21559 SearchIndexState::Stale { stale_files } => {
21560 if autoindex {
21561 if let Err(err) = apply_search_index_update(root, target) {
21562 if is_active_writer_lock_error(&err) {
21563 degraded_targets.push(degraded_search_target(
21564 target,
21565 RebuildSearchReason::Stale { stale_files },
21566 ));
21567 } else {
21568 return Err(err);
21569 }
21570 }
21571 } else {
21572 stale_targets.push(RebuildSearchTarget {
21573 label: target.label.clone(),
21574 reason: RebuildSearchReason::Stale { stale_files },
21575 reindex_cmd: target.reindex_cmd.clone(),
21576 });
21577 }
21578 }
21579 }
21580 }
21581
21582 if stale_targets.is_empty() {
21583 return Ok(SearchPrecheck {
21584 targets,
21585 degraded_targets,
21586 });
21587 }
21588
21589 bail!(
21590 "tsift search aborted: {} \
21591 or re-run without `--no-autoindex`.",
21592 rebuild_search_targets_message(&stale_targets),
21593 );
21594}
21595
21596pub(crate) fn degraded_search_mode(targets: &[DegradedSearchTarget]) -> Option<DegradedSearchMode> {
21597 if targets.is_empty() {
21598 return None;
21599 }
21600
21601 if targets
21602 .iter()
21603 .all(|target| matches!(target.reason, RebuildSearchReason::Missing))
21604 {
21605 Some(DegradedSearchMode::Exact)
21606 } else {
21607 Some(DegradedSearchMode::ReadOnly)
21608 }
21609}
21610
21611fn degraded_search_targets_summary(targets: &[DegradedSearchTarget]) -> String {
21612 if targets.len() == 1 {
21613 let target = &targets[0];
21614 return match target.reason {
21615 RebuildSearchReason::Missing => format!("{} is missing", target.label),
21616 RebuildSearchReason::Stale { stale_files } => {
21617 let file_suffix = if stale_files == 1 { "" } else { "s" };
21618 format!(
21619 "{} is stale ({} file{})",
21620 target.label, stale_files, file_suffix
21621 )
21622 }
21623 };
21624 }
21625
21626 let missing = targets
21627 .iter()
21628 .filter(|target| matches!(target.reason, RebuildSearchReason::Missing))
21629 .count();
21630 let stale = targets.len().saturating_sub(missing);
21631 let mut parts = Vec::new();
21632 if stale > 0 {
21633 let suffix = if stale == 1 { "" } else { "es" };
21634 parts.push(format!("{stale} stale index{suffix}"));
21635 }
21636 if missing > 0 {
21637 let suffix = if missing == 1 { "" } else { "es" };
21638 parts.push(format!("{missing} missing index{suffix}"));
21639 }
21640 parts.join(", ")
21641}
21642
21643pub(crate) fn emit_degraded_search_note(
21644 targets: &[DegradedSearchTarget],
21645 mode: DegradedSearchMode,
21646) {
21647 let summary = degraded_search_targets_summary(targets);
21648 let reindex_cmd = &targets[0].reindex_cmd;
21649 match mode {
21650 DegradedSearchMode::ReadOnly => eprintln!(
21651 "note: active tsift writer detected; skipping autoindex because {}. \
21652 Continuing with read-only search and the current index snapshot; symbol hits may lag. \
21653 Retry `{}` after the active writer finishes for fresh index results.",
21654 summary, reindex_cmd
21655 ),
21656 DegradedSearchMode::Exact => eprintln!(
21657 "note: active tsift writer detected; skipping autoindex because {}. \
21658 Continuing with exact live-file search. Retry `{}` after the active writer finishes \
21659 for indexed symbol hits.",
21660 summary, reindex_cmd
21661 ),
21662 }
21663}
21664
21665fn search_timeout_message(
21666 timeout_secs: u64,
21667 strategy: &str,
21668 targets: &[SearchIndexTarget],
21669) -> Result<String> {
21670 let rebuild_targets = collect_rebuild_search_targets(targets)?;
21671 if rebuild_targets.is_empty() {
21672 return Ok(format!(
21673 "tsift search timed out after {}s (strategy: {}). \
21674 The search root looks fresh, so reindexing is unlikely to help. \
21675 Re-run with `--timeout 0` to disable the timeout, narrow `--path` / `--scope`, \
21676 or try a different strategy.",
21677 timeout_secs, strategy,
21678 ));
21679 }
21680
21681 Ok(format!(
21682 "tsift search timed out after {}s (strategy: {}). {}",
21683 timeout_secs,
21684 strategy,
21685 rebuild_search_targets_message(&rebuild_targets),
21686 ))
21687}
21688
21689fn is_exact_preferring_query_char(ch: char) -> bool {
21690 matches!(ch, '-' | '_' | '/' | '\\' | '.' | ':' | '#' | '@')
21691}
21692
21693fn query_prefers_exact_search(query: &str) -> bool {
21694 let trimmed = query.trim();
21695 !trimmed.is_empty()
21696 && !trimmed.chars().any(char::is_whitespace)
21697 && trimmed.chars().any(|ch| ch.is_alphanumeric())
21698 && trimmed.chars().any(is_exact_preferring_query_char)
21699 && trimmed
21700 .chars()
21701 .all(|ch| ch.is_alphanumeric() || is_exact_preferring_query_char(ch))
21702}
21703
21704pub(crate) fn resolve_search_strategy(query: &str, strategy: Option<String>) -> String {
21705 strategy.unwrap_or_else(|| {
21706 if query_prefers_exact_search(query) {
21707 "exact".to_string()
21708 } else {
21709 "lexical".to_string()
21710 }
21711 })
21712}
21713
21714pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<PathBuf>> {
21715 let mut files = Vec::new();
21716 if path.is_file() {
21717 files.push(path.to_path_buf());
21718 return Ok(files);
21719 }
21720 let walker = ignore::WalkBuilder::new(path)
21721 .hidden(true)
21722 .git_ignore(true)
21723 .build();
21724 for entry in walker {
21725 let entry = entry?;
21726 if entry.file_type().is_some_and(|ft| ft.is_file()) {
21727 let p = entry.path();
21728 if let Some(ext) = p.extension() {
21729 let ext = ext.to_string_lossy();
21730 if matches!(
21731 ext.as_ref(),
21732 "rs" | "py"
21733 | "ts"
21734 | "tsx"
21735 | "js"
21736 | "jsx"
21737 | "kt"
21738 | "kts"
21739 | "zig"
21740 | "sh"
21741 | "bash"
21742 | "zsh"
21743 ) {
21744 files.push(p.to_path_buf());
21745 }
21746 }
21747 }
21748 }
21749 Ok(files)
21750}
21751
21752#[cfg(test)]
21753mod tests {
21754 use super::semantic_edit::{
21755 EditOp, apply_edit_op, apply_edit_plan_atomically_inner, markdown_block_spans,
21756 markdown_section_spans,
21757 };
21758 use super::*;
21759 use tsift_memory::{MemoryEventKind, MemoryStore};
21760
21761 use std::cell::RefCell;
21762 use substrate::{ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow};
21763
21764 #[test]
21765 fn graph_db_write_lock_serializes_concurrent_writers() {
21766 let dir = tempfile::tempdir().unwrap();
21767 let graph_db = dir.path().join(".tsift/graph.db");
21768 let short = Duration::from_millis(150);
21769
21770 let first = acquire_graph_db_write_lock_with_timeout(&graph_db, short)
21771 .expect("first writer acquires the lock");
21772 let second = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21775 assert!(
21776 second.is_err(),
21777 "a second writer must not acquire the graph-db write lock while it is held"
21778 );
21779 drop(first);
21780 let third = acquire_graph_db_write_lock_with_timeout(&graph_db, short);
21782 assert!(
21783 third.is_ok(),
21784 "graph-db write lock must be re-acquirable after release"
21785 );
21786 }
21787
21788 #[test]
21794 fn graph_db_compact_apply_blocks_on_held_write_lock() {
21795 let dir = setup_traversal_project();
21796 let session = dir.path().join("tasks/software/tsift.md");
21797 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
21798 let graph_db = graph_substrate_db_path(dir.path(), None);
21799
21800 let held = acquire_graph_db_write_lock(&graph_db).expect("hold writer lock");
21801
21802 let root = dir.path().to_path_buf();
21803 let handle = std::thread::Builder::new()
21804 .name("compact-apply".to_string())
21805 .stack_size(16 * 1024 * 1024)
21806 .spawn(move || {
21807 crate::commands::infra::cmd_graph_db_compact(
21808 &root,
21809 None,
21810 true,
21811 false,
21812 false,
21813 OutputFormat {
21814 json_output: true,
21815 compact: true,
21816 pretty: false,
21817 terse: false,
21818 ultra_terse: false,
21819 schema: false,
21820 envelope: false,
21821 },
21822 )
21823 })
21824 .unwrap();
21825
21826 std::thread::sleep(Duration::from_millis(300));
21830 assert!(
21831 !handle.is_finished(),
21832 "compact --apply must block on the held graph-db write lock, not run unguarded"
21833 );
21834
21835 drop(held);
21836 let result = handle.join().expect("compact thread joins");
21837 assert!(
21838 result.is_ok(),
21839 "compact --apply must succeed after the lock is released: {result:?}"
21840 );
21841 }
21842
21843 fn parse_cli<I, T>(itr: I) -> Cli
21844 where
21845 I: IntoIterator<Item = T> + Send + 'static,
21846 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21847 {
21848 std::thread::Builder::new()
21849 .name("cli-parse".to_string())
21850 .stack_size(16 * 1024 * 1024)
21851 .spawn(move || Cli::parse_from(itr))
21852 .unwrap()
21853 .join()
21854 .unwrap()
21855 }
21856
21857 fn try_parse_cli<I, T>(itr: I) -> std::result::Result<Cli, clap::Error>
21858 where
21859 I: IntoIterator<Item = T> + Send + 'static,
21860 T: Into<std::ffi::OsString> + Clone + Send + 'static,
21861 {
21862 std::thread::Builder::new()
21863 .name("cli-try-parse".to_string())
21864 .stack_size(16 * 1024 * 1024)
21865 .spawn(move || Cli::try_parse_from(itr))
21866 .unwrap()
21867 .join()
21868 .unwrap()
21869 }
21870
21871 fn build_relative_search_budget_report(
21872 query: &str,
21873 strategy: &str,
21874 root: &Path,
21875 response: &sift::SearchResponse,
21876 symbol_hits: &[index::SymbolHit],
21877 budget: ResponseBudget,
21878 filters: &SearchFacetFilters,
21879 ) -> SearchBudgetReport {
21880 build_search_budget_report(SearchBudgetReportInput {
21881 query,
21882 strategy,
21883 root,
21884 response,
21885 symbol_hits,
21886 absolute: false,
21887 budget,
21888 filters,
21889 })
21890 }
21891
21892 #[derive(Default)]
21893 struct MemoryConvexGraphClient {
21894 nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
21895 edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
21896 }
21897
21898 impl ConvexGraphClient for MemoryConvexGraphClient {
21899 fn upsert_node_row(&self, row: &ConvexNodeRow) -> Result<()> {
21900 self.nodes
21901 .borrow_mut()
21902 .insert(row.external_id.clone(), row.clone());
21903 Ok(())
21904 }
21905
21906 fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> Result<()> {
21907 self.edges
21908 .borrow_mut()
21909 .insert(row.edge_key.clone(), row.clone());
21910 Ok(())
21911 }
21912
21913 fn delete_node_row(&self, external_id: &str) -> Result<usize> {
21914 Ok(usize::from(
21915 self.nodes.borrow_mut().remove(external_id).is_some(),
21916 ))
21917 }
21918
21919 fn delete_edge_row(&self, edge_key: &str) -> Result<usize> {
21920 Ok(usize::from(
21921 self.edges.borrow_mut().remove(edge_key).is_some(),
21922 ))
21923 }
21924
21925 fn node_row(&self, external_id: &str) -> Result<Option<ConvexNodeRow>> {
21926 Ok(self.nodes.borrow().get(external_id).cloned())
21927 }
21928
21929 fn node_rows(&self) -> Result<Vec<ConvexNodeRow>> {
21930 Ok(self.nodes.borrow().values().cloned().collect())
21931 }
21932
21933 fn edge_rows(&self) -> Result<Vec<ConvexEdgeRow>> {
21934 Ok(self.edges.borrow().values().cloned().collect())
21935 }
21936
21937 fn node_rows_by_kind(&self, kind: &str) -> Result<Vec<ConvexNodeRow>> {
21938 Ok(self
21939 .nodes
21940 .borrow()
21941 .values()
21942 .filter(|row| row.kind == kind)
21943 .cloned()
21944 .collect())
21945 }
21946
21947 fn outgoing_edge_rows(
21948 &self,
21949 from_external_id: &str,
21950 kind: Option<&str>,
21951 ) -> Result<Vec<ConvexEdgeRow>> {
21952 Ok(self
21953 .edges
21954 .borrow()
21955 .values()
21956 .filter(|row| row.from_external_id == from_external_id)
21957 .filter(|row| kind.is_none_or(|kind| row.kind == kind))
21958 .cloned()
21959 .collect())
21960 }
21961 }
21962
21963 fn init_git_repo(path: &Path) {
21964 let status = std::process::Command::new("git")
21965 .args(["init"])
21966 .current_dir(path)
21967 .status()
21968 .unwrap();
21969 assert!(status.success(), "git init failed");
21970
21971 let status = std::process::Command::new("git")
21972 .args(["add", "."])
21973 .current_dir(path)
21974 .status()
21975 .unwrap();
21976 assert!(status.success(), "git add failed");
21977
21978 let status = std::process::Command::new("git")
21979 .args([
21980 "-c",
21981 "user.name=tsift-tests",
21982 "-c",
21983 "user.email=tsift-tests@example.com",
21984 "commit",
21985 "--quiet",
21986 "-m",
21987 "init",
21988 ])
21989 .current_dir(path)
21990 .status()
21991 .unwrap();
21992 assert!(status.success(), "git commit failed");
21993 }
21994
21995 fn write_empty_root_index(root: &Path) {
21996 let index_dir = root.join(".tsift");
21997 fs::create_dir_all(&index_dir).unwrap();
21998 fs::write(index_dir.join("index.db"), "").unwrap();
21999 }
22000
22001 fn write_repeated_lines(path: &Path, line: &str, lines: usize) -> PathBuf {
22002 if let Some(parent) = path.parent() {
22003 fs::create_dir_all(parent).unwrap();
22004 }
22005 let body = std::iter::repeat_n(line, lines)
22006 .collect::<Vec<_>>()
22007 .join("\n");
22008 fs::write(path, format!("{body}\n")).unwrap();
22009 path.to_path_buf()
22010 }
22011
22012 #[test]
22015 fn token_capped_preview_returns_all_lines_when_under_cap() {
22016 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 1", "}"];
22017 let result = build_token_capped_preview(&lines, 1, 3, 160, 1000);
22018 assert!(!result.was_capped);
22019 assert_eq!(result.preview.len(), 3);
22020 assert_eq!(result.capped_end, 3);
22021 }
22022
22023 #[test]
22024 fn token_capped_preview_truncates_when_over_cap() {
22025 let lines: Vec<&str> = (0..200)
22026 .map(|_| " let x = some_very_long_expression_here();")
22027 .collect();
22028 let result = build_token_capped_preview(&lines, 1, 200, 160, 100);
22029 assert!(result.was_capped);
22030 assert!(result.preview.len() < 200);
22031 assert!(result.capped_end < 200);
22032 }
22033
22034 #[test]
22035 fn token_capped_preview_keeps_at_least_one_line() {
22036 let long_line: String = "x".repeat(8000);
22037 let lines: Vec<&str> = vec![&long_line];
22038 let result = build_token_capped_preview(&lines, 1, 1, 160, 10);
22039 assert!(!result.was_capped);
22040 assert_eq!(result.preview.len(), 1);
22041 }
22042
22043 #[test]
22044 fn token_capped_preview_cap_at_boundary() {
22045 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22046 let result = build_token_capped_preview(&lines, 1, 4, 160, 4);
22047 assert!(!result.was_capped);
22048 assert_eq!(result.preview.len(), 4);
22049 }
22050
22051 #[test]
22052 fn token_capped_preview_cap_just_over_boundary() {
22053 let lines: Vec<&str> = vec!["aaaa", "bbbb", "cccc", "dddd"];
22054 let result = build_token_capped_preview(&lines, 1, 4, 160, 3);
22055 assert!(result.was_capped);
22056 assert_eq!(result.preview.len(), 3);
22057 assert_eq!(result.capped_end, 3);
22058 }
22059
22060 #[test]
22061 fn token_capped_preview_empty_lines() {
22062 let lines: Vec<&str> = vec![];
22063 let result = build_token_capped_preview(&lines, 1, 0, 160, 100);
22064 assert!(!result.was_capped);
22065 assert!(result.preview.is_empty());
22066 }
22067
22068 #[test]
22069 fn token_capped_preview_per_line_truncation_applied() {
22070 let long_line = "x".repeat(500);
22071 let lines: Vec<&str> = vec![&long_line, "short"];
22072 let result = build_token_capped_preview(&lines, 1, 2, 20, 10000);
22073 assert!(!result.was_capped);
22074 assert_eq!(result.preview.len(), 2);
22075 assert!(result.preview[0].text.len() <= 23);
22076 assert!(result.preview[0].text.ends_with("..."));
22077 }
22078
22079 #[test]
22082 fn route_search_defaults_to_haiku() {
22083 let (tier, model) = classify_task("find all uses of authenticate");
22084 assert_eq!(tier, "haiku");
22085 assert!(
22086 model.contains("haiku"),
22087 "expected haiku model, got {}",
22088 model
22089 );
22090 }
22091
22092 #[test]
22093 fn route_edit_keywords_to_sonnet() {
22094 for kw in &[
22095 "edit the file",
22096 "fix the bug",
22097 "update the config",
22098 "remove dead code",
22099 "create a new module",
22100 ] {
22101 let (tier, _) = classify_task(kw);
22102 assert_eq!(tier, "sonnet", "expected sonnet for {:?}", kw);
22103 }
22104 }
22105
22106 #[test]
22107 fn route_architecture_keywords_to_opus() {
22108 for kw in &[
22109 "design the API",
22110 "architecture review",
22111 "plan the migration",
22112 "analyze the system",
22113 "evaluate trade-offs",
22114 ] {
22115 let (tier, _) = classify_task(kw);
22116 assert_eq!(tier, "opus", "expected opus for {:?}", kw);
22117 }
22118 }
22119
22120 #[test]
22121 fn route_architecture_beats_edit() {
22122 let (tier, _) = classify_task("design and implement the new auth service");
22124 assert_eq!(tier, "opus");
22125 }
22126
22127 #[test]
22128 fn cli_accepts_global_compact_flag() {
22129 let cli = parse_cli(["tsift", "--compact", "status"]);
22130 assert!(cli.compact);
22131 assert!(matches!(cli.command, Some(Commands::Status { .. })));
22132 }
22133
22134 #[test]
22135 fn summarize_diff_scope_matches_relative_directory() {
22136 let root = Path::new("/repo");
22137 let extract_scope = resolve_extract_scope(root, Path::new("src/feature")).unwrap();
22138
22139 assert!(summarize_diff_matches_scope(
22140 Path::new("/repo/src/feature/main.rs"),
22141 &extract_scope
22142 ));
22143 assert!(!summarize_diff_matches_scope(
22144 Path::new("/repo/src/other/main.rs"),
22145 &extract_scope
22146 ));
22147 }
22148
22149 #[test]
22150 fn summarize_diff_scope_matches_relative_file() {
22151 let root = Path::new("/repo");
22152 let extract_scope = resolve_extract_scope(root, Path::new("src/feature/main.rs")).unwrap();
22153
22154 assert!(summarize_diff_matches_scope(
22155 Path::new("/repo/src/feature/main.rs"),
22156 &extract_scope
22157 ));
22158 assert!(!summarize_diff_matches_scope(
22159 Path::new("/repo/src/feature/lib.rs"),
22160 &extract_scope
22161 ));
22162 }
22163
22164 #[test]
22165 fn summarize_extract_scope_walks_relative_paths_from_root() {
22166 let dir = tempfile::tempdir().unwrap();
22167 let source_dir = dir.path().join("src");
22168 std::fs::create_dir_all(&source_dir).unwrap();
22169 let main_rs = source_dir.join("main.rs");
22170 std::fs::write(&main_rs, "fn alpha() {}\n").unwrap();
22171
22172 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src")).unwrap();
22173 let files = collect_source_files(&extract_scope).unwrap();
22174
22175 assert_eq!(files, vec![main_rs]);
22176 }
22177
22178 #[test]
22179 fn summarize_extract_base_uses_nested_path_instead_of_project_root() {
22180 let dir = tempfile::tempdir().unwrap();
22181 let nested = dir.path().join("src/nested");
22182 std::fs::create_dir_all(&nested).unwrap();
22183 std::fs::write(dir.path().join("root.rs"), "fn root_level() {}\n").unwrap();
22184 let nested_file = nested.join("main.rs");
22185 std::fs::write(&nested_file, "fn nested_only() {}\n").unwrap();
22186
22187 let extract_base = resolve_extract_base(&nested).unwrap();
22188 let extract_scope = resolve_extract_scope(&extract_base, Path::new(".")).unwrap();
22189 let files = collect_source_files(&extract_scope).unwrap();
22190
22191 assert_eq!(extract_scope, nested);
22192 assert_eq!(files, vec![nested_file]);
22193 }
22194
22195 #[test]
22196 fn summarize_extract_base_uses_parent_of_file_path() {
22197 let dir = tempfile::tempdir().unwrap();
22198 let nested = dir.path().join("src/nested");
22199 std::fs::create_dir_all(&nested).unwrap();
22200 let file_path = nested.join("main.rs");
22201 std::fs::write(&file_path, "fn nested_only() {}\n").unwrap();
22202
22203 let extract_base = resolve_extract_base(&file_path).unwrap();
22204
22205 assert_eq!(extract_base, nested);
22206 }
22207
22208 #[test]
22209 fn summarize_extract_scope_normalizes_dotdot_segments() {
22210 let dir = tempfile::tempdir().unwrap();
22211 let source_dir = dir.path().join("src");
22212 std::fs::create_dir_all(&source_dir).unwrap();
22213
22214 let extract_scope = resolve_extract_scope(dir.path(), Path::new("src/../src")).unwrap();
22215
22216 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22217 assert!(summarize_diff_matches_scope(
22218 &source_dir.join("main.rs"),
22219 &extract_scope
22220 ));
22221 }
22222
22223 #[cfg(unix)]
22224 #[test]
22225 fn summarize_extract_scope_canonicalizes_absolute_symlink_paths() {
22226 use std::os::unix::fs::symlink;
22227
22228 let dir = tempfile::tempdir().unwrap();
22229 let real_root = dir.path().join("real");
22230 let source_dir = real_root.join("src");
22231 std::fs::create_dir_all(&source_dir).unwrap();
22232 let symlink_scope = dir.path().join("scope-link");
22233 symlink(&source_dir, &symlink_scope).unwrap();
22234
22235 let extract_scope = resolve_extract_scope(&real_root, &symlink_scope).unwrap();
22236
22237 assert_eq!(extract_scope, source_dir.canonicalize().unwrap());
22238 assert!(summarize_diff_matches_scope(
22239 &source_dir.join("lib.rs"),
22240 &extract_scope
22241 ));
22242 }
22243
22244 #[test]
22245 fn summarize_diff_extract_includes_untracked_files() {
22246 let dir = tempfile::tempdir().unwrap();
22247 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22248 init_git_repo(dir.path());
22249
22250 let source_dir = dir.path().join("src");
22251 std::fs::create_dir_all(&source_dir).unwrap();
22252 let new_file = source_dir.join("new.rs");
22253 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22254
22255 let files = summarize::git_changed_files(dir.path()).unwrap();
22256
22257 assert_eq!(files.existing, vec![new_file]);
22258 assert!(files.deleted.is_empty());
22259 }
22260
22261 #[test]
22262 fn summarize_diff_extract_treats_unborn_head_as_untracked_only() {
22263 let dir = tempfile::tempdir().unwrap();
22264 let status = std::process::Command::new("git")
22265 .args(["init"])
22266 .current_dir(dir.path())
22267 .status()
22268 .unwrap();
22269 assert!(status.success(), "git init failed");
22270
22271 let source_dir = dir.path().join("src");
22272 std::fs::create_dir_all(&source_dir).unwrap();
22273 let new_file = source_dir.join("new.rs");
22274 std::fs::write(&new_file, "fn alpha_helper() {}\n").unwrap();
22275
22276 let files = summarize::git_changed_files(dir.path()).unwrap();
22277
22278 assert_eq!(files.existing, vec![new_file]);
22279 assert!(files.deleted.is_empty());
22280 }
22281
22282 #[test]
22283 fn summarize_diff_extract_tracks_deleted_files() {
22284 let dir = tempfile::tempdir().unwrap();
22285 let source_dir = dir.path().join("src");
22286 std::fs::create_dir_all(&source_dir).unwrap();
22287 let deleted_file = source_dir.join("gone.rs");
22288 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22289 init_git_repo(dir.path());
22290
22291 std::fs::remove_file(&deleted_file).unwrap();
22292
22293 let files = summarize::git_changed_files(dir.path()).unwrap();
22294
22295 assert!(files.existing.is_empty());
22296 assert_eq!(files.deleted, vec![deleted_file]);
22297 }
22298
22299 #[test]
22300 fn summarize_diff_extract_tracks_git_renames() {
22301 let dir = tempfile::tempdir().unwrap();
22302 let source_dir = dir.path().join("src");
22303 std::fs::create_dir_all(&source_dir).unwrap();
22304 let old_file = source_dir.join("old.rs");
22305 let new_file = source_dir.join("new.rs");
22306 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22307 init_git_repo(dir.path());
22308
22309 let status = std::process::Command::new("git")
22310 .args(["mv", "src/old.rs", "src/new.rs"])
22311 .current_dir(dir.path())
22312 .status()
22313 .unwrap();
22314 assert!(status.success(), "git mv failed");
22315
22316 let files = summarize::git_changed_files(dir.path()).unwrap();
22317
22318 assert_eq!(files.existing, vec![new_file]);
22319 assert_eq!(files.deleted, vec![old_file]);
22320 }
22321
22322 #[test]
22323 fn summarize_diff_extract_deletes_removed_summary_rows() {
22324 let dir = tempfile::tempdir().unwrap();
22325 let source_dir = dir.path().join("src");
22326 std::fs::create_dir_all(&source_dir).unwrap();
22327 let deleted_file = source_dir.join("gone.rs");
22328 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22329 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22330 init_git_repo(dir.path());
22331
22332 let summary_db =
22333 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22334 summary_db
22335 .insert(&summarize::Summary {
22336 id: 0,
22337 symbol_name: "stale".to_string(),
22338 file_path: "src/gone.rs".to_string(),
22339 content_hash: "hash1".to_string(),
22340 summary: "stale summary".to_string(),
22341 entities: None,
22342 relationships: None,
22343 concept_labels: None,
22344 extracted_at: "1700000000".to_string(),
22345 model: "test".to_string(),
22346 tokens_input: Some(100),
22347 tokens_output: Some(50),
22348 })
22349 .unwrap();
22350
22351 std::fs::remove_file(&deleted_file).unwrap();
22352
22353 cmd_summarize(
22354 None,
22355 None,
22356 Some(PathBuf::from("src")),
22357 true,
22358 false,
22359 dir.path(),
22360 false,
22361 true,
22362 false,
22363 false,
22364 false,
22365 None,
22366 )
22367 .unwrap();
22368
22369 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22370 }
22371
22372 #[test]
22373 fn summarize_diff_extract_deletes_renamed_summary_rows() {
22374 let dir = tempfile::tempdir().unwrap();
22375 let source_dir = dir.path().join("src");
22376 std::fs::create_dir_all(&source_dir).unwrap();
22377 let old_file = source_dir.join("old.rs");
22378 std::fs::write(&old_file, "fn stale() {}\n").unwrap();
22379 std::fs::write(dir.path().join("README.md"), "# repo\n").unwrap();
22380 init_git_repo(dir.path());
22381
22382 let summary_db =
22383 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22384 summary_db
22385 .insert(&summarize::Summary {
22386 id: 0,
22387 symbol_name: "stale".to_string(),
22388 file_path: "src/old.rs".to_string(),
22389 content_hash: "hash1".to_string(),
22390 summary: "stale summary".to_string(),
22391 entities: None,
22392 relationships: None,
22393 concept_labels: None,
22394 extracted_at: "1700000000".to_string(),
22395 model: "test".to_string(),
22396 tokens_input: Some(100),
22397 tokens_output: Some(50),
22398 })
22399 .unwrap();
22400
22401 let status = std::process::Command::new("git")
22402 .args(["mv", "src/old.rs", "src/new.rs"])
22403 .current_dir(dir.path())
22404 .status()
22405 .unwrap();
22406 assert!(status.success(), "git mv failed");
22407
22408 cmd_summarize(
22409 None,
22410 None,
22411 Some(PathBuf::from("src")),
22412 true,
22413 false,
22414 dir.path(),
22415 false,
22416 true,
22417 false,
22418 false,
22419 false,
22420 None,
22421 )
22422 .unwrap();
22423
22424 assert!(summary_db.get_by_file("src/old.rs").unwrap().is_empty());
22425 }
22426
22427 #[test]
22428 fn summarize_full_extract_deletes_removed_summary_rows_when_scope_is_empty() {
22429 let dir = tempfile::tempdir().unwrap();
22430 let source_dir = dir.path().join("src");
22431 std::fs::create_dir_all(&source_dir).unwrap();
22432 let deleted_file = source_dir.join("gone.rs");
22433 std::fs::write(&deleted_file, "fn stale() {}\n").unwrap();
22434
22435 let summary_db =
22436 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22437 summary_db
22438 .insert(&summarize::Summary {
22439 id: 0,
22440 symbol_name: "stale".to_string(),
22441 file_path: "src/gone.rs".to_string(),
22442 content_hash: "hash1".to_string(),
22443 summary: "stale summary".to_string(),
22444 entities: None,
22445 relationships: None,
22446 concept_labels: None,
22447 extracted_at: "1700000000".to_string(),
22448 model: "test".to_string(),
22449 tokens_input: Some(100),
22450 tokens_output: Some(50),
22451 })
22452 .unwrap();
22453
22454 std::fs::remove_file(&deleted_file).unwrap();
22455
22456 cmd_summarize(
22457 None,
22458 None,
22459 Some(PathBuf::from("src")),
22460 false,
22461 false,
22462 dir.path(),
22463 false,
22464 true,
22465 false,
22466 false,
22467 false,
22468 None,
22469 )
22470 .unwrap();
22471
22472 assert!(summary_db.get_by_file("src/gone.rs").unwrap().is_empty());
22473 }
22474
22475 #[test]
22476 fn summarize_extract_fails_fast_when_summary_writer_lock_is_live() {
22477 let dir = tempfile::tempdir().unwrap();
22478 let source_dir = dir.path().join("src");
22479 std::fs::create_dir_all(&source_dir).unwrap();
22480 let file = source_dir.join("lib.rs");
22481 std::fs::write(&file, "fn helper() {}\n").unwrap();
22482
22483 let content = std::fs::read(&file).unwrap();
22484 let summary_db =
22485 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22486 summary_db
22487 .insert(&summarize::Summary {
22488 id: 0,
22489 symbol_name: "lib.rs".to_string(),
22490 file_path: "src/lib.rs".to_string(),
22491 content_hash: summarize::content_hash(&content),
22492 summary: "cached summary".to_string(),
22493 entities: None,
22494 relationships: None,
22495 concept_labels: None,
22496 extracted_at: "1700000000".to_string(),
22497 model: "test".to_string(),
22498 tokens_input: Some(100),
22499 tokens_output: Some(50),
22500 })
22501 .unwrap();
22502 drop(summary_db);
22503
22504 let lock_path = summarize::writer_lock_path(&dir.path().join(".tsift/summaries.db"));
22505 let _lock = hold_writer_lock(&lock_path);
22506
22507 let err = cmd_summarize(
22508 None,
22509 None,
22510 Some(PathBuf::from("src")),
22511 false,
22512 false,
22513 dir.path(),
22514 false,
22515 true,
22516 false,
22517 false,
22518 false,
22519 None,
22520 )
22521 .unwrap_err();
22522 let message = err.to_string();
22523
22524 assert!(message.contains("another tsift summarize extractor is already active"));
22525 assert!(message.contains("tsift summarize --extract"));
22526 }
22527
22528 #[test]
22529 fn summarize_stats_fails_closed_when_cache_missing() {
22530 let dir = tempfile::tempdir().unwrap();
22531 let err = cmd_summarize(
22532 None,
22533 None,
22534 None,
22535 false,
22536 true,
22537 dir.path(),
22538 false,
22539 false,
22540 false,
22541 false,
22542 false,
22543 None,
22544 )
22545 .unwrap_err();
22546
22547 assert!(
22548 err.to_string().contains("no summaries.db found"),
22549 "got: {err}"
22550 );
22551 assert!(!dir.path().join(".tsift/summaries.db").exists());
22552 }
22553
22554 #[test]
22555 fn summarize_stats_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22556 let dir = tempfile::tempdir().unwrap();
22557 let summary_db =
22558 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22559 summary_db
22560 .insert(&summarize::Summary {
22561 id: 0,
22562 symbol_name: "alpha_helper".to_string(),
22563 file_path: "src/lib.rs".to_string(),
22564 content_hash: "hash1".to_string(),
22565 summary: "cached summary".to_string(),
22566 entities: None,
22567 relationships: None,
22568 concept_labels: None,
22569 extracted_at: "1700000000".to_string(),
22570 model: "claude-haiku-4-5-20251001".to_string(),
22571 tokens_input: Some(100),
22572 tokens_output: Some(40),
22573 })
22574 .unwrap();
22575 drop(summary_db);
22576 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22577
22578 let result = cmd_summarize(
22579 None,
22580 None,
22581 None,
22582 false,
22583 true,
22584 dir.path(),
22585 false,
22586 false,
22587 false,
22588 false,
22589 false,
22590 None,
22591 );
22592
22593 assert!(result.is_ok());
22594 }
22595
22596 #[test]
22597 fn summarize_symbol_query_uses_snapshot_fallback_when_rollback_journal_is_locked() {
22598 let dir = tempfile::tempdir().unwrap();
22599 let summary_db =
22600 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22601 summary_db
22602 .insert(&summarize::Summary {
22603 id: 0,
22604 symbol_name: "alpha_helper".to_string(),
22605 file_path: "src/lib.rs".to_string(),
22606 content_hash: "hash1".to_string(),
22607 summary: "cached summary".to_string(),
22608 entities: None,
22609 relationships: None,
22610 concept_labels: None,
22611 extracted_at: "1700000000".to_string(),
22612 model: "claude-haiku-4-5-20251001".to_string(),
22613 tokens_input: Some(100),
22614 tokens_output: Some(40),
22615 })
22616 .unwrap();
22617 drop(summary_db);
22618 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/summaries.db"));
22619
22620 let result = cmd_summarize(
22621 Some("alpha_helper".to_string()),
22622 None,
22623 None,
22624 false,
22625 false,
22626 dir.path(),
22627 false,
22628 true,
22629 false,
22630 false,
22631 false,
22632 None,
22633 );
22634
22635 assert!(result.is_ok());
22636 }
22637
22638 #[test]
22639 fn summarize_cmd_uses_ancestor_project_root_for_nested_paths() {
22640 let dir = tempfile::tempdir().unwrap();
22641 let nested = dir.path().join("src/nested");
22642 std::fs::create_dir_all(&nested).unwrap();
22643
22644 let summary_db =
22645 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
22646 summary_db
22647 .insert(&summarize::Summary {
22648 id: 0,
22649 symbol_name: "alpha_helper".to_string(),
22650 file_path: "src/lib.rs".to_string(),
22651 content_hash: "hash1".to_string(),
22652 summary: "cached summary".to_string(),
22653 entities: None,
22654 relationships: None,
22655 concept_labels: None,
22656 extracted_at: "1700000000".to_string(),
22657 model: "claude-haiku-4-5-20251001".to_string(),
22658 tokens_input: Some(100),
22659 tokens_output: Some(40),
22660 })
22661 .unwrap();
22662
22663 let result = cmd_summarize(
22664 Some("alpha_helper".to_string()),
22665 None,
22666 None,
22667 false,
22668 false,
22669 &nested,
22670 false,
22671 true,
22672 false,
22673 false,
22674 false,
22675 None,
22676 );
22677
22678 assert!(result.is_ok());
22679 assert!(!nested.join(".tsift/summaries.db").exists());
22680 }
22681
22682 #[test]
22683 fn summarize_extract_uses_matching_scoped_index_for_workspace_file() {
22684 let dir = tempfile::tempdir().unwrap();
22685 std::fs::write(
22686 dir.path().join(".gitmodules"),
22687 r#"[submodule "src/alpha"]
22688 path = src/alpha
22689 url = https://example.com/alpha
22690[submodule "src/beta"]
22691 path = src/beta
22692 url = https://example.com/beta
22693"#,
22694 )
22695 .unwrap();
22696
22697 let alpha_root = dir.path().join("src/alpha");
22698 let beta_root = dir.path().join("src/beta");
22699 std::fs::create_dir_all(alpha_root.join("src")).unwrap();
22700 std::fs::create_dir_all(beta_root.join("src")).unwrap();
22701 std::fs::create_dir_all(dir.path().join(".tsift/indexes/alpha")).unwrap();
22702 std::fs::create_dir_all(dir.path().join(".tsift/indexes/beta")).unwrap();
22703 std::fs::write(alpha_root.join("src/lib.rs"), "fn alpha_helper() {}\n").unwrap();
22704 let beta_file = beta_root.join("src/lib.rs");
22705 std::fs::write(&beta_file, "fn beta_helper() {}\n").unwrap();
22706 std::fs::write(dir.path().join(".tsift/indexes/alpha/index.db"), "").unwrap();
22707 std::fs::write(dir.path().join(".tsift/indexes/beta/index.db"), "").unwrap();
22708
22709 let context = find_symbols_db_for_file(dir.path(), &beta_file)
22710 .unwrap()
22711 .expect("expected matching scoped index");
22712
22713 assert_eq!(
22714 context.db_path,
22715 dir.path().join(".tsift/indexes/beta/index.db")
22716 );
22717 assert_eq!(context.source_root, beta_root);
22718 }
22719
22720 fn make_op(old: &str, new: &str, replace_all: bool) -> EditOp {
22723 EditOp {
22724 file: PathBuf::from("dummy.txt"),
22725 old: old.to_string(),
22726 new: new.to_string(),
22727 replace_all,
22728 }
22729 }
22730
22731 #[test]
22732 fn edit_replaces_single_occurrence() {
22733 let content = "hello world";
22734 let op = make_op("world", "rust", false);
22735 let (result, count) = apply_edit_op(content, &op).unwrap();
22736 assert_eq!(result, "hello rust");
22737 assert_eq!(count, 1);
22738 }
22739
22740 #[test]
22741 fn edit_replace_all_replaces_every_occurrence() {
22742 let content = "foo foo foo";
22743 let op = make_op("foo", "bar", true);
22744 let (result, count) = apply_edit_op(content, &op).unwrap();
22745 assert_eq!(result, "bar bar bar");
22746 assert_eq!(count, 3);
22747 }
22748
22749 #[test]
22750 fn edit_fails_when_old_not_found() {
22751 let content = "hello world";
22752 let op = make_op("missing", "x", false);
22753 assert!(apply_edit_op(content, &op).is_err());
22754 }
22755
22756 #[test]
22757 fn edit_fails_when_ambiguous_without_replace_all() {
22758 let content = "foo foo";
22759 let op = make_op("foo", "bar", false);
22760 let err = apply_edit_op(content, &op).unwrap_err();
22761 assert!(err.to_string().contains("2 times"), "got: {}", err);
22762 }
22763
22764 #[test]
22765 fn edit_fails_when_old_equals_new() {
22766 let content = "hello";
22767 let op = make_op("hello", "hello", false);
22768 assert!(apply_edit_op(content, &op).is_err());
22769 }
22770
22771 #[test]
22772 fn edit_batch_rolls_back_when_later_swap_fails() {
22773 let dir = tempfile::tempdir().unwrap();
22774 let alpha = dir.path().join("alpha.txt");
22775 let beta = dir.path().join("beta.txt");
22776 fs::write(&alpha, "alpha old\n").unwrap();
22777 fs::write(&beta, "beta old\n").unwrap();
22778
22779 let batch = EditBatch {
22780 edits: vec![
22781 EditOp {
22782 file: alpha.clone(),
22783 old: "old".to_string(),
22784 new: "new".to_string(),
22785 replace_all: false,
22786 },
22787 EditOp {
22788 file: beta.clone(),
22789 old: "old".to_string(),
22790 new: "new".to_string(),
22791 replace_all: false,
22792 },
22793 ],
22794 };
22795
22796 let plan = build_edit_plan(&batch).unwrap();
22797 let err = match apply_edit_plan_atomically_inner(plan, |commit_index, _| {
22798 if commit_index == 1 {
22799 bail!("simulated swap failure");
22800 }
22801 Ok(())
22802 }) {
22803 Ok(_) => panic!("expected simulated swap failure"),
22804 Err(err) => err,
22805 };
22806
22807 assert!(err.to_string().contains("simulated swap failure"));
22808 assert_eq!(fs::read_to_string(&alpha).unwrap(), "alpha old\n");
22809 assert_eq!(fs::read_to_string(&beta).unwrap(), "beta old\n");
22810 }
22811
22812 fn setup_test_db() -> (tempfile::NamedTempFile, Connection) {
22815 let tmp = tempfile::NamedTempFile::new().unwrap();
22816 let conn = Connection::open(tmp.path()).unwrap();
22817 conn.execute_batch(
22818 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);
22819 INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
22820 INSERT INTO users VALUES (2, 'Bob', NULL);
22821 CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT,
22822 FOREIGN KEY(user_id) REFERENCES users(id));
22823 INSERT INTO posts VALUES (1, 1, 'Hello World', 'First post');
22824 INSERT INTO posts VALUES (2, 1, 'Second', NULL);
22825 INSERT INTO posts VALUES (3, 2, 'Bob post', 'Content here');"
22826 ).unwrap();
22827 (tmp, conn)
22828 }
22829
22830 #[test]
22833 fn rewrite_rg_simple_pattern() {
22834 let result = rewrite_command("rg authenticate");
22835 assert_eq!(
22836 result,
22837 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string(),)
22838 );
22839 }
22840
22841 #[test]
22842 fn rewrite_rg_with_path() {
22843 let result = rewrite_command("rg authenticate src/");
22844 assert_eq!(
22845 result,
22846 Some(
22847 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22848 .to_string()
22849 )
22850 );
22851 }
22852
22853 #[test]
22854 fn rewrite_rg_with_flags_ignored() {
22855 let result = rewrite_command("rg -i authenticate src/");
22856 assert_eq!(
22857 result,
22858 Some(
22859 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22860 .to_string()
22861 )
22862 );
22863 }
22864
22865 #[test]
22866 fn rewrite_rg_with_type_flag() {
22867 let result = rewrite_command("rg -t rs authenticate");
22869 assert_eq!(
22870 result,
22871 Some("tsift --envelope search \"authenticate\" --exact --budget normal".to_string())
22872 );
22873 }
22874
22875 #[test]
22876 fn rewrite_rg_pipe_passthrough() {
22877 let result = rewrite_command("rg authenticate | head -5");
22879 assert_eq!(result, None);
22880 }
22881
22882 #[test]
22883 fn rewrite_rg_files_passthrough() {
22884 let result = rewrite_command("rg --files src/tsift .agent-doc logs");
22885 assert_eq!(result, None);
22886 }
22887
22888 #[test]
22889 fn rewrite_find_passthrough() {
22890 let result = rewrite_command("find src/tsift .agent-doc -type f -name '*.rs'");
22891 assert_eq!(result, None);
22892 }
22893
22894 #[test]
22895 fn rewrite_grep_recursive() {
22896 let result = rewrite_command("grep -r authenticate src/");
22897 assert_eq!(
22898 result,
22899 Some(
22900 "tsift --envelope search \"authenticate\" --exact --budget normal --path \"src/\""
22901 .to_string()
22902 )
22903 );
22904 }
22905
22906 #[test]
22907 fn rewrite_grep_non_recursive_passthrough() {
22908 let result = rewrite_command("grep authenticate file.txt");
22909 assert_eq!(result, None);
22910 }
22911
22912 #[test]
22913 fn rewrite_tsift_passthrough() {
22914 let result = rewrite_command("tsift search \"foo\"");
22915 assert_eq!(result, Some("tsift search \"foo\"".to_string()));
22916 }
22917
22918 #[test]
22919 fn rewrite_run_tsift_search_disables_timeout_by_default() {
22920 let result = effective_rewrite_run_command("tsift search hookcaps --exact --path /tmp/x");
22921 assert_eq!(
22922 result,
22923 "tsift search hookcaps --exact --path /tmp/x --timeout 0"
22924 );
22925 }
22926
22927 #[test]
22928 fn rewrite_run_preserves_explicit_search_timeout() {
22929 let result = effective_rewrite_run_command(
22930 "tsift search hookcaps --exact --path /tmp/x --timeout 5",
22931 );
22932 assert_eq!(
22933 result,
22934 "tsift search hookcaps --exact --path /tmp/x --timeout 5"
22935 );
22936 }
22937
22938 #[test]
22939 fn rewrite_unrelated_passthrough() {
22940 let result = rewrite_command("echo cargo build");
22941 assert_eq!(result, None);
22942 }
22943
22944 #[test]
22945 fn rewrite_rg_quoted_pattern() {
22946 let result = rewrite_command("rg \"fn main\"");
22947 assert_eq!(
22948 result,
22949 Some("tsift --envelope search \"fn main\" --exact --budget normal".to_string())
22950 );
22951 }
22952
22953 #[test]
22954 fn rewrite_git_diff_to_diff_digest() {
22955 let result = rewrite_command("git diff");
22956 assert_eq!(result, Some("tsift diff-digest .".to_string()));
22957 }
22958
22959 #[test]
22960 fn rewrite_git_diff_cached_to_diff_digest() {
22961 let result = rewrite_command("git diff --cached");
22962 assert_eq!(result, Some("tsift diff-digest --cached .".to_string()));
22963 }
22964
22965 #[test]
22966 fn rewrite_git_diff_with_path_to_diff_digest() {
22967 let result = rewrite_command("git diff -- src/");
22968 assert_eq!(result, Some("tsift diff-digest \"src/\"".to_string()));
22969 }
22970
22971 #[test]
22972 fn rewrite_git_diff_with_revision_passthrough() {
22973 let result = rewrite_command("git diff HEAD~1");
22974 assert_eq!(result, None);
22975 }
22976
22977 #[test]
22978 fn rewrite_git_show_to_revision_diff_digest() {
22979 let result = rewrite_command("git show HEAD~1");
22980 assert_eq!(
22981 result,
22982 Some("tsift diff-digest --revision \"HEAD~1\" .".to_string())
22983 );
22984 }
22985
22986 #[test]
22987 fn rewrite_git_log_patch_history_to_revision_diff_digest() {
22988 let result = rewrite_command("git log -p -1 HEAD~2");
22989 assert_eq!(
22990 result,
22991 Some("tsift diff-digest --revision \"HEAD~2\" .".to_string())
22992 );
22993 }
22994
22995 #[test]
22996 fn rewrite_cat_long_agent_doc_session_to_session_digest() {
22997 let dir = tempfile::tempdir().unwrap();
22998 let session = dir.path().join("tsift.md");
22999 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23000 for index in 0..90 {
23001 body.push_str(&format!("❯ prompt {index}?\n"));
23002 }
23003 fs::write(&session, body).unwrap();
23004
23005 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23006 assert_eq!(
23007 result,
23008 Some(format!(
23009 "tsift session-digest --path {} --input {} --source markdown",
23010 shell_quote(&resolve_digest_context_path(&session)),
23011 shell_quote(session.to_str().unwrap())
23012 ))
23013 );
23014 }
23015
23016 #[test]
23017 fn rewrite_head_long_claude_jsonl_to_session_digest() {
23018 let dir = tempfile::tempdir().unwrap();
23019 let session = dir.path().join("session.jsonl");
23020 let line =
23021 r#"{"message":{"role":"assistant","content":[{"type":"text","text":"❯ do [#yyhd]"}]}}"#;
23022 let body = std::iter::repeat_n(line, 120)
23023 .collect::<Vec<_>>()
23024 .join("\n");
23025 fs::write(&session, format!("{body}\n")).unwrap();
23026
23027 let result = rewrite_command(&format!(
23028 "head -n 120 {}",
23029 shell_quote(session.to_str().unwrap())
23030 ));
23031 assert_eq!(
23032 result,
23033 Some(format!(
23034 "tsift session-digest --path {} --input {} --source claude-jsonl",
23035 shell_quote(&resolve_digest_context_path(&session)),
23036 shell_quote(session.to_str().unwrap())
23037 ))
23038 );
23039 }
23040
23041 #[test]
23042 fn rewrite_head_long_codex_jsonl_to_session_digest() {
23043 let dir = tempfile::tempdir().unwrap();
23044 let session = dir.path().join("codex.jsonl");
23045 let line = r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#;
23046 let body = std::iter::repeat_n(line, 120)
23047 .collect::<Vec<_>>()
23048 .join("\n");
23049 fs::write(&session, format!("{body}\n")).unwrap();
23050
23051 let result = rewrite_command(&format!(
23052 "head -n 120 {}",
23053 shell_quote(session.to_str().unwrap())
23054 ));
23055 assert_eq!(
23056 result,
23057 Some(format!(
23058 "tsift session-digest --path {} --input {} --source codex-jsonl",
23059 shell_quote(&resolve_digest_context_path(&session)),
23060 shell_quote(session.to_str().unwrap())
23061 ))
23062 );
23063 }
23064
23065 #[test]
23066 fn rewrite_small_transcript_window_passthrough() {
23067 let dir = tempfile::tempdir().unwrap();
23068 let session = dir.path().join("session.jsonl");
23069 let line = r#"{"message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}"#;
23070 let body = std::iter::repeat_n(line, 120)
23071 .collect::<Vec<_>>()
23072 .join("\n");
23073 fs::write(&session, format!("{body}\n")).unwrap();
23074
23075 let result = rewrite_command(&format!(
23076 "tail -n 20 {}",
23077 shell_quote(session.to_str().unwrap())
23078 ));
23079 assert_eq!(result, None);
23080 }
23081
23082 #[test]
23083 fn rewrite_sed_large_agent_doc_range_to_session_digest() {
23084 let dir = tempfile::tempdir().unwrap();
23085 let session = dir.path().join("tsift.md");
23086 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23087 for index in 0..120 {
23088 body.push_str(&format!("### Re: topic {index}\n"));
23089 }
23090 fs::write(&session, body).unwrap();
23091
23092 let result = rewrite_command(&format!(
23093 "sed -n '1,120p' {}",
23094 shell_quote(session.to_str().unwrap())
23095 ));
23096 assert_eq!(
23097 result,
23098 Some(format!(
23099 "tsift session-digest --path {} --input {} --source markdown",
23100 shell_quote(&resolve_digest_context_path(&session)),
23101 shell_quote(session.to_str().unwrap())
23102 ))
23103 );
23104 }
23105
23106 #[test]
23107 fn rewrite_cat_large_agent_doc_log_to_session_digest() {
23108 let dir = tempfile::tempdir().unwrap();
23109 let session = dir.path().join("tsift.log");
23110 let line = "[1776528398] claude_start mode=fresh_restart restart_count=1";
23111 let body = std::iter::repeat_n(line, 120)
23112 .collect::<Vec<_>>()
23113 .join("\n");
23114 fs::write(&session, format!("{body}\n")).unwrap();
23115
23116 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23117 assert_eq!(
23118 result,
23119 Some(format!(
23120 "tsift session-digest --path {} --input {} --source agent-doc-log",
23121 shell_quote(&resolve_digest_context_path(&session)),
23122 shell_quote(session.to_str().unwrap())
23123 ))
23124 );
23125 }
23126
23127 #[test]
23128 fn rewrite_session_reads_prefer_submodule_root_for_digest_path() {
23129 let dir = tempfile::tempdir().unwrap();
23130 fs::write(
23131 dir.path().join(".gitmodules"),
23132 r#"[submodule "src/tsift"]
23133 path = src/tsift
23134 url = https://example.com/tsift
23135"#,
23136 )
23137 .unwrap();
23138 let submodule = dir.path().join("src/tsift");
23139 fs::create_dir_all(submodule.join("tasks")).unwrap();
23140 fs::write(
23141 submodule.join(".git"),
23142 "gitdir: ../../.git/modules/src/tsift\n",
23143 )
23144 .unwrap();
23145 let session = submodule.join("tasks/plan.md");
23146 let mut body = String::from("---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n");
23147 for index in 0..90 {
23148 body.push_str(&format!("❯ prompt {index}?\n"));
23149 }
23150 fs::write(&session, body).unwrap();
23151
23152 let result = rewrite_command(&format!("cat {}", shell_quote(session.to_str().unwrap())));
23153
23154 assert_eq!(
23155 result,
23156 Some(format!(
23157 "tsift session-digest --path {} --input {} --source markdown",
23158 shell_quote(submodule.to_str().unwrap()),
23159 shell_quote(session.to_str().unwrap())
23160 ))
23161 );
23162 }
23163
23164 #[test]
23165 fn rewrite_regular_markdown_read_passthrough() {
23166 let dir = tempfile::tempdir().unwrap();
23167 let readme = dir.path().join("README.md");
23168 let body = std::iter::repeat_n("plain markdown", 120)
23169 .collect::<Vec<_>>()
23170 .join("\n");
23171 fs::write(&readme, format!("{body}\n")).unwrap();
23172
23173 let result = rewrite_command(&format!("cat {}", shell_quote(readme.to_str().unwrap())));
23174 assert_eq!(result, None);
23175 }
23176
23177 #[test]
23178 fn rewrite_cat_large_source_to_source_read_in_indexed_repo() {
23179 let dir = tempfile::tempdir().unwrap();
23180 write_empty_root_index(dir.path());
23181 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23182
23183 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23184
23185 assert_eq!(
23186 result,
23187 Some(format!(
23188 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 1 --lines 80 --budget normal",
23189 shell_quote(&dir.path().to_string_lossy())
23190 ))
23191 );
23192 }
23193
23194 #[test]
23195 fn rewrite_head_small_source_window_passthrough() {
23196 let dir = tempfile::tempdir().unwrap();
23197 write_empty_root_index(dir.path());
23198 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23199
23200 let result = rewrite_command(&format!(
23201 "head -n 20 {}",
23202 shell_quote(source.to_str().unwrap())
23203 ));
23204
23205 assert_eq!(result, None);
23206 }
23207
23208 #[test]
23209 fn rewrite_sed_large_source_range_to_source_read() {
23210 let dir = tempfile::tempdir().unwrap();
23211 write_empty_root_index(dir.path());
23212 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23213
23214 let result = rewrite_command(&format!(
23215 "sed -n '40,160p' {}",
23216 shell_quote(source.to_str().unwrap())
23217 ));
23218
23219 assert_eq!(
23220 result,
23221 Some(format!(
23222 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 40 --lines 121 --budget normal",
23223 shell_quote(&dir.path().to_string_lossy())
23224 ))
23225 );
23226 }
23227
23228 #[test]
23229 fn rewrite_tail_large_source_window_preserves_tail_anchor() {
23230 let dir = tempfile::tempdir().unwrap();
23231 write_empty_root_index(dir.path());
23232 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 200);
23233
23234 let result = rewrite_command(&format!(
23235 "tail -n 120 {}",
23236 shell_quote(source.to_str().unwrap())
23237 ));
23238
23239 assert_eq!(
23240 result,
23241 Some(format!(
23242 "tsift --envelope source-read \"src/lib.rs\" --path {} --style window --start 81 --lines 120 --budget normal",
23243 shell_quote(&dir.path().to_string_lossy())
23244 ))
23245 );
23246 }
23247
23248 #[test]
23249 fn rewrite_large_non_source_read_passthrough_even_when_indexed() {
23250 let dir = tempfile::tempdir().unwrap();
23251 write_empty_root_index(dir.path());
23252 let text = write_repeated_lines(&dir.path().join("notes.txt"), "plain text", 120);
23253
23254 let result = rewrite_command(&format!("cat {}", shell_quote(text.to_str().unwrap())));
23255
23256 assert_eq!(result, None);
23257 }
23258
23259 #[test]
23260 fn rewrite_large_source_read_passthrough_without_index() {
23261 let dir = tempfile::tempdir().unwrap();
23262 let source = write_repeated_lines(&dir.path().join("src/lib.rs"), "fn demo() {}", 120);
23263
23264 let result = rewrite_command(&format!("cat {}", shell_quote(source.to_str().unwrap())));
23265
23266 assert_eq!(result, None);
23267 }
23268
23269 #[test]
23270 fn rewrite_cargo_test_to_digest_runner() {
23271 let result = rewrite_command("cargo test --lib");
23272 assert_eq!(
23273 result,
23274 Some(
23275 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\"".to_string()
23276 )
23277 );
23278 }
23279
23280 #[test]
23281 fn rewrite_pytest_to_digest_runner() {
23282 let result = rewrite_command("pytest -q tests/test_cli.py");
23283 assert_eq!(
23284 result,
23285 Some(
23286 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"pytest -q tests/test_cli.py\" --runner \"pytest\"".to_string()
23287 )
23288 );
23289 }
23290
23291 #[test]
23292 fn rewrite_python_m_pytest_to_digest_runner() {
23293 let result = rewrite_command("python -m pytest tests/test_cli.py");
23294 assert_eq!(
23295 result,
23296 Some(
23297 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"python -m pytest tests/test_cli.py\" --runner \"pytest\"".to_string()
23298 )
23299 );
23300 }
23301
23302 #[test]
23303 fn rewrite_cargo_build_to_log_digest_runner() {
23304 let result = rewrite_command("cargo build --release");
23305 assert_eq!(
23306 result,
23307 Some(
23308 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\"".to_string()
23309 )
23310 );
23311 }
23312
23313 #[test]
23314 fn rewrite_cargo_install_to_log_digest_runner() {
23315 let result = rewrite_command("cargo install --path . --force");
23316 assert_eq!(
23317 result,
23318 Some(
23319 "tsift --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo install --path . --force\"".to_string()
23320 )
23321 );
23322 }
23323
23324 #[test]
23325 fn rewrite_metacharacter_command_passthrough() {
23326 let result = rewrite_command("cargo test | head");
23327 assert_eq!(result, None);
23328 }
23329
23330 #[test]
23331 fn rewrite_output_cap_detects_search_even_with_global_flag() {
23332 let cap = rewrite_output_cap("tsift --compact search foo").expect("cap");
23333 assert_eq!(cap.max_lines, 50);
23334 assert_eq!(cap.strip_prefix, Some("Strategy:"));
23335 }
23336
23337 #[test]
23338 fn rewrite_output_cap_skips_structured_output() {
23339 assert!(rewrite_output_cap("tsift search foo --json").is_none());
23340 assert!(rewrite_output_cap("tsift --schema graph foo").is_none());
23341 assert!(rewrite_output_cap("tsift --envelope search foo").is_none());
23342 }
23343
23344 #[test]
23345 fn rewrite_output_format_forwards_envelope_to_digest_runner() {
23346 let command = rewrite_command("cargo test --lib").expect("rewrite");
23347 let forwarded = apply_rewrite_output_format(
23348 &command,
23349 OutputFormat {
23350 json_output: true,
23351 compact: false,
23352 pretty: false,
23353 terse: false,
23354 ultra_terse: false,
23355 schema: false,
23356 envelope: true,
23357 },
23358 );
23359 assert_eq!(
23360 forwarded,
23361 "tsift --envelope digest-runner --kind \"test\" --path \".\" --shell-command \"cargo test --lib\" --runner \"cargo\""
23362 );
23363 }
23364
23365 #[test]
23366 fn rewrite_output_format_forwards_json_when_requested() {
23367 let command = rewrite_command("cargo build --release").expect("rewrite");
23368 let forwarded = apply_rewrite_output_format(
23369 &command,
23370 OutputFormat {
23371 json_output: true,
23372 compact: false,
23373 pretty: true,
23374 terse: false,
23375 ultra_terse: false,
23376 schema: false,
23377 envelope: false,
23378 },
23379 );
23380 assert_eq!(
23381 forwarded,
23382 "tsift --pretty --envelope digest-runner --kind \"log\" --path \".\" --shell-command \"cargo build --release\""
23383 );
23384 }
23385
23386 #[test]
23387 fn output_cap_strips_search_header_and_truncates() {
23388 let capped = apply_output_cap(
23389 b"Strategy: exact | Indexed: 0 | Skipped: 0\n\nline1\nline2\nline3\n",
23390 OutputCap {
23391 max_lines: 2,
23392 strip_prefix: Some("Strategy:"),
23393 },
23394 );
23395 assert_eq!(
23396 capped,
23397 "line1\nline2\n... (+1 more lines; rerun the underlying tsift command directly for the full output)\n"
23398 );
23399 }
23400
23401 #[test]
23402 fn sql_schema_overview_lists_tables() {
23403 let (_tmp, conn) = setup_test_db();
23404 let tables = schema_overview(&conn).unwrap();
23405 let names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect();
23406 assert_eq!(names, &["posts", "users"]);
23407 }
23408
23409 #[test]
23410 fn sql_schema_overview_row_counts() {
23411 let (_tmp, conn) = setup_test_db();
23412 let tables = schema_overview(&conn).unwrap();
23413 let users = tables.iter().find(|t| t.name == "users").unwrap();
23414 let posts = tables.iter().find(|t| t.name == "posts").unwrap();
23415 assert_eq!(users.row_count, 2);
23416 assert_eq!(posts.row_count, 3);
23417 }
23418
23419 #[test]
23420 fn sql_table_columns_metadata() {
23421 let (_tmp, conn) = setup_test_db();
23422 let cols = table_columns(&conn, "users").unwrap();
23423 assert_eq!(cols.len(), 3);
23424 assert_eq!(cols[0].name, "id");
23425 assert!(cols[0].pk);
23426 assert_eq!(cols[1].name, "name");
23427 assert!(cols[1].notnull);
23428 assert_eq!(cols[2].name, "email");
23429 assert!(!cols[2].notnull);
23430 }
23431
23432 #[test]
23433 fn sql_execute_query_returns_rows() {
23434 let (_tmp, conn) = setup_test_db();
23435 let (columns, rows) =
23436 execute_query(&conn, "SELECT name, email FROM users ORDER BY id").unwrap();
23437 assert_eq!(columns, &["name", "email"]);
23438 assert_eq!(rows.len(), 2);
23439 assert_eq!(rows[0][0], serde_json::json!("Alice"));
23440 assert_eq!(rows[0][1], serde_json::json!("alice@example.com"));
23441 assert_eq!(rows[1][1], serde_json::Value::Null);
23442 }
23443
23444 #[test]
23445 fn sql_execute_query_aggregate() {
23446 let (_tmp, conn) = setup_test_db();
23447 let (columns, rows) = execute_query(&conn, "SELECT COUNT(*) as cnt FROM posts").unwrap();
23448 assert_eq!(columns, &["cnt"]);
23449 assert_eq!(rows[0][0], serde_json::json!(3));
23450 }
23451
23452 #[test]
23453 fn sql_execute_query_join() {
23454 let (_tmp, conn) = setup_test_db();
23455 let (_cols, rows) = execute_query(
23456 &conn,
23457 "SELECT u.name, p.title FROM users u JOIN posts p ON u.id = p.user_id ORDER BY p.id",
23458 )
23459 .unwrap();
23460 assert_eq!(rows.len(), 3);
23461 assert_eq!(rows[0][0], serde_json::json!("Alice"));
23462 assert_eq!(rows[2][0], serde_json::json!("Bob"));
23463 }
23464
23465 #[test]
23466 fn sql_open_db_read_only() {
23467 let (tmp, _conn) = setup_test_db();
23468 drop(_conn);
23469 let ro_conn = open_db(tmp.path()).unwrap();
23470 let result = ro_conn.execute("INSERT INTO users VALUES (99, 'Fail', NULL)", []);
23471 assert!(result.is_err(), "read-only connection should reject writes");
23472 }
23473
23474 #[test]
23475 fn sql_empty_table_schema() {
23476 let tmp = tempfile::NamedTempFile::new().unwrap();
23477 let conn = Connection::open(tmp.path()).unwrap();
23478 conn.execute_batch("CREATE TABLE empty_tbl (id INTEGER PRIMARY KEY, data BLOB)")
23479 .unwrap();
23480 let tables = schema_overview(&conn).unwrap();
23481 assert_eq!(tables[0].row_count, 0);
23482 assert_eq!(tables[0].columns.len(), 2);
23483 }
23484
23485 fn setup_graph_index() -> tempfile::TempDir {
23488 let dir = tempfile::tempdir().unwrap();
23489 std::fs::write(
23490 dir.path().join("main.rs"),
23491 "fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }",
23492 )
23493 .unwrap();
23494 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23495 db.apply_changes(dir.path()).unwrap();
23496 dir
23497 }
23498
23499 fn setup_traversal_project() -> tempfile::TempDir {
23500 let dir = setup_graph_index();
23501 let task_dir = dir.path().join("tasks/software");
23502 std::fs::create_dir_all(&task_dir).unwrap();
23503 std::fs::write(
23504 task_dir.join("tsift.md"),
23505 r#"---
23506agent_doc_session: tsift-v0.1
23507agent_doc_format: template
23508---
23509
23510## Exchange
23511
23512<!-- agent:exchange patch=append -->
23513❯ do [#kgnv]
23514Completed `#kgnv`; touched files `main.rs`; tests `cargo test traversal_graph`; follow-up `#gfix`.
23515<!-- /agent:exchange -->
23516
23517<!-- agent:queue -->
23518dispatch #spec-test-build-install-commit-push
23519- do [#kgnv]
23520<!-- /agent:queue -->
23521
23522## Backlog
23523
23524<!-- agent:backlog -->
23525- [ ] [#kgnv] Fix helper traversal handles while preserving graph navigation.
23526<!-- /agent:backlog -->
23527"#,
23528 )
23529 .unwrap();
23530 dir
23531 }
23532
23533 fn resolve_ast_span_node<'a>(
23534 graph: &'a TraversalGraphBuild,
23535 label: &str,
23536 symbol_kind: &str,
23537 ) -> &'a TraversalNode {
23538 graph
23539 .nodes
23540 .values()
23541 .find(|node| {
23542 node.kind == "ast_span"
23543 && node.label == label
23544 && node.properties.get("symbol_kind") == Some(&symbol_kind.to_string())
23545 })
23546 .unwrap_or_else(|| panic!("missing ast_span {symbol_kind} {label}"))
23547 }
23548
23549 fn setup_multilingual_ast_navigation_project() -> tempfile::TempDir {
23550 let dir = tempfile::tempdir().unwrap();
23551 std::fs::write(
23552 dir.path().join("rust.rs"),
23553 r#"mod fixture_nav_rust_mod {
23554 pub fn fixture_nav_rust_helper() {}
23555 pub fn fixture_nav_rust_entry() {
23556 fixture_nav_rust_helper();
23557 }
23558}
23559"#,
23560 )
23561 .unwrap();
23562 std::fs::write(
23563 dir.path().join("python.py"),
23564 r#"def fixture_nav_python_helper():
23565 return 1
23566
23567def fixture_nav_python_entry():
23568 return fixture_nav_python_helper()
23569"#,
23570 )
23571 .unwrap();
23572 std::fs::write(
23573 dir.path().join("typescript.ts"),
23574 r#"export function fixture_nav_typescript_entry(): number {
23575 return fixtureNavTsHelper();
23576}
23577
23578function fixtureNavTsHelper(): number {
23579 return 1;
23580}
23581"#,
23582 )
23583 .unwrap();
23584 std::fs::write(
23585 dir.path().join("javascript.js"),
23586 r#"function fixture_nav_javascript_entry() {
23587 return fixtureNavJsHelper();
23588}
23589
23590function fixtureNavJsHelper() {
23591 return 1;
23592}
23593"#,
23594 )
23595 .unwrap();
23596 std::fs::write(
23597 dir.path().join("kotlin.kt"),
23598 r#"fun fixture_nav_kotlin_entry(): Int {
23599 return fixtureNavKotlinHelper()
23600}
23601
23602fun fixtureNavKotlinHelper(): Int = 1
23603"#,
23604 )
23605 .unwrap();
23606 std::fs::write(
23607 dir.path().join("zig.zig"),
23608 r#"pub fn fixture_nav_zig_entry() i32 {
23609 return fixtureNavZigHelper();
23610}
23611
23612fn fixtureNavZigHelper() i32 {
23613 return 1;
23614}
23615"#,
23616 )
23617 .unwrap();
23618 std::fs::write(
23619 dir.path().join("bash.sh"),
23620 r#"#!/usr/bin/env bash
23621fixture_nav_bash_entry() {
23622 fixture_nav_bash_helper
23623}
23624
23625fixture_nav_bash_helper() {
23626 echo ok
23627}
23628
23629alias fixture_nav_bash_alias='echo alias'
23630"#,
23631 )
23632 .unwrap();
23633 std::fs::write(
23634 dir.path().join("README.md"),
23635 r#"# Fixture Guide
23636
23637## Fixture Section
23638
23639- Fixture step
23640 - Nested fixture step
23641
23642```python
23643def fixture_nav_markdown_embedded():
23644 return 1
23645```
23646"#,
23647 )
23648 .unwrap();
23649
23650 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23651 db.apply_changes(dir.path()).unwrap();
23652 dir
23653 }
23654
23655 fn assert_cli_expand_command_parses(command: &str) {
23656 let args = shell_split(command)
23657 .into_iter()
23658 .map(str::to_string)
23659 .collect::<Vec<_>>();
23660 assert!(
23661 try_parse_cli(args).is_ok(),
23662 "expand command should parse as a tsift CLI command: {command}"
23663 );
23664 }
23665
23666 fn setup_multiplicity_project() -> tempfile::TempDir {
23667 let dir = tempfile::tempdir().unwrap();
23668 std::fs::write(
23669 dir.path().join("Cargo.toml"),
23670 r#"[workspace]
23671members = ["crates/core-lib", "crates/cli-app"]
23672"#,
23673 )
23674 .unwrap();
23675 std::fs::create_dir_all(dir.path().join("crates/core-lib/src")).unwrap();
23676 std::fs::write(
23677 dir.path().join("crates/core-lib/Cargo.toml"),
23678 r#"[package]
23679name = "core-lib"
23680
23681[lib]
23682name = "core_lib"
23683
23684[features]
23685default = []
23686"#,
23687 )
23688 .unwrap();
23689 std::fs::write(
23690 dir.path().join("crates/core-lib/src/lib.rs"),
23691 "pub fn run() {}\n",
23692 )
23693 .unwrap();
23694 std::fs::create_dir_all(dir.path().join("crates/cli-app/src")).unwrap();
23695 std::fs::write(
23696 dir.path().join("crates/cli-app/Cargo.toml"),
23697 r#"[package]
23698name = "cli-app"
23699
23700[[bin]]
23701name = "cli-app"
23702
23703[dependencies]
23704core-lib = { path = "../core-lib" }
23705"#,
23706 )
23707 .unwrap();
23708 std::fs::write(
23709 dir.path().join("crates/cli-app/src/main.rs"),
23710 "use core_lib::run;\nfn main() { run(); }\n",
23711 )
23712 .unwrap();
23713 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23714 db.apply_changes(dir.path()).unwrap();
23715
23716 let task_dir = dir.path().join("tasks/software");
23717 std::fs::create_dir_all(&task_dir).unwrap();
23718 std::fs::write(
23719 task_dir.join("tsift.md"),
23720 r#"---
23721agent_doc_session: tsift-multiplicity
23722agent_doc_format: template
23723---
23724
23725## Backlog
23726
23727<!-- agent:backlog -->
23728- [ ] [#corepkg] Update the core-lib Cargo package ownership model.
23729<!-- /agent:backlog -->
23730"#,
23731 )
23732 .unwrap();
23733 init_git_repo(dir.path());
23734 dir
23735 }
23736
23737 fn setup_dependency_dag_project() -> tempfile::TempDir {
23738 let dir = tempfile::tempdir().unwrap();
23739 std::fs::write(
23740 dir.path().join("main.rs"),
23741 "fn shared_helper() {}\nfn main() { shared_helper(); }\n",
23742 )
23743 .unwrap();
23744 std::fs::write(
23745 dir.path().join("Cargo.toml"),
23746 "[package]\nname = \"dag-fixture\"\n",
23747 )
23748 .unwrap();
23749 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23750 db.apply_changes(dir.path()).unwrap();
23751
23752 let task_dir = dir.path().join("tasks/software");
23753 std::fs::create_dir_all(&task_dir).unwrap();
23754 std::fs::write(
23755 task_dir.join("tsift.md"),
23756 r#"---
23757agent_doc_session: tsift-dag
23758agent_doc_format: template
23759---
23760
23761## Exchange
23762
23763<!-- agent:exchange patch=append -->
23764Completed `#alpha`; touched files `main.rs`; tests `cargo test dependency_dag`; follow-up `#gamma`.
23765<!-- /agent:exchange -->
23766
23767## Backlog
23768
23769<!-- agent:backlog -->
23770- [ ] [#prep] Prepare Cargo.toml configuration before shared helper work.
23771- [ ] [#alpha] Update shared_helper in main.rs after #prep.
23772- [ ] [#beta] Refactor shared_helper tests in main.rs.
23773- [ ] [#gamma] Follow-up review for graph navigation.
23774<!-- /agent:backlog -->
23775"#,
23776 )
23777 .unwrap();
23778 dir
23779 }
23780
23781 fn setup_dependency_dag_cycle_project() -> tempfile::TempDir {
23782 let dir = setup_graph_index();
23783 let task_dir = dir.path().join("tasks/software");
23784 std::fs::create_dir_all(&task_dir).unwrap();
23785 std::fs::write(
23786 task_dir.join("tsift.md"),
23787 r#"---
23788agent_doc_session: tsift-dag-cycle
23789agent_doc_format: template
23790---
23791
23792## Backlog
23793
23794<!-- agent:backlog -->
23795- [ ] [#left] Left side depends on #right.
23796- [ ] [#right] Right side depends on #left.
23797<!-- /agent:backlog -->
23798"#,
23799 )
23800 .unwrap();
23801 dir
23802 }
23803
23804 fn seed_traversal_semantic_summaries(dir: &Path) {
23805 let summary_db = summarize::SummaryDb::open(&dir.join(".tsift/summaries.db")).unwrap();
23806 summary_db
23807 .insert(&summarize::Summary {
23808 id: 0,
23809 symbol_name: "helper".to_string(),
23810 file_path: "main.rs".to_string(),
23811 content_hash: "hash-main".to_string(),
23812 summary: "helper builds graph navigation handles for traversal.".to_string(),
23813 entities: Some(vec![
23814 summarize::Entity {
23815 name: "helper".to_string(),
23816 kind: "function".to_string(),
23817 description: "Builds graph navigation handles.".to_string(),
23818 },
23819 summarize::Entity {
23820 name: "TraversalGraph".to_string(),
23821 kind: "type".to_string(),
23822 description: "Carries GraphStore-backed traversal rows.".to_string(),
23823 },
23824 ]),
23825 relationships: Some(vec![summarize::Relationship {
23826 from: "helper".to_string(),
23827 to: "TraversalGraph".to_string(),
23828 kind: "uses".to_string(),
23829 }]),
23830 concept_labels: Some(vec![
23831 "graph navigation".to_string(),
23832 "semantic extraction".to_string(),
23833 ]),
23834 extracted_at: "1700000000".to_string(),
23835 model: "test-model".to_string(),
23836 tokens_input: Some(10),
23837 tokens_output: Some(5),
23838 })
23839 .unwrap();
23840 }
23841
23842 fn seed_tsift_memory_graph_db(dir: &Path) {
23843 let db = dir.join(".tsift").join("memory.db");
23844 let store = MemoryStore::open_or_create(&db).unwrap();
23845 let project = dir.to_string_lossy().to_string();
23846 let observation = MemoryEvent::new(
23847 MemoryEventKind::ImportedObservation,
23848 "claude-mem:observations:1",
23849 [
23850 "Graph memory adapter",
23851 "read-only projection",
23852 "graph-db should retrieve tsift memory observations",
23853 "Project memory is queried from .tsift/memory.db",
23854 "graph memory, tsift memory, semantic query",
23855 ]
23856 .join("\n\n"),
23857 )
23858 .with_session_id("claude-session-a")
23859 .with_observed_at_unix(1_700_000_000)
23860 .with_import("claude-mem", "observations:1")
23861 .with_metadata("project", project.clone())
23862 .with_metadata("observation_type", "fact")
23863 .with_metadata("prompt_number", "7")
23864 .with_metadata("discovery_tokens", "42")
23865 .with_metadata("content_hash", "hash-observation-1");
23866 store.insert_event(&observation).unwrap();
23867
23868 let summary = MemoryEvent::new(
23869 MemoryEventKind::ImportedSessionSummary,
23870 "claude-mem:session_summaries:2",
23871 [
23872 "Query old memory from graph-db",
23873 "Read-only tsift memory SQLite projection",
23874 "Semantic graph rows can point at existing memory",
23875 "Projected source and session nodes",
23876 "Keep capture ownership inside tsift-memory",
23877 "summary note",
23878 ]
23879 .join("\n\n"),
23880 )
23881 .with_session_id("claude-session-a")
23882 .with_observed_at_unix(1_700_000_010)
23883 .with_import("claude-mem", "session_summaries:2")
23884 .with_metadata("project", project)
23885 .with_metadata("prompt_number", "8")
23886 .with_metadata("discovery_tokens", "36");
23887 store.insert_event(&summary).unwrap();
23888
23889 let prompt = MemoryEvent::new(
23890 MemoryEventKind::ImportedUserPrompt,
23891 "claude-mem:user_prompts:3",
23892 "How can graph-db query tsift memory semantic history?",
23893 )
23894 .with_session_id("claude-session-a")
23895 .with_observed_at_unix(1_700_000_020)
23896 .with_import("claude-mem", "user_prompts:3")
23897 .with_metadata("prompt_number", "9");
23898 store.insert_event(&prompt).unwrap();
23899 }
23900
23901 #[test]
23902 fn graph_callers_query() {
23903 let dir = setup_graph_index();
23904 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23905 let callers = db.callers_of("helper").unwrap();
23906 assert_eq!(callers.len(), 1);
23907 assert_eq!(callers[0].caller_name, "main");
23908 }
23909
23910 #[test]
23911 fn graph_callees_query() {
23912 let dir = setup_graph_index();
23913 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23914 let callees = db.callees_of("main").unwrap();
23915 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
23916 assert!(names.contains(&"helper"));
23917 assert!(names.contains(&"new"));
23918 }
23919
23920 #[test]
23921 fn graph_no_callers_returns_empty() {
23922 let dir = setup_graph_index();
23923 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
23924 let callers = db.callers_of("nonexistent").unwrap();
23925 assert!(callers.is_empty());
23926 }
23927
23928 #[test]
23929 fn graph_cmd_autoindexes_missing_index_by_default() {
23930 let dir = tempfile::tempdir().unwrap();
23931 std::fs::write(
23932 dir.path().join("main.rs"),
23933 "fn helper() {}\nfn main() { helper(); }\n",
23934 )
23935 .unwrap();
23936 let result = cmd_graph(
23937 "helper",
23938 dir.path(),
23939 true,
23940 false,
23941 None,
23942 20,
23943 false,
23944 true,
23945 false,
23946 false,
23947 false,
23948 false,
23949 false,
23950 TagpathSearchOpts::default(),
23951 );
23952
23953 assert!(result.is_ok());
23954 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
23955 let summary = db.compute_changes(dir.path()).unwrap();
23956 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
23957 }
23958
23959 #[test]
23960 fn traversal_graph_has_stable_typed_handles() {
23961 let dir = setup_traversal_project();
23962 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23963 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23964
23965 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
23966 let symbol = resolve_traversal_node(&graph, "helper").unwrap();
23967 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23968 let session = resolve_traversal_node(&graph, "tsift-v0.1").unwrap();
23969
23970 assert!(file.handle.starts_with("gfil-"));
23971 assert!(symbol.handle.starts_with("gsym-"));
23972 assert!(backlog.handle.starts_with("gbak-"));
23973 assert!(session.handle.starts_with("gses-"));
23974
23975 assert_eq!(
23976 symbol.handle,
23977 resolve_traversal_node(&graph_again, "helper")
23978 .unwrap()
23979 .handle
23980 );
23981 assert_eq!(
23982 backlog.handle,
23983 resolve_traversal_node(&graph_again, "#kgnv")
23984 .unwrap()
23985 .handle
23986 );
23987 }
23988
23989 #[test]
23990 fn traversal_graph_links_backlog_items_to_code_tokens() {
23991 let dir = setup_traversal_project();
23992 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
23993 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
23994 let helper = resolve_traversal_node(&graph, "helper").unwrap();
23995
23996 assert!(graph.edges.iter().any(|edge| {
23997 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
23998 }));
23999 }
24000
24001 #[test]
24002 fn session_hinted_traversal_skips_global_call_edges() {
24003 let dir = setup_traversal_project();
24004 let session = dir.path().join("tasks/software/tsift.md");
24005 let bounded = build_traversal_graph_source(dir.path(), &session, None).unwrap();
24006 let backlog = resolve_traversal_node(&bounded, "#kgnv").unwrap();
24007 let helper = resolve_traversal_node(&bounded, "helper").unwrap();
24008
24009 assert!(bounded.edges.iter().any(|edge| {
24010 edge.from == backlog.handle && edge.to == helper.handle && edge.relation == "mentions"
24011 }));
24012 assert!(
24013 !bounded.edges.iter().any(|edge| edge.relation == "calls"),
24014 "session-hinted graph-db projections should not materialize unrelated global call edges"
24015 );
24016
24017 let full = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
24018 assert!(
24019 full.edges.iter().any(|edge| edge.relation == "calls"),
24020 "root/full projections still carry the complete indexed call graph"
24021 );
24022 }
24023
24024 #[test]
24025 fn agent_doc_task_path_infers_matching_workspace_scope() {
24026 let dir = tempfile::tempdir().unwrap();
24027 std::fs::create_dir_all(dir.path().join("src/tsift")).unwrap();
24028 std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
24029 std::fs::write(
24030 dir.path().join(".gitmodules"),
24031 "[submodule \"src/tsift\"]\n\tpath = src/tsift\n\turl = https://example.invalid/tsift.git\n",
24032 )
24033 .unwrap();
24034 let task = dir.path().join("tasks/software/tsift.md");
24035 std::fs::write(&task, "# tsift\n").unwrap();
24036
24037 let targets = resolve_search_index_targets(dir.path(), &task, None, false).unwrap();
24038 let query_db_path = resolve_query_db_path(dir.path(), &task, None).unwrap();
24039 let cfg = config::Config::load(dir.path()).unwrap();
24040
24041 assert_eq!(targets.len(), 1);
24042 assert_eq!(targets[0].scope_name.as_deref(), Some("tsift"));
24043 assert_eq!(targets[0].source_root, dir.path().join("src/tsift"));
24044 assert!(
24045 targets[0]
24046 .db_path
24047 .ends_with(".tsift/indexes/tsift/index.db")
24048 );
24049 assert_eq!(query_db_path, cfg.db_path_for(dir.path(), "tsift"));
24050 }
24051
24052 #[test]
24053 fn cargo_package_scope_selector_indexes_package_db() {
24054 let dir = setup_multiplicity_project();
24055 let targets =
24056 resolve_search_index_targets(dir.path(), dir.path(), Some("core_lib"), false).unwrap();
24057
24058 assert_eq!(targets.len(), 1);
24059 assert_eq!(targets[0].scope_name.as_deref(), Some("core-lib"));
24060 assert_eq!(targets[0].source_root, dir.path().join("crates/core-lib"));
24061 assert!(
24062 targets[0]
24063 .db_path
24064 .ends_with(".tsift/indexes/cargo/core-lib/index.db")
24065 );
24066
24067 cmd_index(
24068 dir.path(),
24069 false,
24070 false,
24071 false,
24072 false,
24073 true,
24074 false,
24075 Some("core_lib"),
24076 false,
24077 true,
24078 false,
24079 false,
24080 false,
24081 false,
24082 )
24083 .unwrap();
24084 assert!(targets[0].db_path.exists());
24085 }
24086
24087 #[test]
24088 fn source_read_symbols_build_cargo_package_index_on_demand() {
24089 let dir = setup_multiplicity_project();
24095 let cargo_index = dir.path().join(".tsift/indexes/cargo/core-lib/index.db");
24096 assert!(
24097 !cargo_index.exists(),
24098 "core-lib cargo index should not exist before the first source-read"
24099 );
24100
24101 let file_abs = dir.path().join("crates/core-lib/src/lib.rs");
24102 let source = std::fs::read(&file_abs).unwrap();
24103 let mut warnings = Vec::new();
24104 let symbols = load_source_symbols(
24105 dir.path(),
24106 &file_abs,
24107 "crates/core-lib/src/lib.rs",
24108 &source,
24109 None,
24110 1,
24111 usize::MAX,
24112 10,
24113 4096,
24114 &mut warnings,
24115 );
24116
24117 assert!(
24118 warnings.is_empty(),
24119 "source-read must build the index on demand instead of warning: {warnings:?}"
24120 );
24121 let symbol_names = symbols
24122 .iter()
24123 .map(|symbol| symbol.name.as_str())
24124 .collect::<Vec<_>>();
24125 assert!(
24126 symbol_names.contains(&"run"),
24127 "source-read should resolve `run` from the on-demand-built cargo index: {symbol_names:?}"
24128 );
24129 assert!(
24130 cargo_index.exists(),
24131 "source-read should have built the core-lib cargo index on demand"
24132 );
24133 }
24134
24135 #[test]
24136 fn path_inference_prefers_nested_cargo_package_without_submodule() {
24137 let dir = setup_multiplicity_project();
24138 let source = dir.path().join("crates/cli-app/src/main.rs");
24139 let targets = resolve_search_index_targets(dir.path(), &source, None, false).unwrap();
24140
24141 assert_eq!(targets.len(), 1);
24142 assert_eq!(targets[0].scope_name.as_deref(), Some("cli-app"));
24143 assert_eq!(targets[0].source_root, dir.path().join("crates/cli-app"));
24144 }
24145
24146 #[test]
24147 fn traversal_graph_projects_cargo_multiplicity_nodes_and_edges() {
24148 let dir = setup_multiplicity_project();
24149 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24150 let workspace = resolve_traversal_node(&graph, "root cargo workspace").unwrap();
24151 let core = resolve_traversal_node(&graph, "core-lib").unwrap();
24152 let cli = resolve_traversal_node(&graph, "cli-app").unwrap();
24153 let core_file = resolve_traversal_node(&graph, "crates/core-lib/src/lib.rs").unwrap();
24154
24155 assert_eq!(workspace.kind, "cargo_workspace");
24156 assert_eq!(core.kind, "cargo_package");
24157 assert_eq!(
24158 core.properties.get("features"),
24159 Some(&"default".to_string())
24160 );
24161 assert!(graph.edges.iter().any(|edge| {
24162 edge.from == workspace.handle
24163 && edge.to == core.handle
24164 && edge.relation == "contains_package"
24165 }));
24166 assert!(graph.edges.iter().any(|edge| {
24167 edge.from == core.handle && edge.to == core_file.handle && edge.relation == "owns_file"
24168 }));
24169 assert!(graph.edges.iter().any(|edge| {
24170 edge.from == cli.handle
24171 && edge.to == core.handle
24172 && (edge.relation == "declares_dependency" || edge.relation == "uses_crate")
24173 }));
24174 }
24175
24176 #[test]
24177 fn conflict_matrix_uses_cargo_package_mentions_as_ownership_evidence() {
24178 let dir = setup_multiplicity_project();
24179 let session = dir.path().join("tasks/software/tsift.md");
24180 let report =
24181 build_conflict_matrix_report(&session, None, &["corepkg".to_string()], 3, 8, 20)
24182 .unwrap();
24183
24184 assert!(report.per_target_fail_closed.is_empty());
24185 let candidate = report
24186 .candidates
24187 .iter()
24188 .find(|candidate| candidate.target == "corepkg")
24189 .unwrap();
24190 assert!(
24191 candidate
24192 .owned_files
24193 .iter()
24194 .any(|file| file == "crates/core-lib/Cargo.toml"),
24195 "{:?}",
24196 candidate.owned_files
24197 );
24198 }
24199
24200 #[test]
24201 fn traversal_graph_links_agent_doc_queue_job_packets_to_backlog() {
24202 let dir = setup_traversal_project();
24203 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24204 let job = resolve_traversal_node(&graph, "do #kgnv").unwrap();
24205 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24206
24207 assert_eq!(job.kind, "job_packet");
24208 assert!(job.handle.starts_with("gjob-"));
24209 assert!(graph.edges.iter().any(|edge| {
24210 edge.from == job.handle && edge.to == backlog.handle && edge.relation == "targets"
24211 }));
24212
24213 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24214 let jobs = store.nodes_by_kind("job_packet").unwrap();
24215 assert!(
24216 jobs.iter()
24217 .any(|node| node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24218 "expected queued job packet in graph store, got {jobs:?}"
24219 );
24220 }
24221
24222 #[test]
24223 fn traversal_graph_includes_routes_and_handler_edges() {
24224 let dir = tempfile::tempdir().unwrap();
24225 std::fs::write(
24226 dir.path().join("api.py"),
24227 r#"@router.get("/items")
24228def list_items():
24229 return []
24230"#,
24231 )
24232 .unwrap();
24233 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24234 db.apply_changes(dir.path()).unwrap();
24235
24236 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24237 let route = resolve_traversal_node(&graph, "/items").unwrap();
24238 let handler = resolve_traversal_node(&graph, "list_items").unwrap();
24239
24240 assert_eq!(route.kind, "route");
24241 assert!(graph.edges.iter().any(|edge| {
24242 edge.from == route.handle && edge.to == handler.handle && edge.relation == "handled_by"
24243 }));
24244 }
24245
24246 #[test]
24247 fn traversal_graph_projects_rust_ast_navigation_edges() {
24248 let dir = tempfile::tempdir().unwrap();
24249 std::fs::write(
24250 dir.path().join("main.rs"),
24251 r#"mod api {
24252 pub fn helper() {}
24253 pub fn handler() { helper(); }
24254}
24255
24256fn main() { api::handler(); }
24257"#,
24258 )
24259 .unwrap();
24260 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24261 db.apply_changes(dir.path()).unwrap();
24262
24263 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24264 let api = resolve_ast_span_node(&graph, "api", "mod");
24265 let helper = resolve_ast_span_node(&graph, "helper", "function");
24266 let handler = resolve_ast_span_node(&graph, "handler", "function");
24267
24268 assert_eq!(helper.kind, "ast_span");
24269 assert!(helper.handle.starts_with("span-"));
24270 assert_eq!(helper.properties.get("language"), Some(&"rust".to_string()));
24271 assert!(graph.edges.iter().any(|edge| {
24272 edge.from == api.handle && edge.to == helper.handle && edge.relation == "contains"
24273 }));
24274 assert!(graph.edges.iter().any(|edge| {
24275 edge.from == api.handle && edge.to == helper.handle && edge.relation == "child"
24276 }));
24277 assert!(graph.edges.iter().any(|edge| {
24278 edge.from == helper.handle && edge.to == api.handle && edge.relation == "parent"
24279 }));
24280 assert!(graph.edges.iter().any(|edge| {
24281 edge.from == helper.handle
24282 && edge.to == handler.handle
24283 && edge.relation == "next_sibling"
24284 }));
24285 assert!(graph.edges.iter().any(|edge| {
24286 edge.from == handler.handle
24287 && edge.to == helper.handle
24288 && edge.relation == "previous_sibling"
24289 }));
24290 assert!(graph.edges.iter().any(|edge| {
24291 edge.from == helper.handle
24292 && edge.to == api.handle
24293 && edge.relation == "enclosing_module"
24294 }));
24295 assert!(graph.edges.iter().any(|edge| {
24296 edge.from == handler.handle && edge.to == helper.handle && edge.relation == "calls"
24297 }));
24298
24299 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24300 let ast_nodes = store.nodes_by_kind("ast_span").unwrap();
24301 assert!(
24302 ast_nodes.iter().any(|node| node.id == helper.handle
24303 && node.properties.get("symbol_kind") == Some(&"function".to_string())),
24304 "expected helper AST span in graph store, got {ast_nodes:?}"
24305 );
24306 assert!(
24307 store
24308 .outgoing_edges(&helper.handle, Some("parent"))
24309 .unwrap()
24310 .iter()
24311 .any(|edge| edge.to_id == api.handle),
24312 "expected persisted AST parent edge"
24313 );
24314 }
24315
24316 #[test]
24317 fn traversal_graph_projects_markdown_section_block_edges() {
24318 let dir = tempfile::tempdir().unwrap();
24319 std::fs::write(
24320 dir.path().join("README.md"),
24321 "# Guide\n\n- Setup\n- Verify\n\n```rust\nfn demo() {}\n```\n",
24322 )
24323 .unwrap();
24324 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
24325 db.apply_changes(dir.path()).unwrap();
24326
24327 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24328 let guide = resolve_ast_span_node(&graph, "Guide", "heading");
24329 let code = resolve_ast_span_node(&graph, "rust", "code_block");
24330 let embedded = resolve_ast_span_node(&graph, "demo", "function");
24331 let list_item = graph
24332 .nodes
24333 .values()
24334 .find(|node| {
24335 node.kind == "ast_span"
24336 && node.properties.get("symbol_kind") == Some(&"list_item".to_string())
24337 && node.properties.get("section_handle") == Some(&guide.handle)
24338 })
24339 .expect("missing Markdown list item AST span");
24340
24341 assert_eq!(
24342 code.properties.get("markdown_block_kind"),
24343 Some(&"fenced_code_block".to_string())
24344 );
24345 assert_eq!(
24346 guide.properties.get("heading_level"),
24347 Some(&"1".to_string())
24348 );
24349 assert_eq!(
24350 embedded.properties.get("embedded"),
24351 Some(&"true".to_string())
24352 );
24353 assert_eq!(
24354 embedded.properties.get("language"),
24355 Some(&"rust".to_string())
24356 );
24357 assert_eq!(
24358 embedded.properties.get("markdown_block_handle"),
24359 Some(&code.handle)
24360 );
24361 assert!(graph.edges.iter().any(|edge| {
24362 edge.from == guide.handle
24363 && edge.to == code.handle
24364 && edge.relation == "contains_markdown_block"
24365 }));
24366 assert!(graph.edges.iter().any(|edge| {
24367 edge.from == code.handle
24368 && edge.to == guide.handle
24369 && edge.relation == "enclosing_section"
24370 }));
24371 assert!(graph.edges.iter().any(|edge| {
24372 edge.from == guide.handle
24373 && edge.to == list_item.handle
24374 && edge.relation == "contains_markdown_block"
24375 }));
24376 assert!(graph.edges.iter().any(|edge| {
24377 edge.from == code.handle
24378 && edge.to == embedded.handle
24379 && edge.relation == "contains_embedded_symbol"
24380 }));
24381 assert!(graph.edges.iter().any(|edge| {
24382 edge.from == embedded.handle
24383 && edge.to == code.handle
24384 && edge.relation == "embedded_in_fence"
24385 }));
24386 assert!(graph.edges.iter().any(|edge| {
24387 edge.from == guide.handle
24388 && edge.to == embedded.handle
24389 && edge.relation == "contains_embedded_code"
24390 }));
24391
24392 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24393 assert!(
24394 store
24395 .outgoing_edges(&guide.handle, Some("contains_markdown_block"))
24396 .unwrap()
24397 .iter()
24398 .any(|edge| edge.to_id == code.handle),
24399 "expected persisted Markdown section/block edge"
24400 );
24401 assert!(
24402 store
24403 .outgoing_edges(&code.handle, Some("contains_embedded_symbol"))
24404 .unwrap()
24405 .iter()
24406 .any(|edge| edge.to_id == embedded.handle),
24407 "expected persisted Markdown fence/embedded symbol edge"
24408 );
24409 }
24410
24411 #[test]
24412 fn multilingual_ast_navigation_fixture_locks_recall_handles_expands_and_budget() {
24413 let dir = setup_multilingual_ast_navigation_project();
24414 let db =
24415 index::IndexDb::open_read_only_resilient(&dir.path().join(".tsift/index.db")).unwrap();
24416 let symbols = db.all_symbols().unwrap();
24417 let expected_symbols = [
24418 ("rust", "fixture_nav_rust_entry", "function", "rust.rs"),
24419 (
24420 "python",
24421 "fixture_nav_python_entry",
24422 "function",
24423 "python.py",
24424 ),
24425 (
24426 "typescript",
24427 "fixture_nav_typescript_entry",
24428 "function",
24429 "typescript.ts",
24430 ),
24431 (
24432 "javascript",
24433 "fixture_nav_javascript_entry",
24434 "function",
24435 "javascript.js",
24436 ),
24437 (
24438 "kotlin",
24439 "fixture_nav_kotlin_entry",
24440 "function",
24441 "kotlin.kt",
24442 ),
24443 ("zig", "fixture_nav_zig_entry", "function", "zig.zig"),
24444 ("bash", "fixture_nav_bash_entry", "function", "bash.sh"),
24445 ("markdown", "Fixture Section", "heading", "README.md"),
24446 ("markdown", "Fixture step", "list_item", "README.md"),
24447 ("markdown", "python", "code_block", "README.md"),
24448 ];
24449
24450 for (language, name, kind, file) in expected_symbols {
24451 let symbol = symbols
24452 .iter()
24453 .find(|symbol| {
24454 symbol.language == language
24455 && symbol.name == name
24456 && symbol.kind == kind
24457 && symbol.file.ends_with(file)
24458 })
24459 .unwrap_or_else(|| panic!("missing indexed {language} {kind} {name}"));
24460 assert!(
24461 symbol.start_byte.is_some() && symbol.end_byte.is_some(),
24462 "{language} {name} should carry AST byte spans"
24463 );
24464 }
24465
24466 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24467 let graph_again = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24468 let expected_ast_nodes = [
24469 ("fixture_nav_rust_entry", "function", "rust"),
24470 ("fixture_nav_python_entry", "function", "python"),
24471 ("fixture_nav_typescript_entry", "function", "typescript"),
24472 ("fixture_nav_javascript_entry", "function", "javascript"),
24473 ("fixture_nav_kotlin_entry", "function", "kotlin"),
24474 ("fixture_nav_zig_entry", "function", "zig"),
24475 ("fixture_nav_bash_entry", "function", "bash"),
24476 ("Fixture Section", "heading", "markdown"),
24477 ("Fixture step", "list_item", "markdown"),
24478 ("python", "code_block", "markdown"),
24479 ("fixture_nav_markdown_embedded", "function", "python"),
24480 ];
24481
24482 for (name, kind, language) in expected_ast_nodes {
24483 let node = resolve_ast_span_node(&graph, name, kind);
24484 let repeated = resolve_ast_span_node(&graph_again, name, kind);
24485 assert!(
24486 node.handle.starts_with("span-"),
24487 "{name} handle: {}",
24488 node.handle
24489 );
24490 assert_eq!(
24491 node.handle, repeated.handle,
24492 "{language} {name} handle drifted"
24493 );
24494 assert_eq!(
24495 node.properties.get("language"),
24496 Some(&language.to_string()),
24497 "{name} should keep its language label"
24498 );
24499 }
24500
24501 let markdown_section = resolve_ast_span_node(&graph, "Fixture Section", "heading");
24502 let markdown_code = resolve_ast_span_node(&graph, "python", "code_block");
24503 let embedded = resolve_ast_span_node(&graph, "fixture_nav_markdown_embedded", "function");
24504 assert!(graph.edges.iter().any(|edge| {
24505 edge.from == markdown_section.handle
24506 && edge.to == markdown_code.handle
24507 && edge.relation == "contains_markdown_block"
24508 }));
24509 assert!(graph.edges.iter().any(|edge| {
24510 edge.from == markdown_code.handle
24511 && edge.to == embedded.handle
24512 && edge.relation == "contains_embedded_symbol"
24513 }));
24514 assert!(
24515 graph.nodes.len() <= 80,
24516 "multilingual AST fixture should stay bounded, got {} nodes",
24517 graph.nodes.len()
24518 );
24519 assert!(
24520 graph.edges.len() <= 180,
24521 "multilingual AST fixture should stay bounded, got {} edges",
24522 graph.edges.len()
24523 );
24524
24525 let response = empty_search_response(dir.path(), "lexical");
24526 let symbol_hits = db.symbol_search("fixture_nav_python_entry", 20).unwrap();
24527 let report = build_relative_search_budget_report(
24528 "fixture_nav_python_entry",
24529 "lexical",
24530 dir.path(),
24531 &response,
24532 &symbol_hits,
24533 ResponseBudget::new(Some(8), Some(120)),
24534 &SearchFacetFilters::default(),
24535 );
24536 let report_again = build_relative_search_budget_report(
24537 "fixture_nav_python_entry",
24538 "lexical",
24539 dir.path(),
24540 &response,
24541 &symbol_hits,
24542 ResponseBudget::new(Some(8), Some(120)),
24543 &SearchFacetFilters::default(),
24544 );
24545
24546 let top = report
24547 .ranked
24548 .first()
24549 .expect("ranked preview should not be empty");
24550 assert_eq!(top.source, "symbol_span");
24551 assert_eq!(top.name.as_deref(), Some("fixture_nav_python_entry"));
24552 assert!(top.handle.starts_with("srnk-"));
24553 assert_eq!(top.handle, report_again.ranked[0].handle);
24554 assert!(
24555 top.reasons.iter().any(|reason| reason == "ast_span"),
24556 "expected AST span ranking reason, got {:?}",
24557 top.reasons
24558 );
24559 assert!(report.ranked.len() <= 8);
24560 assert!(report.symbols.len() <= 8);
24561
24562 let symbol = report
24563 .symbols
24564 .iter()
24565 .find(|symbol| symbol.name == "fixture_nav_python_entry")
24566 .expect("missing search preview symbol");
24567 assert_cli_expand_command_parses(&symbol.expand);
24568 let ast = symbol
24569 .ast
24570 .as_ref()
24571 .expect("search symbol should expose AST");
24572 assert_cli_expand_command_parses(&ast.expand.source_window);
24573 assert_cli_expand_command_parses(ast.expand.source_body.as_ref().unwrap());
24574 assert_cli_expand_command_parses(&ast.expand.symbol_read);
24575
24576 let markdown_hits = db.symbol_search("python", 20).unwrap();
24577 let markdown_report = build_relative_search_budget_report(
24578 "python",
24579 "lexical",
24580 dir.path(),
24581 &response,
24582 &markdown_hits,
24583 ResponseBudget::new(Some(8), Some(120)),
24584 &SearchFacetFilters::default(),
24585 );
24586 let markdown_symbol = markdown_report
24587 .symbols
24588 .iter()
24589 .find(|symbol| symbol.kind == "code_block" && symbol.language == "markdown")
24590 .expect("missing Markdown code-block symbol");
24591 let markdown_ast = markdown_symbol
24592 .ast
24593 .as_ref()
24594 .expect("Markdown code block should expose AST");
24595 assert_cli_expand_command_parses(markdown_ast.expand.markdown_ast.as_ref().unwrap());
24596 assert_eq!(
24597 markdown_ast
24598 .span
24599 .markdown
24600 .as_ref()
24601 .unwrap()
24602 .embedded_symbols[0]
24603 .name,
24604 "fixture_nav_markdown_embedded"
24605 );
24606 }
24607
24608 #[test]
24609 fn traversal_neighborhood_handles_prioritizes_high_signal_edges_when_limited() {
24610 let edges = vec![
24611 TraversalEdge {
24612 from: "origin".to_string(),
24613 to: "aaa_low".to_string(),
24614 relation: "unknown".to_string(),
24615 label: None,
24616 weight: 1,
24617 },
24618 TraversalEdge {
24619 from: "origin".to_string(),
24620 to: "zzz_high".to_string(),
24621 relation: "mentions".to_string(),
24622 label: None,
24623 weight: 1,
24624 },
24625 ];
24626
24627 let handles = traversal_neighborhood_handles(&edges, "origin", 1, 2);
24628
24629 assert!(handles.contains("origin"));
24630 assert!(handles.contains("zzz_high"), "{handles:?}");
24631 assert!(!handles.contains("aaa_low"), "{handles:?}");
24632 }
24633
24634 #[test]
24635 fn traversal_materializes_provider_neutral_sqlite_graph() {
24636 let dir = setup_traversal_project();
24637 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24638 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
24639
24640 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24641 let backlog_nodes = store.nodes_by_kind("backlog").unwrap();
24642 assert!(
24643 backlog_nodes.iter().any(|node| node.id == backlog.handle
24644 && node.properties.get("ref_id") == Some(&"kgnv".to_string())),
24645 "expected materialized backlog node, got {backlog_nodes:?}"
24646 );
24647 assert!(
24648 store
24649 .all_nodes()
24650 .unwrap()
24651 .iter()
24652 .any(|node| node.kind == GRAPH_PROJECTION_META_KIND
24653 && node.properties.get("projection_version")
24654 == Some(&GRAPH_PROJECTION_VERSION.to_string())),
24655 "expected projection metadata node"
24656 );
24657 let source_handles = store.nodes_by_kind("source_handle").unwrap();
24658 assert!(
24659 source_handles
24660 .iter()
24661 .any(|node| node.properties.get("file") == Some(&"main.rs".to_string())),
24662 "expected bounded source_handle rows, got {source_handles:?}"
24663 );
24664 let worker_context = store.nodes_by_kind("worker_context").unwrap();
24665 assert!(
24666 worker_context
24667 .iter()
24668 .any(|node| node.properties.get("target")
24669 == Some(&"tasks/software/tsift.md".to_string())),
24670 "expected bounded worker_context rows, got {worker_context:?}"
24671 );
24672 let worker_results = store.nodes_by_kind("worker_result").unwrap();
24673 assert!(
24674 worker_results.iter().any(|node| {
24675 node.properties.get("ref_id") == Some(&"kgnv".to_string())
24676 && node.properties.get("status") == Some(&"completed".to_string())
24677 && node.properties.get("touched_files") == Some(&"main.rs".to_string())
24678 && node.properties.get("follow_up_ids") == Some(&"gfix".to_string())
24679 }),
24680 "expected worker_result rows, got {worker_results:?}"
24681 );
24682 }
24683
24684 #[test]
24685 fn traversal_projection_materializes_cached_semantic_rows() {
24686 let dir = setup_traversal_project();
24687 seed_traversal_semantic_summaries(dir.path());
24688 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
24689 let helper = resolve_traversal_node(&graph, "helper").unwrap();
24690 let concept = resolve_traversal_node(&graph, "graph navigation").unwrap();
24691 let entity = resolve_traversal_node(&graph, "TraversalGraph").unwrap();
24692
24693 assert_eq!(concept.kind, "semantic_concept");
24694 assert_eq!(entity.kind, "semantic_entity");
24695 assert!(concept.handle.starts_with("gcon-"));
24696 assert!(entity.handle.starts_with("gent-"));
24697
24698 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24699 assert!(
24700 store
24701 .nodes_by_kind("semantic_concept")
24702 .unwrap()
24703 .iter()
24704 .any(|node| node.label == "semantic extraction"
24705 && node.properties.contains_key("embedding")),
24706 "expected persisted concept embeddings"
24707 );
24708 assert!(
24709 store
24710 .outgoing_edges(&helper.handle, Some("mentions_concept"))
24711 .unwrap()
24712 .iter()
24713 .any(|edge| edge.to_id == concept.handle),
24714 "expected helper symbol to link to cached summary concept"
24715 );
24716 assert!(
24717 store
24718 .outgoing_edges(
24719 &semantic_entity_handle("helper", "function"),
24720 Some("semantic_relation")
24721 )
24722 .unwrap()
24723 .iter()
24724 .any(|edge| edge.to_id == entity.handle
24725 && edge.properties.get("relationship_kind") == Some(&"uses".to_string())),
24726 "expected LLM relationship rows projected into GraphStore"
24727 );
24728 }
24729
24730 #[test]
24731 fn traversal_projection_materializes_tsift_memory_rows() {
24732 let dir = setup_traversal_project();
24733 seed_tsift_memory_graph_db(dir.path());
24734 let memory_db = dir.path().join(".tsift").join("memory.db");
24735 let store = MemoryStore::open_or_create(&memory_db).unwrap();
24736 for summary in ["first closeout", "second closeout"] {
24737 let event = MemoryEvent::new(
24738 MemoryEventKind::ResponseSummary,
24739 "tasks/software/tsift.md",
24740 summary,
24741 )
24742 .with_session_id("tasks/software/tsift.md")
24743 .with_observed_at_unix(1_700_000_100);
24744 store.insert_event(&event).unwrap();
24745 }
24746 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24747 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24748
24749 let native_sources = store
24750 .nodes_by_kind("source_handle")
24751 .unwrap()
24752 .into_iter()
24753 .filter(|node| {
24754 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24755 && node.properties.get("source_ref")
24756 == Some(&"tasks/software/tsift.md".to_string())
24757 })
24758 .collect::<Vec<_>>();
24759 assert_eq!(
24760 native_sources.len(),
24761 2,
24762 "same-source native memory events must get distinct source handles"
24763 );
24764
24765 let source = store
24766 .nodes_by_kind("source_handle")
24767 .unwrap()
24768 .into_iter()
24769 .find(|node| {
24770 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24771 })
24772 .expect("expected tsift-memory source handle");
24773 let session = store
24774 .nodes_by_kind("memory_session")
24775 .unwrap()
24776 .into_iter()
24777 .find(|node| {
24778 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24779 && node.properties.get("session_id") == Some(&"claude-session-a".to_string())
24780 })
24781 .expect("expected tsift-memory session node");
24782 let event = store
24783 .nodes_by_kind("memory_event")
24784 .unwrap()
24785 .into_iter()
24786 .find(|node| {
24787 node.properties.get("source_ref") == Some(&"claude-mem:observations:1".to_string())
24788 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24789 && node.properties.get("imported_from") == Some(&"claude-mem".to_string())
24790 })
24791 .expect("expected tsift-memory event node");
24792 let concept = store
24793 .nodes_by_kind("semantic_concept")
24794 .unwrap()
24795 .into_iter()
24796 .find(|node| {
24797 node.properties.get("provider") == Some(&"tsift-memory".to_string())
24798 && node.label.contains("Graph memory adapter")
24799 && node.properties.contains_key("embedding")
24800 })
24801 .expect("expected tsift-memory semantic concept");
24802
24803 assert!(
24804 store
24805 .outgoing_edges(&session.id, Some("records_memory_source"))
24806 .unwrap()
24807 .iter()
24808 .any(|edge| edge.to_id == source.id),
24809 "expected session to link to source handle"
24810 );
24811 assert!(
24812 store
24813 .outgoing_edges(&session.id, Some("records_memory_event"))
24814 .unwrap()
24815 .iter()
24816 .any(|edge| edge.to_id == event.id),
24817 "expected session to link to memory event"
24818 );
24819 assert!(
24820 store
24821 .outgoing_edges(&event.id, Some("projects_source"))
24822 .unwrap()
24823 .iter()
24824 .any(|edge| edge.to_id == source.id),
24825 "expected memory event to project source handle"
24826 );
24827 assert!(
24828 store
24829 .outgoing_edges(&source.id, Some("mentions_concept"))
24830 .unwrap()
24831 .iter()
24832 .any(|edge| edge.to_id == concept.id),
24833 "expected source handle to seed semantic concept"
24834 );
24835
24836 let related = semantic_related_report_from_store(
24837 dir.path(),
24838 None,
24839 "tsift memory graph adapter",
24840 5,
24841 SemanticRelatedKind::Concept,
24842 &store,
24843 )
24844 .unwrap();
24845 assert!(
24846 related
24847 .items
24848 .iter()
24849 .any(|item| item.handle == concept.id && item.score > 0.0),
24850 "expected semantic query to retrieve tsift-memory concept, got {:?}",
24851 related.items
24852 );
24853
24854 let graph_related = graph_db_report_from_store(
24855 dir.path(),
24856 None,
24857 "sqlite",
24858 GraphDbQuery::Related {
24859 query: "tsift memory graph adapter".to_string(),
24860 kind: SemanticRelatedKind::Concept,
24861 depth: 1,
24862 seed_limit: 5,
24863 limit: 20,
24864 },
24865 &store,
24866 sqlite_graph_freshness(&store, "root").unwrap(),
24867 Vec::new(),
24868 )
24869 .unwrap();
24870 assert_eq!(
24871 graph_related
24872 .readiness
24873 .as_ref()
24874 .map(|readiness| readiness.status.as_str()),
24875 Some("ready"),
24876 "tsift-memory semantic rows should satisfy graph-db related readiness"
24877 );
24878 assert!(
24879 graph_related.nodes.iter().any(|node| {
24880 node.kind == "semantic_concept"
24881 && node.properties.get("provider") == Some(&"tsift-memory".to_string())
24882 }),
24883 "expected related graph output to include tsift-memory semantic rows"
24884 );
24885 }
24886
24887 #[test]
24888 fn semantic_related_query_uses_persisted_graph_embeddings() {
24889 let dir = setup_traversal_project();
24890 seed_traversal_semantic_summaries(dir.path());
24891 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24892 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24893 let semantic_vector_rows: usize = Connection::open(dir.path().join(".tsift/graph.db"))
24894 .unwrap()
24895 .query_row(
24896 "SELECT COUNT(*) FROM graph_node_semantic_vectors",
24897 [],
24898 |row| row_usize(row, 0),
24899 )
24900 .unwrap();
24901 assert!(semantic_vector_rows > 0);
24902
24903 let report = semantic_related_report_from_store(
24904 dir.path(),
24905 None,
24906 "graph navigation",
24907 5,
24908 SemanticRelatedKind::Concept,
24909 &store,
24910 )
24911 .unwrap();
24912
24913 assert_eq!(report.embedding_model, SEMANTIC_EMBEDDING_MODEL);
24914 assert!(
24915 report
24916 .items
24917 .iter()
24918 .any(|item| item.label == "graph navigation"
24919 && item.kind == "semantic_concept"
24920 && item.score > 0.9),
24921 "expected nearest concept match from graph embeddings, got {:?}",
24922 report.items
24923 );
24924 }
24925
24926 #[test]
24927 fn graph_db_related_query_uses_semantic_seeds_and_incident_neighborhoods() {
24928 let dir = setup_traversal_project();
24929 seed_traversal_semantic_summaries(dir.path());
24930 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
24931 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
24932
24933 let report = graph_db_report_from_store(
24934 dir.path(),
24935 None,
24936 "sqlite",
24937 GraphDbQuery::Related {
24938 query: "graph navigation".to_string(),
24939 kind: SemanticRelatedKind::All,
24940 depth: 1,
24941 seed_limit: 2,
24942 limit: 20,
24943 },
24944 &store,
24945 sqlite_graph_freshness(&store, "root").unwrap(),
24946 Vec::new(),
24947 )
24948 .unwrap();
24949
24950 let knowledge = report.knowledge_retrieval.as_ref().unwrap();
24951 assert_eq!(knowledge.mode, "semantic_seeded_neighborhood");
24952 assert_eq!(knowledge.seed_kind, "all");
24953 assert_eq!(knowledge.depth, 1);
24954 assert_eq!(
24955 report
24956 .readiness
24957 .as_ref()
24958 .map(|readiness| readiness.status.as_str()),
24959 Some("ready")
24960 );
24961 assert!(
24962 knowledge
24963 .diagnostics
24964 .iter()
24965 .any(|diagnostic| diagnostic.contains("incident"))
24966 );
24967 assert!(
24968 report
24969 .semantic_related
24970 .iter()
24971 .any(|item| item.label == "graph navigation"
24972 && item.kind == "semantic_concept"
24973 && item.score > 0.9),
24974 "expected natural-language query to seed the graph navigation concept, got {:?}",
24975 report.semantic_related
24976 );
24977 assert!(
24978 report
24979 .nodes
24980 .iter()
24981 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation")
24982 );
24983 assert!(
24984 report
24985 .nodes
24986 .iter()
24987 .any(|node| node.kind == "symbol" && node.label == "helper"),
24988 "incident expansion from semantic seed should recover source symbols, got {:?}",
24989 report
24990 .nodes
24991 .iter()
24992 .map(|node| (&node.kind, &node.label))
24993 .collect::<Vec<_>>()
24994 );
24995 assert!(
24996 report
24997 .edges
24998 .iter()
24999 .any(|edge| edge.kind == "mentions_concept")
25000 );
25001 assert!(
25002 report.output_budget.as_ref().is_some_and(|budget| budget
25003 .diagnostics
25004 .iter()
25005 .any(|diagnostic| { diagnostic.contains("budget ranking signals") })),
25006 "expected related output budget diagnostics, got {:?}",
25007 report.output_budget
25008 );
25009 }
25010
25011 #[test]
25012 fn graph_db_related_reports_summary_extract_gate_when_summary_cache_empty() {
25013 let dir = setup_graph_index();
25014 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25015 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25016
25017 let report = graph_db_report_from_store(
25018 dir.path(),
25019 None,
25020 "sqlite",
25021 GraphDbQuery::Related {
25022 query: "graph navigation".to_string(),
25023 kind: SemanticRelatedKind::All,
25024 depth: 1,
25025 seed_limit: 2,
25026 limit: 20,
25027 },
25028 &store,
25029 sqlite_graph_freshness(&store, "root").unwrap(),
25030 Vec::new(),
25031 )
25032 .unwrap();
25033
25034 let readiness = report.readiness.as_ref().unwrap();
25035 assert_eq!(readiness.status, "blocked");
25036 assert_eq!(readiness.reason, "summary_cache_empty");
25037 assert!(readiness.fail_closed);
25038 assert_eq!(
25039 readiness.next_commands,
25040 vec![
25041 "tsift summarize --extract .".to_string(),
25042 graph_db_refresh_command(dir.path(), None)
25043 ]
25044 );
25045 assert!(
25046 report
25047 .knowledge_retrieval
25048 .as_ref()
25049 .unwrap()
25050 .diagnostics
25051 .iter()
25052 .any(|diagnostic| diagnostic.contains("summary cache empty")
25053 && diagnostic.contains("graph-db materialized code/session rows")),
25054 "expected related diagnostics to carry readiness gate, got {:?}",
25055 report.knowledge_retrieval.as_ref().unwrap().diagnostics
25056 );
25057 }
25058
25059 #[test]
25060 fn graph_db_semantic_seeded_neighborhood_scores_before_caps() {
25061 let mut nodes = vec![
25062 SubstrateGraphNode::new("seed", "semantic_concept", "graph budget"),
25063 SubstrateGraphNode::new("zzz_high", "symbol", "high_signal"),
25064 ];
25065 let mut edges = vec![SubstrateGraphEdge::new(
25066 "zzz_high",
25067 "seed",
25068 "mentions_concept",
25069 )];
25070 for idx in 0..24 {
25071 let id = format!("aaa_low_{idx:02}");
25072 nodes.push(SubstrateGraphNode::new(
25073 id.clone(),
25074 "note",
25075 format!("low {idx}"),
25076 ));
25077 edges.push(SubstrateGraphEdge::new(id, "seed", "weak_link"));
25078 }
25079 let mut store = SqliteGraphStore::in_memory().unwrap();
25080 store
25081 .replace_projection(&GraphProjection { nodes, edges })
25082 .unwrap();
25083
25084 let subgraph =
25085 graph_db_semantic_seeded_neighborhood(&store, &["seed".to_string()], 1, 3).unwrap();
25086
25087 assert_eq!(subgraph.nodes.len(), 3);
25088 assert_eq!(subgraph.nodes[0].id, "seed");
25089 assert_eq!(
25090 subgraph.nodes[1].id, "zzz_high",
25091 "expected semantic mention edge to survive caps before lexicographic low-signal nodes: {:?}",
25092 subgraph.nodes
25093 );
25094 assert!(subgraph.truncated);
25095 assert!(
25096 subgraph
25097 .diagnostics
25098 .iter()
25099 .any(|diagnostic| diagnostic.contains("per-node edge scan cap")),
25100 "{:?}",
25101 subgraph.diagnostics
25102 );
25103 assert!(
25104 subgraph
25105 .diagnostics
25106 .iter()
25107 .any(|diagnostic| diagnostic.contains("skipped")),
25108 "{:?}",
25109 subgraph.diagnostics
25110 );
25111 }
25112
25113 #[test]
25114 fn conflict_matrix_uses_semantic_rows_as_dispatch_ranking_signal() {
25115 let dir = setup_traversal_project();
25116 seed_traversal_semantic_summaries(dir.path());
25117 init_git_repo(dir.path());
25118 let session = dir.path().join("tasks/software/tsift.md");
25119 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25120 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25121 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25122 let evidence = graph_db_evidence_report_from_store(GraphDbEvidenceInput {
25123 root: dir.path(),
25124 scope: None,
25125 backend: "sqlite",
25126 target: "kgnv",
25127 preferred_path: None,
25128 depth: 4,
25129 limit: 8,
25130 cursor: None,
25131 store: &store,
25132 freshness,
25133 warnings: Vec::new(),
25134 })
25135 .unwrap();
25136 assert!(
25137 evidence
25138 .semantic_related
25139 .iter()
25140 .any(|node| node.kind == "semantic_concept" && node.label == "graph navigation"),
25141 "expected semantic evidence rows, got {:?}",
25142 evidence
25143 .semantic_related
25144 .iter()
25145 .map(|node| (&node.kind, &node.label))
25146 .collect::<Vec<_>>()
25147 );
25148 assert!(
25149 evidence
25150 .output_budget
25151 .as_ref()
25152 .is_some_and(|budget| budget.diagnostics.iter().any(|diagnostic| {
25153 diagnostic.contains("semantic_match")
25154 && diagnostic.contains("source_handle_coverage")
25155 })),
25156 "expected evidence output budget diagnostics, got {:?}",
25157 evidence.output_budget
25158 );
25159
25160 let cached_diff = diff_digest::compute(
25161 dir.path(),
25162 diff_digest::DiffDigestOptions {
25163 cached: true,
25164 revision: None,
25165 max_parsed_files: None,
25166 },
25167 )
25168 .unwrap();
25169 let impact_report = impact::compute(
25170 dir.path(),
25171 impact::ImpactOptions {
25172 cached: true,
25173 revision: None,
25174 scope: None,
25175 limit: 10,
25176 },
25177 )
25178 .unwrap();
25179 let graph_nodes = store.all_nodes().unwrap();
25180 let graph_index = conflict_matrix_graph_index(&graph_nodes);
25181 let semantic_candidate = conflict_matrix_candidate_from_evidence(
25182 dir.path(),
25183 &evidence,
25184 &graph_index,
25185 &cached_diff,
25186 &impact_report,
25187 );
25188 assert!(semantic_candidate.semantic_dispatch_score > 0);
25189 assert!(
25190 semantic_candidate
25191 .semantic_dispatch_reasons
25192 .iter()
25193 .any(|reason| reason.contains("semantic_concept") && reason.contains("owned file")),
25194 "expected semantic ranking explanations, got {:?}",
25195 semantic_candidate.semantic_dispatch_reasons
25196 );
25197 assert!(
25198 semantic_candidate
25199 .semantic_related
25200 .iter()
25201 .any(|item| item.label == "graph navigation")
25202 );
25203
25204 let mut plain_candidate = semantic_candidate.clone();
25205 plain_candidate.target = "plain".to_string();
25206 plain_candidate.semantic_related.clear();
25207 plain_candidate.semantic_dispatch_score = 0;
25208 plain_candidate.semantic_dispatch_reasons.clear();
25209 let mut ranked = [plain_candidate, semantic_candidate];
25210 ranked.sort_by(|left, right| {
25211 left.risk
25212 .cmp(&right.risk)
25213 .then_with(|| left.risk_score.cmp(&right.risk_score))
25214 .then_with(|| {
25215 right
25216 .semantic_dispatch_score
25217 .cmp(&left.semantic_dispatch_score)
25218 })
25219 .then_with(|| left.target.cmp(&right.target))
25220 });
25221 assert_eq!(ranked[0].target, "kgnv");
25222 }
25223
25224 #[test]
25225 fn dependency_dag_extracts_explicit_overlap_and_follow_up_edges() {
25226 let dir = setup_dependency_dag_project();
25227 let session = dir.path().join("tasks/software/tsift.md");
25228 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25229
25230 assert_eq!(report.contract_version, "dependency-dag-v1");
25231 assert_eq!(
25232 report.targets,
25233 vec![
25234 "prep".to_string(),
25235 "alpha".to_string(),
25236 "beta".to_string(),
25237 "gamma".to_string()
25238 ]
25239 );
25240 assert!(report.edges.iter().any(|edge| {
25241 edge.from == "prep" && edge.to == "alpha" && edge.kind == "explicit_depends_on"
25242 }));
25243 assert!(report.edges.iter().any(|edge| {
25244 edge.from == "alpha" && edge.to == "gamma" && edge.kind == "worker_result_follow_up"
25245 }));
25246 assert!(report.edges.iter().any(|edge| {
25247 edge.from == "alpha"
25248 && edge.to == "beta"
25249 && edge.kind == "shared_resource"
25250 && edge.shared_files.contains(&"main.rs".to_string())
25251 && edge.shared_symbols.contains(&"shared_helper".to_string())
25252 }));
25253 assert!(
25254 !report.cycle_diagnostics.has_cycles,
25255 "{:?}",
25256 report.cycle_diagnostics
25257 );
25258 assert_eq!(report.topo_batches[0].targets, vec!["prep".to_string()]);
25259 assert_eq!(report.topo_batches[1].targets, vec!["alpha".to_string()]);
25260 assert!(
25261 report.replay_commands[0].contains("dependency-dag"),
25262 "{:?}",
25263 report.replay_commands
25264 );
25265
25266 cmd_dependency_dag(
25267 &session,
25268 None,
25269 &["alpha".to_string(), "beta".to_string()],
25270 4,
25271 12,
25272 OutputFormat {
25273 json_output: true,
25274 compact: false,
25275 pretty: false,
25276 terse: false,
25277 ultra_terse: false,
25278 schema: false,
25279 envelope: false,
25280 },
25281 )
25282 .unwrap();
25283 }
25284
25285 #[test]
25286 fn dependency_dag_reports_cycles_from_explicit_depends_on_text() {
25287 let dir = setup_dependency_dag_cycle_project();
25288 let report = build_dependency_dag_report(dir.path(), None, &[], 4, 12).unwrap();
25289
25290 assert!(report.cycle_diagnostics.has_cycles);
25291 assert_eq!(
25292 report.cycle_diagnostics.blocked_nodes,
25293 vec!["left".to_string(), "right".to_string()]
25294 );
25295 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25296 edge.from == "left" && edge.to == "right" && edge.kind == "explicit_depends_on"
25297 }));
25298 assert!(report.cycle_diagnostics.cycle_edges.iter().any(|edge| {
25299 edge.from == "right" && edge.to == "left" && edge.kind == "explicit_depends_on"
25300 }));
25301 }
25302
25303 #[test]
25304 fn traversal_projection_queries_match_sqlite_and_convex_stores() {
25305 let dir = setup_traversal_project();
25306 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
25307 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
25308
25309 let mut sqlite = SqliteGraphStore::in_memory().unwrap();
25310 sqlite.replace_projection(&projection).unwrap();
25311 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
25312 projection.upsert_into(&convex).unwrap();
25313
25314 let sqlite_graph = traversal_graph_from_store(dir.path(), &sqlite).unwrap();
25315 let convex_graph = traversal_graph_from_store(dir.path(), &convex).unwrap();
25316 assert_eq!(sqlite_graph.nodes.len(), convex_graph.nodes.len());
25317 assert_eq!(sqlite_graph.edges.len(), convex_graph.edges.len());
25318
25319 let sqlite_backlog = resolve_traversal_node(&sqlite_graph, "#kgnv").unwrap();
25320 let convex_helper = resolve_traversal_node(&convex_graph, "helper").unwrap();
25321 assert!(convex_graph.edges.iter().any(|edge| {
25322 edge.from == sqlite_backlog.handle
25323 && edge.to == convex_helper.handle
25324 && edge.relation == "mentions"
25325 }));
25326 }
25327
25328 #[test]
25329 fn graph_db_api_queries_sqlite_neighborhood_and_schema() {
25330 let dir = setup_traversal_project();
25331 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
25332 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
25333 let freshness = sqlite_graph_freshness(&store, "root").unwrap();
25334 assert_eq!(freshness.status, "current");
25335
25336 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
25337 let report = graph_db_report_from_store(
25338 dir.path(),
25339 None,
25340 "sqlite",
25341 GraphDbQuery::Neighborhood {
25342 id: backlog.handle.clone(),
25343 depth: 1,
25344 edge_kind: Some("mentions".to_string()),
25345 cursor: None,
25346 limit: None,
25347 property_filters: Vec::new(),
25348 },
25349 &store,
25350 freshness,
25351 Vec::new(),
25352 )
25353 .unwrap();
25354 assert!(
25355 report
25356 .edges
25357 .iter()
25358 .any(|edge| edge.from_id == backlog.handle && edge.kind == "mentions"),
25359 "expected backlog mention edge, got {:?}",
25360 report.edges
25361 );
25362 assert!(
25363 report.ranked_neighbors.iter().any(|neighbor| {
25364 neighbor.depth == Some(1)
25365 && neighbor.edge_kinds.iter().any(|kind| kind == "mentions")
25366 && neighbor.node_id != backlog.handle
25367 && neighbor.handle_coverage_pct >= 95.0
25368 && neighbor.duplicate_name_precision >= 0.99
25369 }),
25370 "expected ranked neighborhood neighbors with quality scores, got {:?}",
25371 report.ranked_neighbors
25372 );
25373 assert!(report.ranked_neighbors.len() <= GRAPH_DB_RANKED_NEIGHBOR_CAP);
25374 let ranking_gate = report.neighborhood_ranking_gate.as_ref().unwrap();
25375 assert!(!ranking_gate.ranked_output_default);
25376 assert_eq!(ranking_gate.default_order, "stable_node_id");
25377 assert!(
25378 ranking_gate
25379 .diagnostics
25380 .iter()
25381 .any(|diagnostic| diagnostic.contains("score-capped")),
25382 "{ranking_gate:?}"
25383 );
25384 assert!(
25385 ranking_gate
25386 .required_metrics
25387 .iter()
25388 .any(|metric| metric == "handle_coverage_pct")
25389 );
25390 assert!(
25391 ranking_gate
25392 .required_metrics
25393 .iter()
25394 .any(|metric| metric == "duplicate_name_precision")
25395 );
25396 assert!(
25397 report
25398 .page
25399 .as_ref()
25400 .unwrap()
25401 .diagnostics
25402 .iter()
25403 .any(|diagnostic| diagnostic.contains("idx_graph_edges_from_kind")),
25404 "expected SQLite neighborhood query plan diagnostics, got {:?}",
25405 report.page.as_ref().unwrap().diagnostics
25406 );
25407 let edges_report = graph_db_report_from_store(
25408 dir.path(),
25409 None,
25410 "sqlite",
25411 GraphDbQuery::Edges {
25412 edge_kind: Some("mentions".to_string()),
25413 cursor: None,
25414 limit: Some(2),
25415 property_filters: Vec::new(),
25416 },
25417 &store,
25418 sqlite_graph_freshness(&store, "root").unwrap(),
25419 Vec::new(),
25420 )
25421 .unwrap();
25422 let edge_id = edges_report
25423 .edges
25424 .first()
25425 .map(|edge| edge.id.clone())
25426 .expect("expected at least one paged mentions edge");
25427 assert!(edges_report.edges.iter().any(|edge| edge.id == edge_id));
25428 assert_eq!(
25429 edges_report.page.as_ref().unwrap().returned_edges,
25430 edges_report.edges.len()
25431 );
25432
25433 let edge_report = graph_db_report_from_store(
25434 dir.path(),
25435 None,
25436 "sqlite",
25437 GraphDbQuery::Edge {
25438 id: edge_id.clone(),
25439 },
25440 &store,
25441 sqlite_graph_freshness(&store, "root").unwrap(),
25442 Vec::new(),
25443 )
25444 .unwrap();
25445 assert_eq!(
25446 edge_report
25447 .edge
25448 .as_ref()
25449 .map(|e| graph_db_edge_key(&SubstrateGraphEdge::from(e))),
25450 Some(edge_id.clone())
25451 );
25452
25453 let incident_report = graph_db_report_from_store(
25454 dir.path(),
25455 None,
25456 "sqlite",
25457 GraphDbQuery::Incident {
25458 id: backlog.handle.clone(),
25459 edge_kind: Some("mentions".to_string()),
25460 cursor: None,
25461 limit: Some(1),
25462 property_filters: Vec::new(),
25463 },
25464 &store,
25465 sqlite_graph_freshness(&store, "root").unwrap(),
25466 Vec::new(),
25467 )
25468 .unwrap();
25469 assert_eq!(incident_report.page.as_ref().unwrap().returned_edges, 1);
25470 assert!(
25471 incident_report
25472 .edges
25473 .iter()
25474 .all(|edge| edge.from_id == backlog.handle || edge.to_id == backlog.handle),
25475 "{:?}",
25476 incident_report.edges
25477 );
25478
25479 let schema_report = graph_db_report_from_store(
25480 dir.path(),
25481 None,
25482 "sqlite",
25483 GraphDbQuery::Schema,
25484 &store,
25485 sqlite_graph_freshness(&store, "root").unwrap(),
25486 Vec::new(),
25487 )
25488 .unwrap();
25489 assert!(
25490 schema_report
25491 .schema
25492 .unwrap()
25493 .operations
25494 .iter()
25495 .any(|operation| operation.command.starts_with("neighborhood"))
25496 );
25497 }
25498
25499 #[test]
25500 fn graph_db_neighborhood_reports_dropped_by_budget_diagnostics() {
25501 let mut nodes = vec![SubstrateGraphNode::new(
25502 "origin",
25503 "backlog",
25504 "#budgeted-neighborhood",
25505 )];
25506 let mut edges = Vec::new();
25507 for idx in 0..32 {
25508 let id = format!("src-{idx:02}");
25509 nodes.push(
25510 SubstrateGraphNode::new(id.clone(), "source_handle", format!("source {idx}"))
25511 .with_property("source_ref", format!("fixture:{idx}"))
25512 .with_property("detail", "x".repeat(600)),
25513 );
25514 edges.push(SubstrateGraphEdge::new("origin", id, "mentions"));
25515 }
25516 let store = SqliteGraphStore::in_memory().unwrap();
25517 GraphProjection { nodes, edges }
25518 .upsert_into(&store)
25519 .unwrap();
25520
25521 let report = graph_db_report_from_store(
25522 Path::new("."),
25523 None,
25524 "fixture",
25525 GraphDbQuery::Neighborhood {
25526 id: "origin".to_string(),
25527 depth: 1,
25528 edge_kind: None,
25529 cursor: None,
25530 limit: None,
25531 property_filters: Vec::new(),
25532 },
25533 &store,
25534 current_graph_db_freshness(),
25535 Vec::new(),
25536 )
25537 .unwrap();
25538 let budget = report.output_budget.as_ref().unwrap();
25539 assert!(budget.selected_nodes < budget.candidate_nodes);
25540 assert!(
25541 budget.dropped_by_budget.iter().any(|drop| {
25542 drop.item == "node"
25543 && drop.kind == "source_handle"
25544 && drop.reason == "per_kind_quota"
25545 }),
25546 "expected source_handle budget drops, got {:?}",
25547 budget.dropped_by_budget
25548 );
25549 assert!(report.page.as_ref().unwrap().truncated);
25550 assert!(
25551 report
25552 .page
25553 .as_ref()
25554 .unwrap()
25555 .diagnostics
25556 .iter()
25557 .any(|diagnostic| diagnostic.contains("budget ranking signals")),
25558 "{:?}",
25559 report.page
25560 );
25561 }
25562
25563 #[test]
25564 fn graph_db_output_budget_uses_depth_overrides_for_evidence_rows() {
25565 let mut nodes = vec![SubstrateGraphNode::new("near", "note", "zzz shallow row")];
25566 let mut depth_by_id = BTreeMap::from([("near".to_string(), 1usize)]);
25567 for idx in 0..8 {
25568 let id = format!("far-{idx:02}");
25569 nodes.push(SubstrateGraphNode::new(
25570 id.clone(),
25571 "note",
25572 format!("aaa deeper row {idx}"),
25573 ));
25574 depth_by_id.insert(id, 6);
25575 }
25576
25577 let origin_ids = vec!["target".to_string()];
25578 let budgeted = graph_db_apply_output_budget_with_depths_and_cursor(
25579 &origin_ids,
25580 &BTreeMap::new(),
25581 nodes,
25582 Vec::new(),
25583 Some(3),
25584 Some(&depth_by_id),
25585 None,
25586 );
25587
25588 assert!(
25589 budgeted.nodes.iter().any(|node| node.id == "near"),
25590 "expected the shallow evidence row to outrank deeper rows, got {:?}",
25591 budgeted
25592 .nodes
25593 .iter()
25594 .map(|node| (&node.id, &node.label))
25595 .collect::<Vec<_>>()
25596 );
25597 assert!(
25598 budgeted.report.dropped_by_budget.iter().any(|drop| {
25599 drop.item == "node" && drop.kind == "note" && drop.reason == "per_kind_quota"
25600 }),
25601 "expected node quota drops, got {:?}",
25602 budgeted.report.dropped_by_budget
25603 );
25604 assert!(
25605 budgeted
25606 .report
25607 .diagnostics
25608 .iter()
25609 .any(|diagnostic| diagnostic.contains("depth")),
25610 "{:?}",
25611 budgeted.report.diagnostics
25612 );
25613 }
25614
25615 #[test]
25616 fn evidence_pagination_returns_next_cursor_when_truncated() {
25617 let mut nodes = vec![SubstrateGraphNode::new(
25618 "target".to_string(),
25619 "backlog_item",
25620 "target item".to_string(),
25621 )];
25622 let mut depth_by_id = BTreeMap::new();
25623 depth_by_id.insert("target".to_string(), 0);
25624 for idx in 0..20 {
25625 let id = format!("ev-{idx}");
25626 nodes.push(
25627 SubstrateGraphNode::new(id.clone(), "source_handle", format!("evidence row {idx}"))
25628 .with_property("detail", "x".repeat(400)),
25629 );
25630 depth_by_id.insert(id, 1);
25631 }
25632 let origin_ids = vec!["target".to_string()];
25633 let first_page = graph_db_apply_output_budget_with_depths_and_cursor(
25634 &origin_ids,
25635 &BTreeMap::new(),
25636 nodes.clone(),
25637 Vec::new(),
25638 Some(3),
25639 Some(&depth_by_id),
25640 None,
25641 );
25642 assert!(
25643 first_page.truncated,
25644 "expected first page to be truncated with 20 candidates and low limit, got {} selected of {} candidates",
25645 first_page.nodes.len(),
25646 first_page.report.candidate_nodes
25647 );
25648 assert!(
25649 first_page.next_cursor.is_some(),
25650 "expected next_cursor when truncated"
25651 );
25652 let cursor = first_page.next_cursor.unwrap();
25653 assert!(!cursor.is_empty(), "cursor should be a non-empty node id");
25654 let first_ids: BTreeSet<_> = first_page.nodes.iter().map(|n| n.id.clone()).collect();
25655 let second_page = graph_db_apply_output_budget_with_depths_and_cursor(
25656 &origin_ids,
25657 &BTreeMap::new(),
25658 nodes.clone(),
25659 Vec::new(),
25660 Some(3),
25661 Some(&depth_by_id),
25662 Some(&cursor),
25663 );
25664 let second_ids: BTreeSet<_> = second_page.nodes.iter().map(|n| n.id.clone()).collect();
25665 let overlap: BTreeSet<_> = first_ids.intersection(&second_ids).cloned().collect();
25666 assert!(
25667 overlap.is_empty(),
25668 "pages should not overlap, but found shared ids: {overlap:?}"
25669 );
25670 assert!(
25671 second_page
25672 .report
25673 .diagnostics
25674 .iter()
25675 .any(|d| d.contains("cursor skipped")),
25676 "expected cursor skip diagnostic, got {:?}",
25677 second_page.report.diagnostics
25678 );
25679 }
25680
25681 #[test]
25682 fn evidence_pagination_no_cursor_returns_all_when_within_budget() {
25683 let mut nodes = vec![SubstrateGraphNode::new(
25684 "target".to_string(),
25685 "backlog_item",
25686 "target item".to_string(),
25687 )];
25688 let mut depth_by_id = BTreeMap::new();
25689 depth_by_id.insert("target".to_string(), 0);
25690 for idx in 0..3 {
25691 let id = format!("ev-{idx}");
25692 nodes.push(SubstrateGraphNode::new(
25693 id.clone(),
25694 "source_handle",
25695 format!("evidence row {idx}"),
25696 ));
25697 depth_by_id.insert(id, 1);
25698 }
25699 let origin_ids = vec!["target".to_string()];
25700 let result = graph_db_apply_output_budget_with_depths_and_cursor(
25701 &origin_ids,
25702 &BTreeMap::new(),
25703 nodes,
25704 Vec::new(),
25705 None,
25706 Some(&depth_by_id),
25707 None,
25708 );
25709 assert!(
25710 !result.truncated,
25711 "expected no truncation with small candidate set and default budget"
25712 );
25713 assert!(
25714 result.next_cursor.is_none(),
25715 "expected no next_cursor when not truncated"
25716 );
25717 }
25718
25719 #[test]
25720 fn evidence_pagination_invalid_cursor_returns_first_page() {
25721 let mut nodes = vec![SubstrateGraphNode::new(
25722 "target".to_string(),
25723 "backlog_item",
25724 "target item".to_string(),
25725 )];
25726 let mut depth_by_id = BTreeMap::new();
25727 depth_by_id.insert("target".to_string(), 0);
25728 for idx in 0..5 {
25729 let id = format!("ev-{idx}");
25730 nodes.push(SubstrateGraphNode::new(
25731 id.clone(),
25732 "source_handle",
25733 format!("evidence row {idx}"),
25734 ));
25735 depth_by_id.insert(id, 1);
25736 }
25737 let origin_ids = vec!["target".to_string()];
25738 let result = graph_db_apply_output_budget_with_depths_and_cursor(
25739 &origin_ids,
25740 &BTreeMap::new(),
25741 nodes.clone(),
25742 Vec::new(),
25743 None,
25744 Some(&depth_by_id),
25745 Some("nonexistent-id"),
25746 );
25747 assert!(
25748 result
25749 .report
25750 .diagnostics
25751 .iter()
25752 .any(|d| d.contains("cursor skipped 0")),
25753 "invalid cursor should skip 0 candidates, got {:?}",
25754 result.report.diagnostics
25755 );
25756 }
25757
25758 #[test]
25759 fn graph_db_status_uses_snapshot_fallback_when_rollback_journal_is_locked() {
25760 let dir = setup_traversal_project();
25761 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25762 let graph_db = dir.path().join(".tsift/graph.db");
25763 let _lock = hold_rollback_journal_lock(&graph_db);
25764
25765 let report =
25766 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25767 .unwrap();
25768
25769 assert_eq!(report.status, "current");
25770 assert_eq!(
25771 report.recovery,
25772 Some(index::ReadOnlyRecovery::SnapshotFallback)
25773 );
25774 assert!(
25775 report
25776 .warnings
25777 .iter()
25778 .any(|warning| warning.contains("rollback-journal lock")),
25779 "expected rollback-journal recovery warning, got {:?}",
25780 report.warnings
25781 );
25782 }
25783
25784 #[test]
25785 fn graph_db_status_copies_wal_sidecars_when_locked() {
25786 let dir = setup_traversal_project();
25787 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25788 let graph_db = dir.path().join(".tsift/graph.db");
25789 let _lock = hold_wal_database_lock(&graph_db);
25790
25791 let report =
25792 graph_db_operator_report_from_disk(dir.path(), None, &graph_db, "status", None, vec![])
25793 .unwrap();
25794
25795 assert_eq!(report.status, "current");
25796 assert_eq!(
25797 report.recovery,
25798 Some(index::ReadOnlyRecovery::SnapshotFallbackWal)
25799 );
25800 assert!(
25801 report
25802 .warnings
25803 .iter()
25804 .any(|warning| warning.contains("WAL-aware snapshot fallback")),
25805 "expected WAL recovery warning, got {:?}",
25806 report.warnings
25807 );
25808 }
25809
25810 #[test]
25811 fn graph_db_doctor_reports_snapshot_fallback_when_rollback_journal_is_locked() {
25812 let dir = setup_traversal_project();
25813 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25814 let graph_db = dir.path().join(".tsift/graph.db");
25815 let _lock = hold_rollback_journal_lock(&graph_db);
25816
25817 let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25818 append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25819 report.finalize();
25820
25821 assert_eq!(report.status, "ok");
25822 assert!(!report.fail_closed);
25823 let recovery_check = report
25824 .checks
25825 .iter()
25826 .find(|check| check.name == "sqlite_graph_db_read_recovery")
25827 .expect("doctor should include read recovery diagnostic");
25828 assert_eq!(recovery_check.status, "recovered");
25829 assert!(
25830 recovery_check
25831 .diagnostics
25832 .iter()
25833 .any(|diagnostic| diagnostic.contains("rollback-journal lock")),
25834 "expected rollback-journal recovery diagnostic, got {:?}",
25835 recovery_check.diagnostics
25836 );
25837 }
25838
25839 #[test]
25840 fn graph_db_doctor_reports_wal_snapshot_fallback_when_locked() {
25841 let dir = setup_traversal_project();
25842 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25843 let graph_db = dir.path().join(".tsift/graph.db");
25844 let _lock = hold_wal_database_lock(&graph_db);
25845
25846 let mut report = GraphDbDoctorReport::new(dir.path(), None, "sqlite", &graph_db, None);
25847 append_sqlite_graph_doctor_checks(&mut report, dir.path(), None, &graph_db);
25848 report.finalize();
25849
25850 assert_eq!(report.status, "ok");
25851 assert!(!report.fail_closed);
25852 let recovery_check = report
25853 .checks
25854 .iter()
25855 .find(|check| check.name == "sqlite_graph_db_read_recovery")
25856 .expect("doctor should include WAL read recovery diagnostic");
25857 assert_eq!(recovery_check.status, "recovered");
25858 assert!(
25859 recovery_check
25860 .diagnostics
25861 .iter()
25862 .any(|diagnostic| diagnostic.contains("WAL-aware snapshot fallback")),
25863 "expected WAL recovery diagnostic, got {:?}",
25864 recovery_check.diagnostics
25865 );
25866 }
25867
25868 #[test]
25869 fn graph_db_snapshot_export_import_round_trip_preserves_projection_metadata() {
25870 let dir = setup_traversal_project();
25871 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25872 let artifact = dir.path().join("graph.db.gz");
25873
25874 let exported =
25875 commands::infra::graph_db_snapshot_export_report(dir.path(), None, &artifact, false)
25876 .unwrap();
25877 let exported_projection_version = exported.freshness.projection_version.clone();
25878 let exported_content_hash = exported.freshness.content_hash.clone();
25879 let exported_source_watermark = exported.freshness.source_watermark.clone();
25880 let exported_nodes = exported.counts.nodes;
25881 let exported_edges = exported.counts.edges;
25882 assert_eq!(exported.operation, "snapshot-export");
25883 assert!(exported.status.starts_with("exported"));
25884 assert!(artifact.exists());
25885 assert!(exported.artifact_bytes > 0);
25886 assert_eq!(exported.compression, "gzip");
25887
25888 fs::remove_file(dir.path().join(".tsift/graph.db")).unwrap();
25889
25890 let imported =
25891 commands::infra::graph_db_snapshot_import_report(dir.path(), None, &artifact, false)
25892 .unwrap();
25893 assert_eq!(imported.operation, "snapshot-import");
25894 assert!(imported.status.starts_with("imported"));
25895 assert_eq!(
25896 imported.freshness.projection_version,
25897 exported_projection_version
25898 );
25899 assert_eq!(imported.freshness.content_hash, exported_content_hash);
25900 assert_eq!(
25901 imported.freshness.source_watermark,
25902 exported_source_watermark
25903 );
25904 assert_eq!(imported.counts.nodes, exported_nodes);
25905 assert_eq!(imported.counts.edges, exported_edges);
25906 assert!(dir.path().join(".tsift/graph.db").exists());
25907 }
25908
25909 #[test]
25910 fn graph_db_snapshot_export_fails_closed_when_wal_lock_requires_recovery() {
25911 let dir = setup_traversal_project();
25912 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25913 let graph_db = dir.path().join(".tsift/graph.db");
25914 let _lock = hold_wal_database_lock(&graph_db);
25915
25916 let err = match commands::infra::graph_db_snapshot_export_report(
25917 dir.path(),
25918 None,
25919 &dir.path().join("graph.db.gz"),
25920 false,
25921 ) {
25922 Ok(report) => panic!("expected snapshot export to fail, got {}", report.status),
25923 Err(err) => err,
25924 };
25925
25926 let message = err.to_string();
25932 assert!(
25933 message.contains("recovered path")
25934 && message.contains("database is locked")
25935 && message.contains("wait for it to finish before retrying the export"),
25936 "expected unified live-lock recovery diagnostic, got {err:#}"
25937 );
25938 }
25939
25940 #[test]
25941 fn graph_db_snapshot_clean_export_maps_database_locked_to_live_lock_diagnostic() {
25942 let dir = setup_traversal_project();
25943 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
25944 let graph_db = dir.path().join(".tsift/graph.db");
25945
25946 let blocker = Connection::open(&graph_db).unwrap();
25950 blocker
25951 .execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
25952 .unwrap();
25953 assert!(!substrate::rollback_journal_path(&graph_db).exists());
25954
25955 let clean_path = dir.path().join("graph-clean-export.db");
25956 let err = match commands::infra::graph_db_snapshot_clean_export_copy(&graph_db, &clean_path)
25957 {
25958 Ok(bytes) => panic!("expected export to fail under live lock, got {bytes} bytes"),
25959 Err(err) => err,
25960 };
25961
25962 let message = err.to_string();
25963 assert!(
25964 message.contains("concurrent graph-db refresh or snapshot-import is in progress"),
25965 "expected actionable live-lock diagnostic, got {err:#}"
25966 );
25967 assert!(
25968 message.contains("wait for it to finish before retrying"),
25969 "expected retry guidance, got {err:#}"
25970 );
25971 assert!(
25973 !message.contains("creating clean graph-db export copy"),
25974 "live-lock case must not surface the generic VACUUM context, got {err:#}"
25975 );
25976
25977 drop(blocker);
25978 }
25979
25980 #[test]
25981 fn graph_db_evidence_uses_snapshot_fallback_when_graph_db_is_locked() {
25982 let dir = setup_traversal_project();
25983 let session = dir.path().join("tasks/software/tsift.md");
25984 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
25985 let graph_db = dir.path().join(".tsift/graph.db");
25986 let _lock = hold_rollback_journal_lock(&graph_db);
25987
25988 let result = cmd_graph_db(
25989 &session,
25990 None,
25991 GraphDbBackend::Sqlite,
25992 None,
25993 GraphDbQuery::Evidence {
25994 target: "kgnv".to_string(),
25995 depth: 3,
25996 limit: 8,
25997 cursor: None,
25998 },
25999 OutputFormat {
26000 json_output: false,
26001 compact: true,
26002 pretty: false,
26003 terse: false,
26004 ultra_terse: false,
26005 schema: false,
26006 envelope: false,
26007 },
26008 );
26009
26010 assert!(result.is_ok());
26011 }
26012
26013 fn current_graph_db_freshness() -> GraphDbFreshnessReport {
26014 GraphDbFreshnessReport {
26015 status: "current".to_string(),
26016 fail_closed: false,
26017 projection_version: Some(GRAPH_PROJECTION_VERSION.to_string()),
26018 content_hash: Some("fixture".to_string()),
26019 source_watermark: None,
26020 diagnostics: Vec::new(),
26021 }
26022 }
26023
26024 #[test]
26025 fn graph_db_evidence_fails_closed_with_repair_command_for_stale_freshness() {
26026 let dir = setup_traversal_project();
26027 refresh_traversal_graph_store(dir.path(), dir.path(), None).unwrap();
26028 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
26029 let stale = GraphDbFreshnessReport {
26030 status: "stale".to_string(),
26031 fail_closed: true,
26032 projection_version: Some("old-v0".to_string()),
26033 content_hash: None,
26034 source_watermark: None,
26035 diagnostics: vec!["projection content hash is missing".to_string()],
26036 };
26037
26038 let err = match graph_db_evidence_report_from_store(GraphDbEvidenceInput {
26039 root: dir.path(),
26040 scope: None,
26041 backend: "sqlite",
26042 target: "kgnv",
26043 preferred_path: None,
26044 depth: 3,
26045 limit: 8,
26046 cursor: None,
26047 store: &store,
26048 freshness: stale,
26049 warnings: Vec::new(),
26050 }) {
26051 Ok(_) => panic!("stale graph freshness should fail closed"),
26052 Err(err) => err,
26053 };
26054 let message = err.to_string();
26055 assert!(message.contains("failed closed"), "{message}");
26056 assert!(message.contains("graph-db --path"), "{message}");
26057 assert!(message.contains("refresh --json"), "{message}");
26058 }
26059
26060 fn paged_graph_ids(
26061 store: &impl GraphStore,
26062 cursor: Option<&str>,
26063 ) -> (Vec<String>, GraphDbPageReport) {
26064 let report = graph_db_report_from_store(
26065 Path::new("."),
26066 None,
26067 "fixture",
26068 GraphDbQuery::Kind {
26069 kind: "backlog".to_string(),
26070 cursor: cursor.map(str::to_string),
26071 limit: Some(2),
26072 property_filters: vec!["phase=open".to_string()],
26073 },
26074 store,
26075 current_graph_db_freshness(),
26076 Vec::new(),
26077 )
26078 .unwrap();
26079 (
26080 report.nodes.iter().map(|node| node.id.clone()).collect(),
26081 report.page.unwrap(),
26082 )
26083 }
26084
26085 #[test]
26086 fn graph_db_query_pagination_and_filters_match_sqlite_and_convex() {
26087 let nodes = (0..5)
26088 .map(|idx| {
26089 let phase = if idx == 1 { "closed" } else { "open" };
26090 SubstrateGraphNode::new(format!("gbak-{idx:02}"), "backlog", format!("#{idx:02}"))
26091 .with_property("phase", phase)
26092 })
26093 .collect::<Vec<_>>();
26094 let projection = GraphProjection {
26095 nodes,
26096 edges: Vec::new(),
26097 };
26098 let sqlite = SqliteGraphStore::in_memory().unwrap();
26099 projection.upsert_into(&sqlite).unwrap();
26100 let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
26101 projection.upsert_into(&convex).unwrap();
26102
26103 let (sqlite_first_ids, sqlite_first_page) = paged_graph_ids(&sqlite, None);
26104 let (convex_first_ids, convex_first_page) = paged_graph_ids(&convex, None);
26105 assert_eq!(sqlite_first_ids, vec!["gbak-00", "gbak-02"]);
26106 assert_eq!(sqlite_first_ids, convex_first_ids);
26107 assert_eq!(sqlite_first_page.next_cursor.as_deref(), Some("gbak-02"));
26108 assert!(sqlite_first_page.truncated);
26109 assert_eq!(
26110 sqlite_first_page.returned_nodes,
26111 convex_first_page.returned_nodes
26112 );
26113 assert_eq!(
26114 sqlite_first_page.property_filters,
26115 convex_first_page.property_filters
26116 );
26117 assert!(
26118 sqlite_first_page
26119 .diagnostics
26120 .iter()
26121 .any(|diagnostic| diagnostic.contains("idx_graph_nodes_kind")),
26122 "expected SQLite kind query plan diagnostics, got {:?}",
26123 sqlite_first_page.diagnostics
26124 );
26125
26126 let cursor = sqlite_first_page.next_cursor.as_deref();
26127 let (sqlite_next_ids, sqlite_next_page) = paged_graph_ids(&sqlite, cursor);
26128 let (convex_next_ids, convex_next_page) = paged_graph_ids(&convex, cursor);
26129 assert_eq!(sqlite_next_ids, vec!["gbak-03", "gbak-04"]);
26130 assert_eq!(sqlite_next_ids, convex_next_ids);
26131 assert_eq!(sqlite_next_page.next_cursor, None);
26132 assert!(!sqlite_next_page.truncated);
26133 assert_eq!(
26134 sqlite_next_page.returned_nodes,
26135 convex_next_page.returned_nodes
26136 );
26137 assert_eq!(
26138 sqlite_next_page.property_filters,
26139 convex_next_page.property_filters
26140 );
26141 }
26142
26143 #[test]
26144 fn traversal_shortest_path_crosses_artifacts_and_symbols() {
26145 let dir = setup_traversal_project();
26146 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26147 let backlog = resolve_traversal_node(&graph, "#kgnv").unwrap();
26148 let main = resolve_traversal_node(&graph, "main").unwrap();
26149
26150 let path = traversal_shortest_handles(&graph.edges, &backlog.handle, &main.handle).unwrap();
26151 assert_eq!(path.first(), Some(&backlog.handle));
26152 assert_eq!(path.last(), Some(&main.handle));
26153 assert!(
26154 path.len() >= 3,
26155 "expected backlog -> symbol -> main, got {path:?}"
26156 );
26157 }
26158
26159 #[test]
26160 fn traversal_report_recommends_next_bugfix_nodes() {
26161 let dir = setup_traversal_project();
26162 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26163 let report = traversal_report(dir.path(), None, graph, Some("#kgnv"), None, 1, 50).unwrap();
26164
26165 assert_eq!(report.mode, "neighborhood");
26166 assert!(
26167 report
26168 .recommendations
26169 .iter()
26170 .any(|rec| rec.label == "helper" && rec.reason.contains("matched")),
26171 "expected helper recommendation, got {:?}",
26172 report.recommendations
26173 );
26174 assert!(
26175 !report.exploration.source_windows.is_empty(),
26176 "expected exploration source windows"
26177 );
26178 assert!(
26179 report
26180 .exploration
26181 .no_reread_guidance
26182 .contains("avoid whole-file reads")
26183 );
26184 }
26185
26186 #[test]
26187 fn traversal_graph_refreshes_stale_index_before_loading_symbols() {
26188 let dir = setup_traversal_project();
26189 std::thread::sleep(std::time::Duration::from_millis(50));
26190 std::fs::write(
26191 dir.path().join("main.rs"),
26192 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26193 )
26194 .unwrap();
26195
26196 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26197
26198 assert!(
26199 graph
26200 .warnings
26201 .iter()
26202 .any(|warning| warning.contains("index refreshed")
26203 && warning.contains("graph traversal packet")),
26204 "expected refresh diagnostic, got {:?}",
26205 graph.warnings
26206 );
26207 assert!(resolve_traversal_node(&graph, "fresh_helper").is_some());
26208
26209 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
26210 let summary = db.compute_changes(dir.path()).unwrap();
26211 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
26212 }
26213
26214 #[test]
26215 fn traversal_graph_falls_back_to_raw_source_when_stale_refresh_is_blocked() {
26216 let dir = setup_traversal_project();
26217 let db_path = dir.path().join(".tsift/index.db");
26218 let _writer = hold_writer_lock(&index::writer_lock_path(&db_path));
26219 std::thread::sleep(std::time::Duration::from_millis(50));
26220 std::fs::write(
26221 dir.path().join("main.rs"),
26222 "fn fresh_helper() { println!(\"fresh\"); }\nfn main() { fresh_helper(); }\n",
26223 )
26224 .unwrap();
26225
26226 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26227 let file = resolve_traversal_node(&graph, "main.rs").unwrap();
26228
26229 assert!(
26230 graph
26231 .warnings
26232 .iter()
26233 .any(|warning| warning.contains("falling back to raw source file nodes")),
26234 "expected raw-source fallback diagnostic, got {:?}",
26235 graph.warnings
26236 );
26237 assert!(
26238 file.detail
26239 .as_deref()
26240 .is_some_and(|detail| detail.contains("raw source fallback")),
26241 "expected raw-source detail, got {:?}",
26242 file.detail
26243 );
26244 assert!(
26245 file.expand.contains("source-read"),
26246 "expected source-read fallback command, got {}",
26247 file.expand
26248 );
26249 assert!(
26250 resolve_traversal_node(&graph, "helper").is_none(),
26251 "stale symbol evidence should be skipped when refresh is blocked"
26252 );
26253 }
26254
26255 #[test]
26256 fn traversal_cmd_supports_json_and_html_outputs() {
26257 let dir = setup_traversal_project();
26258 cmd_traverse(
26259 Some("#kgnv"),
26260 Some("main"),
26261 dir.path(),
26262 None,
26263 1,
26264 50,
26265 TraverseFormat::Json,
26266 false,
26267 false,
26268 false,
26269 None,
26270 )
26271 .unwrap();
26272 cmd_traverse(
26273 None,
26274 None,
26275 dir.path(),
26276 None,
26277 1,
26278 50,
26279 TraverseFormat::Html,
26280 false,
26281 false,
26282 false,
26283 None,
26284 )
26285 .unwrap();
26286 }
26287
26288 #[test]
26289 fn traversal_html_renders_inline_graph_visualization() {
26290 let dir = setup_traversal_project();
26291 seed_traversal_semantic_summaries(dir.path());
26292 let graph = build_traversal_graph(dir.path(), dir.path(), None).unwrap();
26293 let report = traversal_report(dir.path(), None, graph, None, None, 1, 50).unwrap();
26294 let html = traversal_report_html(&report).unwrap();
26295
26296 assert!(html.contains("id=\"graph-canvas\""));
26297 assert!(html.contains("semantic_concept"));
26298 assert!(html.contains("graph navigation"));
26299 assert!(html.contains("JSON.parse"));
26300 }
26301
26302 #[test]
26303 fn compact_helpers_trim_scores_and_snippets() {
26304 assert_eq!(format_score(0.12345, true), "0.12");
26305 assert_eq!(format_score(0.12345, false), "0.1235");
26306 let snippet = compact_snippet(" first line with useful context\nsecond");
26307 assert_eq!(snippet.as_deref(), Some("first line with useful context"));
26308 }
26309
26310 #[test]
26311 fn compact_members_caps_list() {
26312 let members: Vec<graph::CommunityMember> = ["a", "b", "c", "d", "e", "f"]
26313 .iter()
26314 .map(|n| graph::CommunityMember::new(*n))
26315 .collect();
26316 assert_eq!(compact_members(&members, 5), "a, b, c, d, e (+1 more)");
26317 }
26318
26319 #[test]
26320 fn abbreviate_kind_maps_common_kinds() {
26321 assert_eq!(abbreviate_kind("function"), "fn");
26322 assert_eq!(abbreviate_kind("method"), "meth");
26323 assert_eq!(abbreviate_kind("class"), "cls");
26324 assert_eq!(abbreviate_kind("interface"), "iface");
26325 assert_eq!(abbreviate_kind("type_alias"), "type");
26326 assert_eq!(abbreviate_kind("data_class"), "data_cls");
26327 assert_eq!(abbreviate_kind("sealed_class"), "sealed_cls");
26328 assert_eq!(abbreviate_kind("enum_class"), "enum_cls");
26329 assert_eq!(abbreviate_kind("companion_object"), "comp_obj");
26330 assert_eq!(abbreviate_kind("object"), "obj");
26331 assert_eq!(abbreviate_kind("heading"), "h");
26332 assert_eq!(abbreviate_kind("code_block"), "code");
26333 assert_eq!(abbreviate_kind("struct"), "struct");
26335 assert_eq!(abbreviate_kind("trait"), "trait");
26336 assert_eq!(abbreviate_kind("enum"), "enum");
26337 assert_eq!(abbreviate_kind("const"), "const");
26338 assert_eq!(abbreviate_kind("unknown_kind"), "unknown_kind");
26339 }
26340
26341 #[test]
26342 fn abbreviate_match_type_maps_search_types() {
26343 assert_eq!(abbreviate_match_type("exact_name"), "exact");
26344 assert_eq!(abbreviate_match_type("partial_tags"), "partial");
26345 assert_eq!(abbreviate_match_type("all_tags"), "all_tags");
26346 assert_eq!(abbreviate_match_type("other_type"), "other_type");
26347 }
26348
26349 #[test]
26350 fn explain_compact_groups_edges_by_file() {
26351 let edges = vec![
26352 index::StoredEdge {
26353 caller_file: "src/main.rs".to_string(),
26354 caller_name: "main".to_string(),
26355 caller_line: 1,
26356 callee_name: "helper".to_string(),
26357 call_site_line: 2,
26358 tagpath_handle: None,
26359 },
26360 index::StoredEdge {
26361 caller_file: "src/main.rs".to_string(),
26362 caller_name: "main".to_string(),
26363 caller_line: 1,
26364 callee_name: "render".to_string(),
26365 call_site_line: 3,
26366 tagpath_handle: None,
26367 },
26368 ];
26369 let lines = format_edge_groups(&edges, false);
26370 assert_eq!(lines, vec![" src/main.rs (2): helper, render"]);
26371 }
26372
26373 #[test]
26374 fn search_hit_groups_preserve_file_counts_and_samples() {
26375 let dir = tempfile::tempdir().unwrap();
26376 let root = dir.path();
26377 let main_rs = root.join("src/main.rs");
26378 fs::create_dir_all(main_rs.parent().unwrap()).unwrap();
26379 fs::write(&main_rs, "claudescore-3 anchor\nclaudescore-3 follow-up\n").unwrap();
26380 let freshness = exact_search_file_timestamp(&main_rs);
26381 let hits = vec![
26382 sift::SearchHit {
26383 artifact_id: "a".to_string(),
26384 artifact_kind: sift::ContextArtifactKind::File,
26385 path: main_rs.display().to_string(),
26386 rank: 1,
26387 score: 10.0,
26388 confidence: sift::ScoreConfidence::High,
26389 location: Some("line 3".to_string()),
26390 snippet: "claudescore-3 anchor".to_string(),
26391 provenance: sift::ArtifactProvenance {
26392 adapter: sift::AcquisitionAdapterKind::FileSystem,
26393 source: "ripgrep -F".to_string(),
26394 synthetic: false,
26395 },
26396 freshness: freshness.clone(),
26397 budget: sift::ArtifactBudget::from_text("claudescore-3 anchor", 1),
26398 },
26399 sift::SearchHit {
26400 artifact_id: "b".to_string(),
26401 artifact_kind: sift::ContextArtifactKind::File,
26402 path: main_rs.display().to_string(),
26403 rank: 2,
26404 score: 9.0,
26405 confidence: sift::ScoreConfidence::High,
26406 location: Some("line 7".to_string()),
26407 snippet: "claudescore-3 follow-up".to_string(),
26408 provenance: sift::ArtifactProvenance {
26409 adapter: sift::AcquisitionAdapterKind::FileSystem,
26410 source: "ripgrep -F".to_string(),
26411 synthetic: false,
26412 },
26413 freshness: freshness.clone(),
26414 budget: sift::ArtifactBudget::from_text("claudescore-3 follow-up", 1),
26415 },
26416 sift::SearchHit {
26417 artifact_id: "c".to_string(),
26418 artifact_kind: sift::ContextArtifactKind::File,
26419 path: main_rs.display().to_string(),
26420 rank: 3,
26421 score: 8.0,
26422 confidence: sift::ScoreConfidence::High,
26423 location: Some("line 9".to_string()),
26424 snippet: "claudescore-3 tail".to_string(),
26425 provenance: sift::ArtifactProvenance {
26426 adapter: sift::AcquisitionAdapterKind::FileSystem,
26427 source: "ripgrep -F".to_string(),
26428 synthetic: false,
26429 },
26430 freshness,
26431 budget: sift::ArtifactBudget::from_text("claudescore-3 tail", 1),
26432 },
26433 ];
26434
26435 let groups = group_search_hits(&hits, root, false);
26436 assert_eq!(groups.len(), 1);
26437 assert_eq!(groups[0].path, "src/main.rs");
26438 assert_eq!(groups[0].hits, 3);
26439 assert_eq!(
26440 groups[0].samples,
26441 vec![
26442 "line 3: claudescore-3 anchor".to_string(),
26443 "line 7: claudescore-3 follow-up".to_string()
26444 ]
26445 );
26446 assert!(should_collapse_search_hits(&hits, root, false));
26447 }
26448
26449 #[test]
26450 fn dense_edge_groups_trigger_collapse() {
26451 let edges = vec![
26452 index::StoredEdge {
26453 caller_file: "src/main.rs".to_string(),
26454 caller_name: "main".to_string(),
26455 caller_line: 1,
26456 callee_name: "helper".to_string(),
26457 call_site_line: 2,
26458 tagpath_handle: None,
26459 },
26460 index::StoredEdge {
26461 caller_file: "src/main.rs".to_string(),
26462 caller_name: "beta".to_string(),
26463 caller_line: 5,
26464 callee_name: "helper".to_string(),
26465 call_site_line: 6,
26466 tagpath_handle: None,
26467 },
26468 index::StoredEdge {
26469 caller_file: "src/main.rs".to_string(),
26470 caller_name: "gamma".to_string(),
26471 caller_line: 9,
26472 callee_name: "helper".to_string(),
26473 call_site_line: 10,
26474 tagpath_handle: None,
26475 },
26476 ];
26477 assert!(should_collapse_edge_groups(&edges));
26478 }
26479
26480 fn setup_workspace() -> tempfile::TempDir {
26483 let dir = tempfile::tempdir().unwrap();
26484 let root = dir.path();
26485 std::fs::write(
26486 root.join(".gitmodules"),
26487 r#"[submodule "src/alpha"]
26488 path = src/alpha
26489 url = https://example.com/alpha
26490[submodule "src/beta"]
26491 path = src/beta
26492 url = https://example.com/beta
26493"#,
26494 )
26495 .unwrap();
26496 let alpha = root.join("src/alpha");
26497 let beta = root.join("src/beta");
26498 std::fs::create_dir_all(&alpha).unwrap();
26499 std::fs::create_dir_all(&beta).unwrap();
26500 std::fs::write(
26501 alpha.join("lib.rs"),
26502 "fn alpha_helper() {}\nfn alpha_main() { alpha_helper(); }",
26503 )
26504 .unwrap();
26505 std::fs::write(beta.join("lib.rs"), "fn beta_func() {}").unwrap();
26506 dir
26507 }
26508
26509 fn setup_workspace_with_duplicate_leaf_names() -> tempfile::TempDir {
26510 let dir = tempfile::tempdir().unwrap();
26511 let root = dir.path();
26512 std::fs::write(
26513 root.join(".gitmodules"),
26514 r#"[submodule "pkg/app/foo"]
26515 path = pkg/app/foo
26516 url = https://example.com/pkg-app-foo
26517[submodule "vendor/foo"]
26518 path = vendor/foo
26519 url = https://example.com/vendor-foo
26520"#,
26521 )
26522 .unwrap();
26523 let pkg_foo = root.join("pkg/app/foo");
26524 let vendor_foo = root.join("vendor/foo");
26525 std::fs::create_dir_all(&pkg_foo).unwrap();
26526 std::fs::create_dir_all(&vendor_foo).unwrap();
26527 std::fs::write(
26528 pkg_foo.join("lib.rs"),
26529 "fn pkg_only() {}\nfn shared_name() { pkg_only(); }\n",
26530 )
26531 .unwrap();
26532 std::fs::write(
26533 vendor_foo.join("lib.rs"),
26534 "fn vendor_only() {}\nfn shared_name() { vendor_only(); }\n",
26535 )
26536 .unwrap();
26537 dir
26538 }
26539
26540 #[test]
26541 fn workspace_index_creates_per_submodule_dbs() {
26542 let dir = setup_workspace();
26543 cmd_index(
26544 dir.path(),
26545 false,
26546 false,
26547 false,
26548 false,
26549 false,
26550 true,
26551 None,
26552 false,
26553 false,
26554 false,
26555 false,
26556 false,
26557 false,
26558 )
26559 .unwrap();
26560 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26561 assert!(dir.path().join(".tsift/indexes/beta/index.db").exists());
26562 }
26563
26564 #[test]
26565 fn workspace_index_single_submodule() {
26566 let dir = setup_workspace();
26567 cmd_index(
26568 dir.path(),
26569 false,
26570 false,
26571 false,
26572 false,
26573 false,
26574 false,
26575 Some("alpha"),
26576 false,
26577 false,
26578 false,
26579 false,
26580 false,
26581 false,
26582 )
26583 .unwrap();
26584 assert!(dir.path().join(".tsift/indexes/alpha/index.db").exists());
26585 assert!(!dir.path().join(".tsift/indexes/beta/index.db").exists());
26586 }
26587
26588 #[test]
26589 fn workspace_index_single_submodule_errors_on_unknown_scope() {
26590 let dir = setup_workspace();
26591
26592 let err = cmd_index(
26593 dir.path(),
26594 false,
26595 false,
26596 false,
26597 false,
26598 false,
26599 false,
26600 Some("missing"),
26601 false,
26602 false,
26603 false,
26604 false,
26605 false,
26606 false,
26607 )
26608 .unwrap_err();
26609
26610 let msg = err.to_string();
26611 assert!(msg.contains("unknown scope `missing`"));
26612 assert!(msg.contains("Available scopes: alpha, beta"));
26613 assert!(!dir.path().join(".tsift/indexes/missing/index.db").exists());
26614 }
26615
26616 #[test]
26617 fn workspace_index_uses_unique_scope_ids_when_leaf_names_collide() {
26618 let dir = setup_workspace_with_duplicate_leaf_names();
26619 cmd_index(
26620 dir.path(),
26621 false,
26622 false,
26623 false,
26624 false,
26625 false,
26626 true,
26627 None,
26628 false,
26629 false,
26630 false,
26631 false,
26632 false,
26633 false,
26634 )
26635 .unwrap();
26636
26637 assert!(
26638 dir.path()
26639 .join(".tsift/indexes/pkg/app/foo/index.db")
26640 .exists()
26641 );
26642 assert!(
26643 dir.path()
26644 .join(".tsift/indexes/vendor/foo/index.db")
26645 .exists()
26646 );
26647 }
26648
26649 #[test]
26650 fn federated_search_across_submodules() {
26651 let dir = setup_workspace();
26652 cmd_index(
26653 dir.path(),
26654 false,
26655 false,
26656 false,
26657 false,
26658 false,
26659 true,
26660 None,
26661 false,
26662 false,
26663 false,
26664 false,
26665 false,
26666 false,
26667 )
26668 .unwrap();
26669 let (hits, _diag) = federated_symbol_search(
26670 dir.path(),
26671 "alpha_helper",
26672 10,
26673 &TagpathSearchOpts {
26674 no_tagpath: true,
26675 strict: false,
26676 },
26677 )
26678 .unwrap();
26679 assert!(
26680 !hits.is_empty(),
26681 "should find alpha_helper via federated search"
26682 );
26683 }
26684
26685 #[test]
26686 fn federated_search_respects_isolation() {
26687 let dir = setup_workspace();
26688 let tsift_dir = dir.path().join(".tsift");
26689 std::fs::create_dir_all(&tsift_dir).unwrap();
26690 std::fs::write(
26691 tsift_dir.join("config.toml"),
26692 r#"
26693[overrides.alpha]
26694tier = "isolated"
26695"#,
26696 )
26697 .unwrap();
26698 cmd_index(
26699 dir.path(),
26700 false,
26701 false,
26702 false,
26703 false,
26704 false,
26705 true,
26706 None,
26707 false,
26708 false,
26709 false,
26710 false,
26711 false,
26712 false,
26713 )
26714 .unwrap();
26715 let (hits, _diag) = federated_symbol_search(
26716 dir.path(),
26717 "alpha_helper",
26718 10,
26719 &TagpathSearchOpts {
26720 no_tagpath: true,
26721 strict: false,
26722 },
26723 )
26724 .unwrap();
26725 assert!(
26726 hits.is_empty(),
26727 "isolated submodule should not appear in federated search"
26728 );
26729 }
26730
26731 #[test]
26732 fn federated_lexical_search_respects_isolation() {
26733 let dir = setup_workspace();
26734 let tsift_dir = dir.path().join(".tsift");
26735 std::fs::create_dir_all(&tsift_dir).unwrap();
26736 std::fs::write(
26737 tsift_dir.join("config.toml"),
26738 r#"
26739[overrides.alpha]
26740tier = "isolated"
26741"#,
26742 )
26743 .unwrap();
26744 cmd_index(
26745 dir.path(),
26746 false,
26747 false,
26748 false,
26749 false,
26750 false,
26751 true,
26752 None,
26753 false,
26754 false,
26755 false,
26756 false,
26757 false,
26758 false,
26759 )
26760 .unwrap();
26761
26762 let response = federated_sift_search(
26763 dir.path(),
26764 &dir.path().join(".tsift/search-cache"),
26765 "fn",
26766 10,
26767 0,
26768 "lexical",
26769 None,
26770 )
26771 .unwrap();
26772
26773 assert!(
26774 !response.hits.is_empty(),
26775 "shared scopes should still contribute lexical hits"
26776 );
26777 assert!(
26778 response
26779 .hits
26780 .iter()
26781 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26782 "isolated scope should not leak lexical hits: {:?}",
26783 response.hits
26784 );
26785 }
26786
26787 #[test]
26788 fn federated_lexical_search_respects_private_tier() {
26789 let dir = setup_workspace();
26790 let tsift_dir = dir.path().join(".tsift");
26791 std::fs::create_dir_all(&tsift_dir).unwrap();
26792 std::fs::write(
26793 tsift_dir.join("config.toml"),
26794 r#"
26795[overrides.alpha]
26796tier = "private"
26797"#,
26798 )
26799 .unwrap();
26800 cmd_index(
26801 dir.path(),
26802 false,
26803 false,
26804 false,
26805 false,
26806 false,
26807 true,
26808 None,
26809 false,
26810 false,
26811 false,
26812 false,
26813 false,
26814 false,
26815 )
26816 .unwrap();
26817
26818 let response = federated_sift_search(
26819 dir.path(),
26820 &dir.path().join(".tsift/search-cache"),
26821 "fn",
26822 10,
26823 0,
26824 "lexical",
26825 None,
26826 )
26827 .unwrap();
26828
26829 assert!(
26830 !response.hits.is_empty(),
26831 "shared scopes should still contribute lexical hits"
26832 );
26833 assert!(
26834 response
26835 .hits
26836 .iter()
26837 .all(|hit| hit.path.ends_with("src/beta/lib.rs")),
26838 "private scope should not leak lexical hits: {:?}",
26839 response.hits
26840 );
26841 }
26842
26843 #[test]
26844 fn scoped_search_finds_submodule_symbols() {
26845 let dir = setup_workspace();
26846 cmd_index(
26847 dir.path(),
26848 false,
26849 false,
26850 false,
26851 false,
26852 false,
26853 true,
26854 None,
26855 false,
26856 false,
26857 false,
26858 false,
26859 false,
26860 false,
26861 )
26862 .unwrap();
26863 let cfg = config::Config::load(dir.path()).unwrap();
26864 let db_path = cfg.db_path_for(dir.path(), "alpha");
26865 let db = index::IndexDb::open(&db_path).unwrap();
26866 let hits = db.symbol_search("alpha_main", 10).unwrap();
26867 assert!(!hits.is_empty());
26868 assert_eq!(hits[0].name, "alpha_main");
26869 }
26870
26871 #[test]
26872 fn scoped_search_cmd_errors_on_unknown_scope() {
26873 let dir = setup_workspace();
26874
26875 let err = cmd_search(
26876 "alpha_main".to_string(),
26877 Some(dir.path().to_path_buf()),
26878 5,
26879 Some("lexical".to_string()),
26880 Some("missing".to_string()),
26881 false,
26882 false,
26883 false,
26884 0,
26885 false,
26886 false,
26887 false,
26888 false,
26889 false,
26890 false,
26891 false,
26892 )
26893 .unwrap_err();
26894
26895 let msg = err.to_string();
26896 assert!(msg.contains("unknown scope `missing`"));
26897 assert!(msg.contains("Available scopes: alpha, beta"));
26898 }
26899
26900 #[test]
26901 fn scoped_search_cmd_errors_on_ambiguous_legacy_scope_name() {
26902 let dir = setup_workspace_with_duplicate_leaf_names();
26903 cmd_index(
26904 dir.path(),
26905 false,
26906 false,
26907 false,
26908 false,
26909 false,
26910 true,
26911 None,
26912 false,
26913 false,
26914 false,
26915 false,
26916 false,
26917 false,
26918 )
26919 .unwrap();
26920
26921 let err = cmd_search(
26922 "vendor_only".to_string(),
26923 Some(dir.path().to_path_buf()),
26924 5,
26925 Some("lexical".to_string()),
26926 Some("foo".to_string()),
26927 false,
26928 false,
26929 false,
26930 0,
26931 false,
26932 false,
26933 false,
26934 false,
26935 false,
26936 false,
26937 false,
26938 )
26939 .unwrap_err();
26940
26941 let msg = err.to_string();
26942 assert!(msg.contains("ambiguous scope `foo`"));
26943 assert!(msg.contains("pkg/app/foo"));
26944 assert!(msg.contains("vendor/foo"));
26945 }
26946
26947 #[test]
26948 fn scoped_graph_query() {
26949 let dir = setup_workspace();
26950 cmd_index(
26951 dir.path(),
26952 false,
26953 false,
26954 false,
26955 false,
26956 false,
26957 true,
26958 None,
26959 false,
26960 false,
26961 false,
26962 false,
26963 false,
26964 false,
26965 )
26966 .unwrap();
26967 let cfg = config::Config::load(dir.path()).unwrap();
26968 let db_path = cfg.db_path_for(dir.path(), "alpha");
26969 let db = index::IndexDb::open(&db_path).unwrap();
26970 let callees = db.callees_of("alpha_main").unwrap();
26971 let names: Vec<&str> = callees.iter().map(|e| e.callee_name.as_str()).collect();
26972 assert!(names.contains(&"alpha_helper"));
26973 }
26974
26975 fn assert_workspace_query_requires_scope(err: anyhow::Error) {
26976 let msg = err.to_string();
26977 assert!(msg.contains("require `--scope <scope>`"), "{msg}");
26978 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26979 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26980 assert!(
26981 !msg.contains("no index found at"),
26982 "workspace query should fail with scope guidance, got: {msg}"
26983 );
26984 }
26985
26986 fn assert_workspace_search_requires_explicit_target(err: anyhow::Error) {
26987 let msg = err.to_string();
26988 assert!(
26989 msg.contains("requires `--scope <scope>` or `--federated`"),
26990 "{msg}"
26991 );
26992 assert!(msg.contains("Available scopes: alpha, beta"), "{msg}");
26993 assert!(msg.contains("Indexed scopes: alpha, beta"), "{msg}");
26994 assert!(
26995 !msg.contains("autoindexing index"),
26996 "workspace search should fail before creating a shared root index: {msg}"
26997 );
26998 }
26999
27000 #[test]
27001 fn graph_cmd_requires_scope_for_workspace_root_without_shared_index() {
27002 let dir = setup_workspace();
27003 cmd_index(
27004 dir.path(),
27005 false,
27006 false,
27007 false,
27008 false,
27009 false,
27010 true,
27011 None,
27012 false,
27013 false,
27014 false,
27015 false,
27016 false,
27017 false,
27018 )
27019 .unwrap();
27020
27021 let err = cmd_graph(
27022 "alpha_main",
27023 dir.path(),
27024 false,
27025 false,
27026 None,
27027 20,
27028 false,
27029 false,
27030 false,
27031 false,
27032 false,
27033 false,
27034 false,
27035 TagpathSearchOpts::default(),
27036 )
27037 .unwrap_err();
27038
27039 assert_workspace_query_requires_scope(err);
27040 }
27041
27042 #[test]
27043 fn graph_cmd_infers_scope_from_nested_workspace_path() {
27044 let dir = setup_workspace();
27045 cmd_index(
27046 dir.path(),
27047 false,
27048 false,
27049 false,
27050 false,
27051 false,
27052 true,
27053 None,
27054 false,
27055 false,
27056 false,
27057 false,
27058 false,
27059 false,
27060 )
27061 .unwrap();
27062 let nested = dir.path().join("src/alpha/nested");
27063 std::fs::create_dir_all(&nested).unwrap();
27064
27065 let result = cmd_graph(
27066 "alpha_main",
27067 &nested,
27068 false,
27069 false,
27070 None,
27071 20,
27072 false,
27073 false,
27074 false,
27075 false,
27076 false,
27077 false,
27078 false,
27079 TagpathSearchOpts::default(),
27080 );
27081
27082 assert!(result.is_ok());
27083 }
27084
27085 #[test]
27086 fn communities_cmd_requires_scope_for_workspace_root_without_shared_index() {
27087 let dir = setup_workspace();
27088 cmd_index(
27089 dir.path(),
27090 false,
27091 false,
27092 false,
27093 false,
27094 false,
27095 true,
27096 None,
27097 false,
27098 false,
27099 false,
27100 false,
27101 false,
27102 false,
27103 )
27104 .unwrap();
27105
27106 let err = cmd_communities(
27107 dir.path(),
27108 None,
27109 1,
27110 10,
27111 false,
27112 false,
27113 false,
27114 false,
27115 false,
27116 false,
27117 TagpathSearchOpts::default(),
27118 )
27119 .unwrap_err();
27120
27121 assert_workspace_query_requires_scope(err);
27122 }
27123
27124 #[test]
27125 fn communities_cmd_infers_scope_from_nested_workspace_path() {
27126 let dir = setup_workspace();
27127 cmd_index(
27128 dir.path(),
27129 false,
27130 false,
27131 false,
27132 false,
27133 false,
27134 true,
27135 None,
27136 false,
27137 false,
27138 false,
27139 false,
27140 false,
27141 false,
27142 )
27143 .unwrap();
27144 let nested = dir.path().join("src/alpha/nested");
27145 std::fs::create_dir_all(&nested).unwrap();
27146
27147 let result = cmd_communities(
27148 &nested,
27149 None,
27150 1,
27151 10,
27152 false,
27153 false,
27154 false,
27155 false,
27156 false,
27157 false,
27158 TagpathSearchOpts::default(),
27159 );
27160
27161 assert!(result.is_ok());
27162 }
27163
27164 #[test]
27165 fn path_cmd_requires_scope_for_workspace_root_without_shared_index() {
27166 let dir = setup_workspace();
27167 cmd_index(
27168 dir.path(),
27169 false,
27170 false,
27171 false,
27172 false,
27173 false,
27174 true,
27175 None,
27176 false,
27177 false,
27178 false,
27179 false,
27180 false,
27181 false,
27182 )
27183 .unwrap();
27184
27185 let err = cmd_path(
27186 "alpha_main",
27187 "alpha_helper",
27188 dir.path(),
27189 None,
27190 false,
27191 false,
27192 false,
27193 false,
27194 false,
27195 TagpathSearchOpts::default(),
27196 )
27197 .unwrap_err();
27198
27199 assert_workspace_query_requires_scope(err);
27200 }
27201
27202 #[test]
27203 fn path_cmd_infers_scope_from_nested_workspace_path() {
27204 let dir = setup_workspace();
27205 cmd_index(
27206 dir.path(),
27207 false,
27208 false,
27209 false,
27210 false,
27211 false,
27212 true,
27213 None,
27214 false,
27215 false,
27216 false,
27217 false,
27218 false,
27219 false,
27220 )
27221 .unwrap();
27222 let nested = dir.path().join("src/alpha/nested");
27223 std::fs::create_dir_all(&nested).unwrap();
27224
27225 let result = cmd_path(
27226 "alpha_main",
27227 "alpha_helper",
27228 &nested,
27229 None,
27230 false,
27231 false,
27232 false,
27233 false,
27234 false,
27235 TagpathSearchOpts::default(),
27236 );
27237
27238 assert!(result.is_ok());
27239 }
27240
27241 #[test]
27242 fn path_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27243 let dir = setup_graph_index();
27244 let db_path = dir.path().join(".tsift/index.db");
27245 let _lock = hold_rollback_journal_lock(&db_path);
27246
27247 let result = cmd_path(
27248 "main",
27249 "helper",
27250 dir.path(),
27251 None,
27252 false,
27253 false,
27254 false,
27255 false,
27256 false,
27257 TagpathSearchOpts::default(),
27258 );
27259
27260 assert!(result.is_ok());
27261 }
27262
27263 #[test]
27264 fn explain_cmd_requires_scope_for_workspace_root_without_shared_index() {
27265 let dir = setup_workspace();
27266 cmd_index(
27267 dir.path(),
27268 false,
27269 false,
27270 false,
27271 false,
27272 false,
27273 true,
27274 None,
27275 false,
27276 false,
27277 false,
27278 false,
27279 false,
27280 false,
27281 )
27282 .unwrap();
27283
27284 let err = cmd_explain(
27285 "alpha_main",
27286 dir.path(),
27287 None,
27288 15,
27289 false,
27290 false,
27291 false,
27292 false,
27293 false,
27294 false,
27295 false,
27296 false,
27297 )
27298 .unwrap_err();
27299
27300 assert_workspace_query_requires_scope(err);
27301 }
27302
27303 #[test]
27304 fn explain_cmd_infers_scope_from_nested_workspace_path() {
27305 let dir = setup_workspace();
27306 cmd_index(
27307 dir.path(),
27308 false,
27309 false,
27310 false,
27311 false,
27312 false,
27313 true,
27314 None,
27315 false,
27316 false,
27317 false,
27318 false,
27319 false,
27320 false,
27321 )
27322 .unwrap();
27323 let nested = dir.path().join("src/alpha/nested");
27324 std::fs::create_dir_all(&nested).unwrap();
27325
27326 let result = cmd_explain(
27327 "alpha_main",
27328 &nested,
27329 None,
27330 15,
27331 false,
27332 false,
27333 false,
27334 false,
27335 false,
27336 false,
27337 false,
27338 false,
27339 );
27340
27341 assert!(result.is_ok());
27342 }
27343
27344 #[test]
27345 fn explain_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
27346 let dir = setup_graph_index();
27347 let db_path = dir.path().join(".tsift/index.db");
27348 let _lock = hold_rollback_journal_lock(&db_path);
27349
27350 let result = cmd_explain(
27351 "main",
27352 dir.path(),
27353 None,
27354 15,
27355 false,
27356 false,
27357 false,
27358 false,
27359 false,
27360 false,
27361 false,
27362 false,
27363 );
27364
27365 assert!(result.is_ok());
27366 }
27367
27368 #[test]
27371 fn community_detection_groups_related() {
27372 let dir = setup_graph_index();
27373 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27374 let edges = db.all_edges().unwrap();
27375 let result = graph::detect_communities(&edges);
27376 assert!(result.node_count > 0);
27377 assert!(!result.communities.is_empty());
27378 }
27379
27380 #[test]
27381 fn community_cmd_autoindexes_missing_index_by_default() {
27382 let dir = tempfile::tempdir().unwrap();
27383 let result = cmd_communities(
27384 dir.path(),
27385 None,
27386 2,
27387 10,
27388 false,
27389 false,
27390 false,
27391 false,
27392 false,
27393 false,
27394 TagpathSearchOpts::default(),
27395 );
27396
27397 assert!(result.is_ok());
27398 assert!(dir.path().join(".tsift/index.db").exists());
27399 }
27400
27401 #[test]
27404 fn path_finds_connected_symbols() {
27405 let dir = setup_graph_index();
27406 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27407 let edges = db.all_edges().unwrap();
27408 let result = graph::shortest_path(&edges, "main", "helper");
27409 assert!(result.is_some());
27410 let path = result.unwrap();
27411 assert_eq!(path.hops, 1);
27412 }
27413
27414 #[test]
27415 fn path_returns_none_for_unknown() {
27416 let dir = setup_graph_index();
27417 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27418 let edges = db.all_edges().unwrap();
27419 assert!(graph::shortest_path(&edges, "main", "nonexistent").is_none());
27420 }
27421
27422 #[test]
27423 fn path_cmd_autoindexes_missing_index_by_default() {
27424 let dir = tempfile::tempdir().unwrap();
27425 let result = cmd_path(
27426 "a",
27427 "b",
27428 dir.path(),
27429 None,
27430 false,
27431 false,
27432 false,
27433 false,
27434 false,
27435 TagpathSearchOpts::default(),
27436 );
27437
27438 assert!(result.is_ok());
27439 assert!(dir.path().join(".tsift/index.db").exists());
27440 }
27441
27442 #[test]
27445 fn explain_shows_symbol_info() {
27446 let dir = setup_graph_index();
27447 let db = index::IndexDb::open(&dir.path().join(".tsift/index.db")).unwrap();
27448 let symbols = db.symbol_info("main").unwrap();
27449 assert!(!symbols.is_empty());
27450 assert_eq!(symbols[0].name, "main");
27451 assert_eq!(symbols[0].kind, "function");
27452 }
27453
27454 #[test]
27455 fn explain_cmd_autoindexes_missing_index_by_default() {
27456 let dir = tempfile::tempdir().unwrap();
27457 let result = cmd_explain(
27458 "main",
27459 dir.path(),
27460 None,
27461 15,
27462 false,
27463 false,
27464 false,
27465 false,
27466 false,
27467 false,
27468 false,
27469 false,
27470 );
27471
27472 assert!(result.is_ok());
27473 assert!(dir.path().join(".tsift/index.db").exists());
27474 }
27475
27476 fn hold_write_lock(db_path: &std::path::Path) -> Connection {
27477 let conn = Connection::open(db_path).unwrap();
27478 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
27479 conn
27480 }
27481
27482 fn hold_writer_lock(lock_path: &std::path::Path) -> std::fs::File {
27483 use fs4::fs_std::FileExt;
27484 use std::io::Write;
27485
27486 let mut file = std::fs::OpenOptions::new()
27487 .read(true)
27488 .write(true)
27489 .create(true)
27490 .truncate(false)
27491 .open(lock_path)
27492 .unwrap();
27493 assert!(file.try_lock_exclusive().unwrap());
27494 writeln!(file, "{}", std::process::id()).unwrap();
27495 file
27496 }
27497
27498 fn hold_rollback_journal_lock(db_path: &std::path::Path) -> Connection {
27499 let conn = Connection::open(db_path).unwrap();
27500 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
27501 .unwrap();
27502 std::fs::write(substrate::rollback_journal_path(db_path), "locked").unwrap();
27503 conn
27504 }
27505
27506 fn hold_wal_database_lock(db_path: &std::path::Path) -> Connection {
27507 let conn = Connection::open(db_path).unwrap();
27508 conn.execute_batch(
27509 "PRAGMA journal_mode=WAL;
27510 PRAGMA wal_autocheckpoint=0;
27511 CREATE TABLE IF NOT EXISTS wal_lock_probe (id INTEGER PRIMARY KEY);
27512 INSERT INTO wal_lock_probe DEFAULT VALUES;
27513 PRAGMA locking_mode=EXCLUSIVE;
27514 BEGIN EXCLUSIVE;",
27515 )
27516 .unwrap();
27517 assert!(substrate::wal_sidecar_path(db_path).exists());
27518 conn
27519 }
27520
27521 #[test]
27522 fn index_cmd_reports_wal_sidecar_diagnostics_without_tsift_writer_lock() {
27523 let dir = setup_graph_index();
27524 let db_path = dir.path().join(".tsift/index.db");
27525 let _lock = hold_wal_database_lock(&db_path);
27526
27527 let err = cmd_index(
27528 dir.path(),
27529 false,
27530 false,
27531 false,
27532 false,
27533 false,
27534 false,
27535 None,
27536 false,
27537 false,
27538 false,
27539 false,
27540 false,
27541 false,
27542 )
27543 .unwrap_err();
27544
27545 let msg = err.to_string();
27546 assert!(msg.contains("indexing"));
27547 assert!(msg.contains("lock diagnostics:"));
27548 assert!(msg.contains("lock: absent"));
27549 assert!(msg.contains("wal: present") || msg.contains("shm: present"));
27550 assert!(msg.contains("wedged writer holding live WAL sidecars"));
27551 assert!(msg.contains("snapshot fallback"));
27552 }
27553
27554 #[test]
27555 fn search_cmd_succeeds_while_writer_lock_is_held() {
27556 let dir = setup_graph_index();
27557 let db_path = dir.path().join(".tsift/index.db");
27558 let _lock = hold_write_lock(&db_path);
27559
27560 let result = cmd_search(
27561 "main".to_string(),
27562 Some(dir.path().to_path_buf()),
27563 5,
27564 Some("lexical".to_string()),
27565 None,
27566 false,
27567 false,
27568 false,
27569 0,
27570 true,
27571 false,
27572 false,
27573 false,
27574 false,
27575 false,
27576 false,
27577 );
27578
27579 assert!(result.is_ok());
27580 }
27581
27582 #[test]
27583 fn search_cmd_uses_snapshot_fallback_when_rollback_journal_lock_appears_after_precheck() {
27584 let dir = setup_graph_index();
27585 let _hook = install_search_post_precheck_lock(dir.path().join(".tsift/index.db"));
27586
27587 let result = cmd_search(
27588 "main".to_string(),
27589 Some(dir.path().to_path_buf()),
27590 5,
27591 Some("lexical".to_string()),
27592 None,
27593 false,
27594 false,
27595 false,
27596 0,
27597 true,
27598 false,
27599 false,
27600 false,
27601 false,
27602 false,
27603 false,
27604 );
27605
27606 assert!(result.is_ok());
27607 }
27608
27609 #[test]
27610 fn search_cmd_uses_wal_snapshot_fallback_when_lock_appears_after_precheck() {
27611 let dir = setup_graph_index();
27612 let _hook = install_search_post_precheck_wal_lock(dir.path().join(".tsift/index.db"));
27613
27614 let result = cmd_search(
27615 "main".to_string(),
27616 Some(dir.path().to_path_buf()),
27617 5,
27618 Some("lexical".to_string()),
27619 None,
27620 false,
27621 false,
27622 false,
27623 0,
27624 true,
27625 false,
27626 false,
27627 false,
27628 false,
27629 false,
27630 false,
27631 );
27632
27633 assert!(result.is_ok());
27634 }
27635
27636 #[test]
27637 fn search_cmd_fails_fast_when_autoindex_disabled_and_index_is_stale() {
27638 let dir = setup_graph_index();
27639 std::thread::sleep(std::time::Duration::from_millis(50));
27640 std::fs::write(
27641 dir.path().join("main.rs"),
27642 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27643 )
27644 .unwrap();
27645
27646 let err = cmd_search(
27647 "helper".to_string(),
27648 Some(dir.path().to_path_buf()),
27649 5,
27650 Some("lexical".to_string()),
27651 None,
27652 false,
27653 false,
27654 false,
27655 0,
27656 false,
27657 false,
27658 false,
27659 false,
27660 false,
27661 false,
27662 false,
27663 )
27664 .unwrap_err();
27665
27666 assert!(err.to_string().contains("search aborted"));
27667 assert!(err.to_string().contains("index is stale"));
27668 assert!(err.to_string().contains("--no-autoindex"));
27669 }
27670
27671 #[test]
27672 fn search_cmd_reports_stale_when_root_index_is_locked_by_rollback_journal() {
27673 let dir = setup_graph_index();
27674 std::thread::sleep(std::time::Duration::from_millis(50));
27675 std::fs::write(
27676 dir.path().join("main.rs"),
27677 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27678 )
27679 .unwrap();
27680 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27681
27682 let err = cmd_search(
27683 "helper".to_string(),
27684 Some(dir.path().to_path_buf()),
27685 5,
27686 Some("lexical".to_string()),
27687 None,
27688 false,
27689 false,
27690 false,
27691 0,
27692 false,
27693 false,
27694 false,
27695 false,
27696 false,
27697 false,
27698 false,
27699 )
27700 .unwrap_err();
27701
27702 assert!(err.to_string().contains("search aborted"));
27703 assert!(err.to_string().contains("index is stale"));
27704 assert!(!err.to_string().contains("database is locked"));
27705 }
27706
27707 #[test]
27708 fn search_cmd_autoindexes_stale_index_by_default() {
27709 let dir = setup_graph_index();
27710 std::thread::sleep(std::time::Duration::from_millis(50));
27711 std::fs::write(
27712 dir.path().join("main.rs"),
27713 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27714 )
27715 .unwrap();
27716
27717 let result = cmd_search(
27718 "helper".to_string(),
27719 Some(dir.path().to_path_buf()),
27720 5,
27721 Some("lexical".to_string()),
27722 None,
27723 false,
27724 false,
27725 true,
27726 0,
27727 false,
27728 false,
27729 false,
27730 false,
27731 false,
27732 false,
27733 false,
27734 );
27735
27736 assert!(result.is_ok());
27737
27738 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27739 let summary = db.compute_changes(dir.path()).unwrap();
27740 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
27741 }
27742
27743 #[test]
27744 fn search_cmd_keeps_read_only_results_when_active_writer_blocks_autoindex() {
27745 let dir = setup_graph_index();
27746 std::thread::sleep(std::time::Duration::from_millis(50));
27747 std::fs::write(
27748 dir.path().join("main.rs"),
27749 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27750 )
27751 .unwrap();
27752 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
27753
27754 let result = cmd_search(
27755 "helper".to_string(),
27756 Some(dir.path().to_path_buf()),
27757 5,
27758 Some("lexical".to_string()),
27759 None,
27760 false,
27761 false,
27762 true,
27763 0,
27764 false,
27765 false,
27766 false,
27767 false,
27768 false,
27769 false,
27770 false,
27771 );
27772
27773 assert!(result.is_ok());
27774
27775 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
27776 let summary = db.compute_changes(dir.path()).unwrap();
27777 assert_eq!(summary.modified, 1);
27778 }
27779
27780 #[test]
27781 fn search_cmd_autoindex_reports_lock_diagnostics_when_rollback_journal_blocks_writer() {
27782 let dir = setup_graph_index();
27783 std::thread::sleep(std::time::Duration::from_millis(50));
27784 std::fs::write(
27785 dir.path().join("main.rs"),
27786 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }",
27787 )
27788 .unwrap();
27789 let _lock = hold_rollback_journal_lock(&dir.path().join(".tsift/index.db"));
27790
27791 let err = cmd_search(
27792 "helper".to_string(),
27793 Some(dir.path().to_path_buf()),
27794 5,
27795 Some("lexical".to_string()),
27796 None,
27797 false,
27798 false,
27799 true,
27800 0,
27801 false,
27802 false,
27803 false,
27804 false,
27805 false,
27806 false,
27807 false,
27808 )
27809 .unwrap_err();
27810
27811 let msg = err.to_string();
27812 assert!(msg.contains("autoindexing index"));
27813 assert!(msg.contains("lock diagnostics:"));
27814 assert!(msg.contains("journal: present"));
27815 assert!(msg.contains("next: inspect the host for a wedged rollback-journal writer"));
27816 }
27817
27818 #[test]
27819 fn search_cmd_uses_ancestor_project_root_for_nested_paths() {
27820 let dir = setup_graph_index();
27821 let nested = dir.path().join("src/nested");
27822 std::fs::create_dir_all(&nested).unwrap();
27823
27824 let result = cmd_search(
27825 "helper".to_string(),
27826 Some(nested.clone()),
27827 5,
27828 Some("lexical".to_string()),
27829 None,
27830 false,
27831 false,
27832 true,
27833 0,
27834 false,
27835 false,
27836 false,
27837 false,
27838 false,
27839 false,
27840 false,
27841 );
27842
27843 assert!(result.is_ok());
27844 assert!(!nested.join(".tsift/index.db").exists());
27845 }
27846
27847 #[test]
27848 fn exact_search_returns_literal_matches() {
27849 let dir = tempfile::tempdir().unwrap();
27850 std::fs::write(dir.path().join("notes.txt"), "alpha\nclaudescore-3\nbeta\n").unwrap();
27851
27852 let response = run_exact_search_with_timeout(dir.path(), "claudescore-3", 5, 0).unwrap();
27853
27854 assert_eq!(response.strategy, "exact");
27855 assert_eq!(response.hits.len(), 1);
27856 assert!(response.hits[0].path.ends_with("notes.txt"));
27857 assert_eq!(response.hits[0].location.as_deref(), Some("line 2"));
27858 assert!(response.hits[0].snippet.contains("claudescore-3"));
27859 }
27860
27861 #[test]
27862 fn exact_search_skips_stale_index_precheck() {
27863 let dir = setup_graph_index();
27864 std::thread::sleep(std::time::Duration::from_millis(50));
27865 std::fs::write(
27866 dir.path().join("main.rs"),
27867 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
27868 )
27869 .unwrap();
27870
27871 let result = cmd_search(
27872 "println!(\"updated\")".to_string(),
27873 Some(dir.path().to_path_buf()),
27874 5,
27875 Some("exact".to_string()),
27876 None,
27877 false,
27878 false,
27879 false,
27880 0,
27881 false,
27882 false,
27883 false,
27884 false,
27885 false,
27886 false,
27887 false,
27888 );
27889
27890 assert!(result.is_ok());
27891 }
27892
27893 #[test]
27894 fn workspace_exact_search_does_not_require_shared_root_index() {
27895 let dir = setup_workspace();
27896 cmd_index(
27897 dir.path(),
27898 false,
27899 false,
27900 false,
27901 false,
27902 false,
27903 true,
27904 None,
27905 false,
27906 false,
27907 false,
27908 false,
27909 false,
27910 false,
27911 )
27912 .unwrap();
27913
27914 let result = cmd_search(
27915 "alpha_helper".to_string(),
27916 Some(dir.path().to_path_buf()),
27917 5,
27918 Some("exact".to_string()),
27919 None,
27920 false,
27921 false,
27922 false,
27923 0,
27924 false,
27925 false,
27926 false,
27927 false,
27928 false,
27929 false,
27930 false,
27931 );
27932
27933 assert!(result.is_ok());
27934 assert!(!dir.path().join(".tsift/index.db").exists());
27935 }
27936
27937 #[test]
27938 fn identifier_like_query_prefers_exact_search() {
27939 assert!(query_prefers_exact_search("claudescore-3"));
27940 assert!(query_prefers_exact_search("alpha_helper"));
27941 assert!(query_prefers_exact_search("src/main.rs"));
27942 assert!(query_prefers_exact_search("crate::module"));
27943 assert!(!query_prefers_exact_search("authenticate"));
27944 assert!(!query_prefers_exact_search("fn main"));
27945 assert!(!query_prefers_exact_search("."));
27946 }
27947
27948 #[test]
27949 fn resolve_search_strategy_auto_promotes_identifier_like_queries() {
27950 assert_eq!(resolve_search_strategy("claudescore-3", None), "exact");
27951 assert_eq!(resolve_search_strategy("authenticate", None), "lexical");
27952 assert_eq!(
27953 resolve_search_strategy("claudescore-3", Some("hybrid".to_string())),
27954 "hybrid"
27955 );
27956 }
27957
27958 #[test]
27959 fn workspace_identifier_like_search_auto_uses_exact_backend() {
27960 let dir = setup_workspace();
27961 cmd_index(
27962 dir.path(),
27963 false,
27964 false,
27965 false,
27966 false,
27967 false,
27968 true,
27969 None,
27970 false,
27971 false,
27972 false,
27973 false,
27974 false,
27975 false,
27976 )
27977 .unwrap();
27978
27979 let result = cmd_search(
27980 "alpha_helper".to_string(),
27981 Some(dir.path().to_path_buf()),
27982 5,
27983 None,
27984 None,
27985 false,
27986 false,
27987 false,
27988 0,
27989 false,
27990 false,
27991 false,
27992 false,
27993 false,
27994 false,
27995 false,
27996 );
27997
27998 assert!(result.is_ok());
27999 assert!(!dir.path().join(".tsift/index.db").exists());
28000 }
28001
28002 #[test]
28003 fn index_cmd_uses_ancestor_project_root_for_nested_paths() {
28004 let dir = setup_graph_index();
28005 let nested = dir.path().join("src/nested");
28006 std::fs::create_dir_all(&nested).unwrap();
28007 std::fs::write(nested.join("extra.rs"), "fn nested_helper() {}\n").unwrap();
28008
28009 let result = cmd_index(
28010 &nested, false, false, false, false, false, false, None, false, false, false, false,
28011 false, false,
28012 );
28013
28014 assert!(result.is_ok());
28015 assert!(dir.path().join(".tsift/index.db").exists());
28016 assert!(!nested.join(".tsift/index.db").exists());
28017 }
28018
28019 #[test]
28020 fn workspace_index_cmd_uses_ancestor_project_root_for_nested_paths() {
28021 let dir = setup_workspace();
28022 let nested = dir.path().join("docs/nested");
28023 std::fs::create_dir_all(&nested).unwrap();
28024
28025 let result = cmd_index(
28026 &nested, false, false, false, false, false, true, None, false, false, false, false,
28027 false, false,
28028 );
28029
28030 let cfg = config::Config::load(dir.path()).unwrap();
28031
28032 assert!(result.is_ok());
28033 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28034 assert!(cfg.db_path_for(dir.path(), "beta").exists());
28035 }
28036
28037 #[test]
28038 fn status_cmd_autoindexes_missing_workspace_scopes() {
28039 let dir = setup_workspace();
28040 let cfg = config::Config::load(dir.path()).unwrap();
28041 let alpha = config::Config::resolve_submodule(dir.path(), "alpha").unwrap();
28042 let alpha_db_path = cfg.db_path_for(dir.path(), &alpha.id);
28043 let alpha_db = index::IndexDb::open(&alpha_db_path).unwrap();
28044 alpha_db.apply_changes(&alpha.source_root).unwrap();
28045
28046 let beta_db_path = cfg.db_path_for(dir.path(), "beta");
28047 assert!(!beta_db_path.exists());
28048
28049 cmd_status(
28050 dir.path(),
28051 StatusCommandOptions {
28052 fix: false,
28053 no_fix: false,
28054 json_output: true,
28055 compact: false,
28056 pretty: false,
28057 terse: false,
28058 schema: false,
28059 },
28060 )
28061 .unwrap();
28062
28063 assert!(beta_db_path.exists());
28064 let report = status::check_status(dir.path()).unwrap();
28065 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28066 }
28067
28068 #[test]
28069 fn status_cmd_autoindexes_workspace_when_all_scopes_are_missing() {
28070 let dir = setup_workspace();
28071 let cfg = config::Config::load(dir.path()).unwrap();
28072
28073 cmd_status(
28074 dir.path(),
28075 StatusCommandOptions {
28076 fix: false,
28077 no_fix: false,
28078 json_output: true,
28079 compact: false,
28080 pretty: false,
28081 terse: false,
28082 schema: false,
28083 },
28084 )
28085 .unwrap();
28086
28087 assert!(cfg.db_path_for(dir.path(), "alpha").exists());
28088 assert!(cfg.db_path_for(dir.path(), "beta").exists());
28089 let report = status::check_status(dir.path()).unwrap();
28090 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28091 }
28092
28093 #[test]
28094 fn status_cmd_fix_refreshes_stale_index() {
28095 let dir = setup_graph_index();
28096 std::thread::sleep(std::time::Duration::from_millis(50));
28097 std::fs::write(
28098 dir.path().join("main.rs"),
28099 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28100 )
28101 .unwrap();
28102
28103 let report = status::check_status(dir.path()).unwrap();
28104 assert!(matches!(report.index, status::IndexStatus::Stale { .. }));
28105
28106 cmd_status(
28107 dir.path(),
28108 StatusCommandOptions {
28109 fix: false,
28110 no_fix: false,
28111 json_output: true,
28112 compact: false,
28113 pretty: false,
28114 terse: false,
28115 schema: false,
28116 },
28117 )
28118 .unwrap();
28119
28120 let report = status::check_status(dir.path()).unwrap();
28121 assert!(matches!(report.index, status::IndexStatus::Fresh { .. }));
28122 }
28123
28124 #[test]
28125 fn status_cmd_reports_wal_snapshot_recovery_without_tsift_writer_lock() {
28126 let dir = setup_graph_index();
28127 let db_path = dir.path().join(".tsift/index.db");
28128 let _lock = hold_wal_database_lock(&db_path);
28129
28130 cmd_status(
28131 dir.path(),
28132 StatusCommandOptions {
28133 fix: false,
28134 no_fix: false,
28135 json_output: true,
28136 compact: false,
28137 pretty: false,
28138 terse: false,
28139 schema: false,
28140 },
28141 )
28142 .unwrap();
28143
28144 let report = status::check_status(dir.path()).unwrap();
28145 assert!(matches!(
28146 report.index,
28147 status::IndexStatus::Fresh {
28148 recovery: Some(index::ReadOnlyRecovery::SnapshotFallbackWal),
28149 ..
28150 }
28151 ));
28152 let locks = status::check_locks(dir.path(), None, None).unwrap();
28153 assert!(matches!(
28154 locks.writer_lock,
28155 status::WriterLockStatus::Absent { .. }
28156 ));
28157 assert!(locks.wal_sidecar.present || locks.shared_memory_sidecar.present);
28158 assert!(
28159 locks
28160 .recommended_action
28161 .contains("wedged writer holding live WAL sidecars")
28162 );
28163 }
28164
28165 #[test]
28166 fn locks_report_uses_ancestor_project_root_for_nested_paths() {
28167 let dir = setup_graph_index();
28168 let nested = dir.path().join("src/nested");
28169 std::fs::create_dir_all(&nested).unwrap();
28170
28171 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28172 let report = status::check_locks(&root, Some(&nested), None).unwrap();
28173
28174 assert_eq!(report.source_root, dir.path());
28175 assert_eq!(report.db_path, dir.path().join(".tsift/index.db"));
28176 }
28177
28178 #[test]
28179 fn workspace_locks_report_infers_scope_from_nested_path() {
28180 let dir = setup_workspace();
28181 cmd_index(
28182 dir.path(),
28183 false,
28184 false,
28185 false,
28186 false,
28187 false,
28188 true,
28189 None,
28190 false,
28191 false,
28192 false,
28193 false,
28194 false,
28195 false,
28196 )
28197 .unwrap();
28198 let nested = dir.path().join("src/alpha/nested");
28199 std::fs::create_dir_all(&nested).unwrap();
28200
28201 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28202 let report = status::check_locks(&root, Some(&nested), None).unwrap();
28203 let cfg = config::Config::load(dir.path()).unwrap();
28204
28205 assert_eq!(report.label, "submodule `alpha` index");
28206 assert_eq!(report.source_root, dir.path().join("src/alpha"));
28207 assert_eq!(report.db_path, cfg.db_path_for(dir.path(), "alpha"));
28208 assert_eq!(
28209 report.reindex_command,
28210 format!("tsift index --submodule alpha {}", dir.path().display())
28211 );
28212 }
28213
28214 #[test]
28215 fn scoped_search_cmd_autoindexes_stale_submodule_index_by_default() {
28216 let dir = setup_workspace();
28217 cmd_index(
28218 dir.path(),
28219 false,
28220 false,
28221 false,
28222 false,
28223 false,
28224 true,
28225 None,
28226 false,
28227 false,
28228 false,
28229 false,
28230 false,
28231 false,
28232 )
28233 .unwrap();
28234
28235 let alpha = dir.path().join("src/alpha/lib.rs");
28236 std::thread::sleep(std::time::Duration::from_millis(50));
28237 std::fs::write(
28238 &alpha,
28239 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28240 )
28241 .unwrap();
28242
28243 let result = cmd_search(
28244 "alpha_helper".to_string(),
28245 Some(dir.path().to_path_buf()),
28246 5,
28247 Some("lexical".to_string()),
28248 Some("alpha".to_string()),
28249 false,
28250 false,
28251 true,
28252 0,
28253 false,
28254 false,
28255 false,
28256 false,
28257 false,
28258 false,
28259 false,
28260 );
28261
28262 assert!(result.is_ok());
28263
28264 let cfg = config::Config::load(dir.path()).unwrap();
28265 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28266 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28267 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28268 }
28269
28270 #[test]
28271 fn scoped_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28272 let dir = setup_workspace();
28273 cmd_index(
28274 dir.path(),
28275 false,
28276 false,
28277 false,
28278 false,
28279 false,
28280 true,
28281 None,
28282 false,
28283 false,
28284 false,
28285 false,
28286 false,
28287 false,
28288 )
28289 .unwrap();
28290
28291 let alpha = dir.path().join("src/alpha/lib.rs");
28292 std::thread::sleep(std::time::Duration::from_millis(50));
28293 std::fs::write(
28294 &alpha,
28295 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28296 )
28297 .unwrap();
28298
28299 let cfg = config::Config::load(dir.path()).unwrap();
28300 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28301
28302 let err = cmd_search(
28303 "alpha_helper".to_string(),
28304 Some(dir.path().to_path_buf()),
28305 5,
28306 Some("lexical".to_string()),
28307 Some("alpha".to_string()),
28308 false,
28309 false,
28310 false,
28311 0,
28312 false,
28313 false,
28314 false,
28315 false,
28316 false,
28317 false,
28318 false,
28319 )
28320 .unwrap_err();
28321
28322 assert!(err.to_string().contains("search aborted"));
28323 assert!(err.to_string().contains("submodule `alpha` index"));
28324 assert!(!err.to_string().contains("database is locked"));
28325 }
28326
28327 #[test]
28328 fn federated_search_cmd_autoindexes_stale_indexes_by_default() {
28329 let dir = setup_workspace();
28330 cmd_index(
28331 dir.path(),
28332 false,
28333 false,
28334 false,
28335 false,
28336 false,
28337 true,
28338 None,
28339 false,
28340 false,
28341 false,
28342 false,
28343 false,
28344 false,
28345 )
28346 .unwrap();
28347
28348 let alpha = dir.path().join("src/alpha/lib.rs");
28349 std::thread::sleep(std::time::Duration::from_millis(50));
28350 std::fs::write(
28351 &alpha,
28352 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28353 )
28354 .unwrap();
28355
28356 let result = cmd_search(
28357 "alpha_helper".to_string(),
28358 Some(dir.path().to_path_buf()),
28359 5,
28360 Some("lexical".to_string()),
28361 None,
28362 true,
28363 false,
28364 true,
28365 0,
28366 false,
28367 false,
28368 false,
28369 false,
28370 false,
28371 false,
28372 false,
28373 );
28374
28375 assert!(result.is_ok());
28376
28377 let cfg = config::Config::load(dir.path()).unwrap();
28378 let db = index::IndexDb::open_read_only(&cfg.db_path_for(dir.path(), "alpha")).unwrap();
28379 let summary = db.compute_changes(&dir.path().join("src/alpha")).unwrap();
28380 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28381 }
28382
28383 #[test]
28384 fn federated_search_cmd_reports_stale_when_submodule_index_is_locked_by_rollback_journal() {
28385 let dir = setup_workspace();
28386 cmd_index(
28387 dir.path(),
28388 false,
28389 false,
28390 false,
28391 false,
28392 false,
28393 true,
28394 None,
28395 false,
28396 false,
28397 false,
28398 false,
28399 false,
28400 false,
28401 )
28402 .unwrap();
28403
28404 let alpha = dir.path().join("src/alpha/lib.rs");
28405 std::thread::sleep(std::time::Duration::from_millis(50));
28406 std::fs::write(
28407 &alpha,
28408 "fn alpha_helper() { println!(\"updated\"); }\nfn alpha_main() { alpha_helper(); }",
28409 )
28410 .unwrap();
28411
28412 let cfg = config::Config::load(dir.path()).unwrap();
28413 let _lock = hold_rollback_journal_lock(&cfg.db_path_for(dir.path(), "alpha"));
28414
28415 let err = cmd_search(
28416 "alpha_helper".to_string(),
28417 Some(dir.path().to_path_buf()),
28418 5,
28419 Some("lexical".to_string()),
28420 None,
28421 true,
28422 false,
28423 false,
28424 30,
28425 false,
28426 false,
28427 false,
28428 false,
28429 false,
28430 false,
28431 false,
28432 )
28433 .unwrap_err();
28434
28435 assert!(err.to_string().contains("stale"));
28436 assert!(err.to_string().contains("submodule `alpha` index"));
28437 assert!(!err.to_string().contains("database is locked"));
28438 }
28439
28440 #[test]
28441 fn workspace_search_cmd_requires_explicit_target_without_shared_root_index() {
28442 let dir = setup_workspace();
28443 cmd_index(
28444 dir.path(),
28445 false,
28446 false,
28447 false,
28448 false,
28449 false,
28450 true,
28451 None,
28452 false,
28453 false,
28454 false,
28455 false,
28456 false,
28457 false,
28458 )
28459 .unwrap();
28460
28461 let err = cmd_search(
28462 "alpha_helper".to_string(),
28463 Some(dir.path().to_path_buf()),
28464 5,
28465 Some("lexical".to_string()),
28466 None,
28467 false,
28468 false,
28469 true,
28470 0,
28471 false,
28472 false,
28473 false,
28474 false,
28475 false,
28476 false,
28477 false,
28478 )
28479 .unwrap_err();
28480
28481 assert_workspace_search_requires_explicit_target(err);
28482 assert!(!dir.path().join(".tsift/index.db").exists());
28483 }
28484
28485 #[test]
28486 fn workspace_search_cmd_infers_scope_from_nested_path() {
28487 let dir = setup_workspace();
28488 cmd_index(
28489 dir.path(),
28490 false,
28491 false,
28492 false,
28493 false,
28494 false,
28495 true,
28496 None,
28497 false,
28498 false,
28499 false,
28500 false,
28501 false,
28502 false,
28503 )
28504 .unwrap();
28505 let nested = dir.path().join("src/alpha/nested");
28506 std::fs::create_dir_all(&nested).unwrap();
28507
28508 let result = cmd_search(
28509 "alpha_helper".to_string(),
28510 Some(nested),
28511 5,
28512 Some("lexical".to_string()),
28513 None,
28514 false,
28515 false,
28516 false,
28517 0,
28518 false,
28519 false,
28520 false,
28521 false,
28522 false,
28523 false,
28524 false,
28525 );
28526
28527 assert!(result.is_ok());
28528 }
28529
28530 #[test]
28531 fn resolve_query_db_path_infers_matching_duplicate_leaf_scope_from_nested_path() {
28532 let dir = setup_workspace_with_duplicate_leaf_names();
28533 cmd_index(
28534 dir.path(),
28535 false,
28536 false,
28537 false,
28538 false,
28539 false,
28540 true,
28541 None,
28542 false,
28543 false,
28544 false,
28545 false,
28546 false,
28547 false,
28548 )
28549 .unwrap();
28550 let nested = dir.path().join("vendor/foo/nested");
28551 std::fs::create_dir_all(&nested).unwrap();
28552
28553 let root = lint::resolve_project_root_or_canonical_path(&nested).unwrap();
28554 let db_path = resolve_query_db_path(&root, &nested, None).unwrap();
28555 let cfg = config::Config::load(dir.path()).unwrap();
28556
28557 assert_eq!(db_path, cfg.db_path_for(dir.path(), "vendor/foo"));
28558 }
28559
28560 #[test]
28561 fn graph_cmd_succeeds_while_writer_lock_is_held() {
28562 let dir = setup_graph_index();
28563 let db_path = dir.path().join(".tsift/index.db");
28564 let _lock = hold_write_lock(&db_path);
28565
28566 let result = cmd_graph(
28567 "main",
28568 dir.path(),
28569 false,
28570 false,
28571 None,
28572 20,
28573 false,
28574 true,
28575 false,
28576 false,
28577 false,
28578 false,
28579 false,
28580 TagpathSearchOpts::default(),
28581 );
28582
28583 assert!(result.is_ok());
28584 }
28585
28586 #[test]
28587 fn graph_cmd_autoindexes_stale_index_by_default() {
28588 let dir = setup_graph_index();
28589 std::thread::sleep(std::time::Duration::from_millis(50));
28590 std::fs::write(
28591 dir.path().join("main.rs"),
28592 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
28593 )
28594 .unwrap();
28595
28596 let result = cmd_graph(
28597 "helper",
28598 dir.path(),
28599 true,
28600 false,
28601 None,
28602 20,
28603 false,
28604 true,
28605 false,
28606 false,
28607 false,
28608 false,
28609 false,
28610 TagpathSearchOpts::default(),
28611 );
28612
28613 assert!(result.is_ok());
28614 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
28615 let summary = db.compute_changes(dir.path()).unwrap();
28616 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
28617 }
28618
28619 #[test]
28620 fn graph_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28621 let dir = setup_graph_index();
28622 let db_path = dir.path().join(".tsift/index.db");
28623 let _lock = hold_rollback_journal_lock(&db_path);
28624
28625 let result = cmd_graph(
28626 "main",
28627 dir.path(),
28628 false,
28629 false,
28630 None,
28631 20,
28632 false,
28633 true,
28634 false,
28635 false,
28636 false,
28637 false,
28638 false,
28639 TagpathSearchOpts::default(),
28640 );
28641
28642 assert!(result.is_ok());
28643 }
28644
28645 #[test]
28646 fn graph_cmd_uses_ancestor_project_root_for_nested_paths() {
28647 let dir = setup_graph_index();
28648 let nested = dir.path().join("src/nested");
28649 std::fs::create_dir_all(&nested).unwrap();
28650
28651 let result = cmd_graph(
28652 "helper",
28653 &nested,
28654 true,
28655 false,
28656 None,
28657 20,
28658 false,
28659 false,
28660 false,
28661 false,
28662 false,
28663 false,
28664 false,
28665 TagpathSearchOpts::default(),
28666 );
28667
28668 assert!(result.is_ok());
28669 }
28670
28671 #[test]
28672 fn communities_cmd_succeeds_while_writer_lock_is_held() {
28673 let dir = setup_graph_index();
28674 let _lock = hold_writer_lock(&dir.path().join(".tsift/index.lock"));
28675
28676 let result = cmd_communities(
28677 dir.path(),
28678 None,
28679 1,
28680 10,
28681 false,
28682 false,
28683 false,
28684 false,
28685 false,
28686 false,
28687 TagpathSearchOpts::default(),
28688 );
28689
28690 assert!(result.is_ok());
28691 }
28692
28693 #[test]
28694 fn communities_cmd_uses_snapshot_fallback_when_rollback_journal_is_locked() {
28695 let dir = setup_graph_index();
28696 let db_path = dir.path().join(".tsift/index.db");
28697 let _lock = hold_rollback_journal_lock(&db_path);
28698
28699 let result = cmd_communities(
28700 dir.path(),
28701 None,
28702 1,
28703 10,
28704 false,
28705 false,
28706 false,
28707 false,
28708 false,
28709 false,
28710 TagpathSearchOpts::default(),
28711 );
28712
28713 assert!(result.is_ok());
28714 }
28715
28716 #[test]
28717 fn lint_finds_entities_from_project_root_index_db() {
28718 let dir = tempfile::tempdir().unwrap();
28719 std::fs::write(dir.path().join("main.rs"), "fn alpha_helper() {}\n").unwrap();
28720 std::fs::write(
28721 dir.path().join("README.md"),
28722 "alpha_helper should be backticked.\n",
28723 )
28724 .unwrap();
28725 cmd_index(
28726 dir.path(),
28727 false,
28728 false,
28729 false,
28730 false,
28731 false,
28732 false,
28733 None,
28734 false,
28735 false,
28736 false,
28737 false,
28738 false,
28739 false,
28740 )
28741 .unwrap();
28742
28743 let root = lint::find_project_root_for_path(&dir.path().join("README.md"))
28744 .unwrap()
28745 .unwrap();
28746 let entities = lint::collect_entities_from_index_path(&root).unwrap();
28747 let result = lint::lint_markdown(&dir.path().join("README.md"), &entities).unwrap();
28748
28749 assert!(
28750 result
28751 .annotations
28752 .iter()
28753 .any(|ann| ann.text == "alpha_helper")
28754 );
28755 }
28756
28757 #[test]
28760 fn search_direct_runs_ok() {
28761 let dir = tempfile::tempdir().unwrap();
28762 let search_dir = dir.path().to_path_buf();
28763 let cache_dir = search_dir.join(".tsift/search-cache");
28764 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28765 let result = run_sift_search(&search_dir, &cache_dir, "main", 1, "lexical", None);
28766 assert!(result.is_ok(), "direct search should succeed");
28767 assert!(
28768 cache_dir.exists(),
28769 "search should create the configured cache dir"
28770 );
28771 }
28772
28773 #[test]
28774 fn search_timeout_zero_disables_timeout() {
28775 let dir = tempfile::tempdir().unwrap();
28776 let search_dir = dir.path().to_path_buf();
28777 let cache_dir = search_dir.join(".tsift/search-cache");
28778 std::fs::write(search_dir.join("test.rs"), "fn main() {}").unwrap();
28779 let result =
28780 run_search_with_timeout(&search_dir, &cache_dir, "main", 1, 0, "lexical", &[], None);
28781 assert!(result.is_ok(), "timeout=0 should still work (no timeout)");
28782 assert!(
28783 cache_dir.exists(),
28784 "timeout=0 should keep using the stable search cache dir"
28785 );
28786 }
28787
28788 #[test]
28789 fn search_timeout_message_reports_missing_index_as_rebuild_needed() {
28790 let dir = tempfile::tempdir().unwrap();
28791 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
28792 cmd_index(
28793 dir.path(),
28794 false,
28795 false,
28796 false,
28797 false,
28798 false,
28799 false,
28800 None,
28801 false,
28802 false,
28803 false,
28804 false,
28805 false,
28806 false,
28807 )
28808 .unwrap();
28809 let db_path = dir.path().join(".tsift/index.db");
28810 std::fs::remove_file(&db_path).unwrap();
28811 let search_target = SearchIndexTarget {
28812 label: "index".to_string(),
28813 db_path,
28814 source_root: dir.path().to_path_buf(),
28815 scope_name: None,
28816 reindex_cmd: format!("tsift index {}", dir.path().display()),
28817 };
28818
28819 let message = search_timeout_message(1, "lexical", &[search_target]).unwrap();
28820
28821 assert!(message.contains("timed out after 1s"));
28822 assert!(message.contains("index is missing"));
28823 assert!(message.contains("Run `tsift index"));
28824 assert!(!message.contains("search root looks fresh"));
28825 }
28826
28827 #[test]
28828 fn search_worker_output_path_uses_json_suffix() {
28829 let path = next_search_worker_output_path();
28830 assert!(path.extension().is_some_and(|ext| ext == "json"));
28831 }
28832
28833 #[test]
28834 fn fts_search_flag_value_parses_falsy_escape_hatch() {
28835 for falsy in ["0", "false", "FALSE", " no ", "Off"] {
28837 assert!(
28838 fts_flag_value_disabled(falsy),
28839 "{falsy:?} should force the legacy TokenIndex path"
28840 );
28841 }
28842 for keeps_default in ["", "1", "true", "yes", "on", "maybe"] {
28843 assert!(
28844 !fts_flag_value_disabled(keeps_default),
28845 "{keeps_default:?} should keep the FTS5 default"
28846 );
28847 }
28848 }
28849
28850 #[test]
28851 fn run_sift_search_defaults_to_fts_when_index_db_present() {
28852 let dir = tempfile::tempdir().unwrap();
28855 let root = dir.path();
28856 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28857 index::IndexDb::open(&root.join(".tsift/index.db"))
28858 .unwrap()
28859 .apply_changes(root)
28860 .unwrap();
28861 let cache_dir = root.join(".tsift/search-cache");
28862
28863 if fts_search_forced_off() {
28865 return;
28866 }
28867 let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
28868 assert_eq!(response.strategy, "fts");
28869 assert!(response.hits.iter().any(|h| h.path.ends_with("alpha.rs")));
28870 }
28871
28872 #[test]
28873 fn run_sift_search_falls_back_to_lexical_without_index_db() {
28874 let dir = tempfile::tempdir().unwrap();
28877 let root = dir.path();
28878 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28879 let cache_dir = root.join(".tsift/search-cache");
28880
28881 let response = run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", None).unwrap();
28882 assert_eq!(response.strategy, "lexical");
28883 }
28884
28885 #[test]
28886 fn run_sift_search_honors_threaded_freshness_verdict() {
28887 let dir = tempfile::tempdir().unwrap();
28891 let root = dir.path();
28892 std::fs::write(root.join("alpha.rs"), "fn alpha_handler() {}\n").unwrap();
28893 index::IndexDb::open(&root.join(".tsift/index.db"))
28894 .unwrap()
28895 .apply_changes(root)
28896 .unwrap();
28897 let cache_dir = root.join(".tsift/search-cache");
28898
28899 if fts_search_forced_off() {
28900 return;
28901 }
28902 let fresh =
28903 run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(true)).unwrap();
28904 assert_eq!(fresh.strategy, "fts");
28905
28906 let stale =
28907 run_sift_search(root, &cache_dir, "alpha_handler", 5, "lexical", Some(false)).unwrap();
28908 assert_eq!(stale.strategy, "lexical");
28909 }
28910
28911 #[test]
28914 fn index_quiet_suppresses_file_list() {
28915 let dir = setup_graph_index();
28916 let result = cmd_index(
28917 dir.path(),
28918 false,
28919 true,
28920 false,
28921 false,
28922 true,
28923 false,
28924 None,
28925 false,
28926 false,
28927 false,
28928 false,
28929 false,
28930 false,
28931 );
28932 assert!(result.is_ok());
28933 }
28934
28935 #[test]
28936 fn index_exit_code_implies_quiet() {
28937 let dir = setup_graph_index();
28938 let result = cmd_index(
28939 dir.path(),
28940 false,
28941 true,
28942 false,
28943 false,
28944 false,
28945 false,
28946 None,
28947 false,
28948 false,
28949 false,
28950 false,
28951 false,
28952 false,
28953 );
28954 assert!(result.is_ok());
28955 }
28956
28957 #[test]
28958 fn index_quiet_json_omits_changes() {
28959 let dir = setup_graph_index();
28960 let result = cmd_index(
28961 dir.path(),
28962 false,
28963 true,
28964 false,
28965 false,
28966 true,
28967 false,
28968 None,
28969 true,
28970 false,
28971 false,
28972 false,
28973 false,
28974 false,
28975 );
28976 assert!(result.is_ok());
28977 }
28978
28979 #[test]
28980 fn cli_workflow_defaults_to_search_topic() {
28981 let cli = parse_cli(["tsift", "workflow"]);
28982 match cli.command {
28983 Some(Commands::Workflow { topic, json }) => {
28984 assert_eq!(topic, "search");
28985 assert!(!json);
28986 }
28987 _ => panic!("expected Workflow command"),
28988 }
28989 }
28990
28991 #[test]
28992 fn search_workflow_recipe_preserves_handles_across_expansions() {
28993 let recipe = workflow::search_workflow_recipe();
28994 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
28995 assert_eq!(
28996 step_names,
28997 vec![
28998 "exact-anchor",
28999 "semantic-search",
29000 "explain-symbol",
29001 "summarize-selection",
29002 "digest-expansion"
29003 ]
29004 );
29005 assert!(
29006 recipe
29007 .handle_contract
29008 .iter()
29009 .any(|item| item.contains("originating command"))
29010 );
29011 assert!(
29012 recipe.steps[1]
29013 .preserves
29014 .iter()
29015 .any(|item| item.contains("sfam-*"))
29016 );
29017 assert!(
29018 recipe.steps[2]
29019 .preserves
29020 .iter()
29021 .any(|item| item.contains("ecall-*"))
29022 );
29023 assert!(
29024 recipe.steps[4]
29025 .preserves
29026 .iter()
29027 .any(|item| item.contains("artifact handles"))
29028 );
29029 }
29030
29031 #[test]
29032 fn kg_workflow_recipe_covers_extract_to_evidence() {
29033 let recipe = workflow::kg_workflow_recipe();
29034 assert_eq!(recipe.topic, "kg");
29035 let step_names: Vec<&str> = recipe.steps.iter().map(|step| step.name).collect();
29036 assert_eq!(
29037 step_names,
29038 vec!["smoke-check", "extract", "status", "refresh", "evidence"]
29039 );
29040 let evidence = recipe.steps.last().unwrap();
29042 assert!(evidence.command.contains("kg evidence --symbol"));
29043 assert!(!evidence.command.contains("--budget"));
29044 assert!(
29046 recipe
29047 .handle_contract
29048 .iter()
29049 .any(|item| item.contains("Extract once"))
29050 );
29051 }
29052
29053 #[test]
29056 fn to_json_compact_default() {
29057 let val = serde_json::json!({"a": 1, "b": [2, 3]});
29058 let compact = to_json(&val, false, false).unwrap();
29059 assert!(!compact.contains('\n'));
29060 assert!(
29061 compact.contains("\"a\":1")
29062 || compact.contains("\"a\": 1")
29063 || compact.contains("\"a\":")
29064 );
29065 }
29066
29067 #[test]
29068 fn to_json_pretty_indents() {
29069 let val = serde_json::json!({"a": 1, "b": [2, 3]});
29070 let pretty = to_json(&val, true, false).unwrap();
29071 assert!(pretty.contains('\n'));
29072 assert!(pretty.contains(" "));
29073 }
29074
29075 #[test]
29076 fn to_json_compact_is_shorter() {
29077 let val =
29078 serde_json::json!({"name": "test", "items": [1, 2, 3], "nested": {"key": "value"}});
29079 let compact = to_json(&val, false, false).unwrap();
29080 let pretty = to_json(&val, true, false).unwrap();
29081 assert!(compact.len() < pretty.len());
29082 }
29083
29084 #[test]
29085 fn terse_renames_keys() {
29086 let val =
29087 serde_json::json!({"caller_file": "a.rs", "caller_name": "main", "call_site_line": 10});
29088 let result = to_json(&val, false, true).unwrap();
29089 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29090 assert!(parsed["_s"].is_object());
29091 let d = &parsed["d"];
29092 assert_eq!(d["cf"], "a.rs");
29093 assert_eq!(d["cn"], "main");
29094 assert_eq!(d["csl"], 10);
29095 }
29096
29097 #[test]
29098 fn terse_schema_only_includes_used_keys() {
29099 let val = serde_json::json!({"name": "test", "score": 0.5});
29100 let result = to_json(&val, false, true).unwrap();
29101 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29102 let schema = parsed["_s"].as_object().unwrap();
29103 assert_eq!(schema["n"], "name");
29104 assert_eq!(schema["sc"], "score");
29105 assert!(!schema.contains_key("cf"));
29106 }
29107
29108 #[test]
29109 fn terse_nested_arrays() {
29110 let val = serde_json::json!({"callers": [{"caller_name": "a", "caller_file": "b.rs", "caller_line": 1, "callee_name": "c", "call_site_line": 2}]});
29111 let result = to_json(&val, false, true).unwrap();
29112 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29113 let d = &parsed["d"];
29114 assert_eq!(d["crs"][0]["cn"], "a");
29115 assert_eq!(d["crs"][0]["cf"], "b.rs");
29116 }
29117
29118 #[test]
29119 fn terse_preserves_unknown_keys() {
29120 let val = serde_json::json!({"custom_field": "value", "name": "test"});
29121 let result = to_json(&val, false, true).unwrap();
29122 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29123 let d = &parsed["d"];
29124 assert_eq!(d["custom_field"], "value");
29125 assert_eq!(d["n"], "test");
29126 }
29127
29128 #[test]
29131 fn ultra_terse_strips_properties_from_graph_nodes() {
29132 let val = serde_json::json!({
29133 "nodes": [{"id": "fn:main", "kind": "fn", "name": "main", "properties": {"line": "10"}}]
29134 });
29135 let result = to_json_schema(&val, false, true, true, false).unwrap();
29136 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29137 let node = &parsed["d"]["nodes"][0];
29138 assert_eq!(node["id"], "fn:main");
29139 assert_eq!(node["k"], "fn");
29140 assert_eq!(node["n"], "main");
29141 assert!(node.get("properties").is_none());
29142 }
29143
29144 #[test]
29145 fn ultra_terse_strips_properties_from_graph_edges() {
29146 let val = serde_json::json!({
29147 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "properties": {"weight": "2"}}]
29148 });
29149 let result = to_json_schema(&val, false, true, true, false).unwrap();
29150 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29151 let edge = &parsed["d"]["edges"][0];
29152 assert_eq!(edge["from_id"], "a");
29153 assert_eq!(edge["to_id"], "b");
29154 assert_eq!(edge["k"], "c");
29155 assert!(edge.get("properties").is_none());
29156 }
29157
29158 #[test]
29159 fn ultra_terse_abbreviates_edge_kinds() {
29160 let val = serde_json::json!({
29161 "edges": [
29162 {"from_id": "a", "to_id": "b", "kind": "defines"},
29163 {"from_id": "a", "to_id": "c", "kind": "contains"},
29164 {"from_id": "a", "to_id": "d", "kind": "imports"},
29165 {"from_id": "a", "to_id": "e", "kind": "mentions"},
29166 {"from_id": "a", "to_id": "f", "kind": "semantic_relation"},
29167 {"from_id": "a", "to_id": "g", "kind": "belongs_to"},
29168 {"from_id": "a", "to_id": "h", "kind": "scopes_context"},
29169 {"from_id": "a", "to_id": "i", "kind": "uses"},
29170 {"from_id": "a", "to_id": "j", "kind": "parent"},
29171 {"from_id": "a", "to_id": "k", "kind": "unknown_edge"},
29172 ]
29173 });
29174 let result = to_json_schema(&val, false, true, true, false).unwrap();
29175 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29176 let edges = &parsed["d"]["edges"].as_array().unwrap();
29177 assert_eq!(edges[0]["k"], "d");
29178 assert_eq!(edges[1]["k"], "ct");
29179 assert_eq!(edges[2]["k"], "i");
29180 assert_eq!(edges[3]["k"], "m");
29181 assert_eq!(edges[4]["k"], "sr");
29182 assert_eq!(edges[5]["k"], "bt");
29183 assert_eq!(edges[6]["k"], "sctx");
29184 assert_eq!(edges[7]["k"], "u");
29185 assert_eq!(edges[8]["k"], "p");
29186 assert_eq!(edges[9]["k"], "unknown_edge");
29187 }
29188
29189 #[test]
29190 fn ultra_terse_strips_provenance_freshness_from_edges() {
29191 let val = serde_json::json!({
29192 "edges": [{"from_id": "a", "to_id": "b", "kind": "calls", "provenance": [{"source": "tsift"}], "freshness": {"observed_at_unix": 1234567890}}]
29193 });
29194 let result = to_json_schema(&val, false, true, true, false).unwrap();
29195 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29196 let edge = &parsed["d"]["edges"][0];
29197 assert!(edge.get("provenance").is_none());
29198 assert!(edge.get("freshness").is_none());
29199 assert_eq!(edge["k"], "c");
29200 }
29201
29202 #[test]
29203 fn ultra_terse_truncates_snippets() {
29204 let long_snippet = "x".repeat(120);
29205 let val = serde_json::json!({"snippet": long_snippet});
29206 let result = to_json_schema(&val, false, true, true, false).unwrap();
29207 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29208 let snipped = parsed["d"]["sn"].as_str().unwrap();
29209 assert_eq!(snipped.len(), 80);
29210 assert!(snipped.ends_with("..."));
29211 }
29212
29213 #[test]
29214 fn ultra_terse_truncates_abbreviated_snippet_key() {
29215 let long_snippet = "y".repeat(100);
29216 let val = serde_json::json!({"snippet": long_snippet});
29217 let result = to_json_schema(&val, false, true, true, false).unwrap();
29218 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29219 let snipped = parsed["d"]["sn"].as_str().unwrap();
29220 assert_eq!(snipped.len(), 80);
29221 assert!(snipped.ends_with("..."));
29222 }
29223
29224 #[test]
29225 fn ultra_terse_compacts_coverage_snapshot() {
29226 let val = serde_json::json!({
29227 "mode": "incremental",
29228 "total_sector_count": 10,
29229 "dirty_sector_count": 2,
29230 "active_rebuild": Some("rebuild-1"),
29231 "completed_dirty_sector_count": 1,
29232 "mounted_sector_count": 8,
29233 "rebuilding_sector_count": 1,
29234 "resumed_sector_count": 3,
29235 "reused_sector_count": 5
29236 });
29237 let result = to_json_schema(&val, false, true, true, false).unwrap();
29238 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29239 let d = &parsed["d"];
29240 assert_eq!(d["mode"], "incremental");
29241 assert_eq!(d["total_sector_count"], 10);
29242 assert_eq!(d["dirty_sector_count"], 2);
29243 assert!(d.get("active_rebuild").is_none());
29244 assert!(d.get("completed_dirty_sector_count").is_none());
29245 assert!(d.get("mounted_sector_count").is_none());
29246 assert!(d.get("rebuilding_sector_count").is_none());
29247 assert!(d.get("resumed_sector_count").is_none());
29248 assert!(d.get("reused_sector_count").is_none());
29249 }
29250
29251 #[test]
29252 fn ultra_terse_short_snippet_unchanged() {
29253 let val = serde_json::json!({"snippet": "short text"});
29254 let result = to_json_schema(&val, false, true, true, false).unwrap();
29255 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29256 assert_eq!(parsed["d"]["sn"], "short text");
29257 }
29258
29259 #[test]
29260 fn ultra_terse_non_graph_object_properties_preserved() {
29261 let val = serde_json::json!({"config": {"properties": {"a": "1"}}});
29262 let result = to_json_schema(&val, false, true, true, false).unwrap();
29263 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29264 assert!(parsed["d"]["config"]["properties"].is_object());
29265 }
29266
29267 #[test]
29270 fn schema_converts_homogeneous_arrays() {
29271 let val = serde_json::json!({"symbols": [
29272 {"name": "foo", "kind": "fn", "line": 10},
29273 {"name": "bar", "kind": "fn", "line": 20}
29274 ]});
29275 let result = to_json_schema(&val, false, false, false, true).unwrap();
29276 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29277 let syms = &parsed["symbols"];
29278 let columns = syms["_c"]
29279 .as_array()
29280 .unwrap()
29281 .iter()
29282 .map(|value| value.as_str().unwrap())
29283 .collect::<Vec<_>>();
29284 let row0 = syms["_r"][0].as_array().unwrap();
29285 let row1 = syms["_r"][1].as_array().unwrap();
29286 let name_index = columns.iter().position(|column| *column == "name").unwrap();
29287 let kind_index = columns.iter().position(|column| *column == "kind").unwrap();
29288 let line_index = columns.iter().position(|column| *column == "line").unwrap();
29289 assert_eq!(row0[name_index], "foo");
29290 assert_eq!(row0[kind_index], "fn");
29291 assert_eq!(row0[line_index], 10);
29292 assert_eq!(row1[name_index], "bar");
29293 assert_eq!(row1[kind_index], "fn");
29294 assert_eq!(row1[line_index], 20);
29295 }
29296
29297 #[test]
29298 fn schema_skips_short_arrays() {
29299 let val = serde_json::json!({"items": [{"name": "only"}]});
29300 let result = to_json_schema(&val, false, false, false, true).unwrap();
29301 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29302 assert!(parsed["items"].is_array());
29303 assert_eq!(parsed["items"][0]["name"], "only");
29304 }
29305
29306 #[test]
29307 fn schema_skips_heterogeneous_arrays() {
29308 let val = serde_json::json!({"items": [{"a": 1}, {"b": 2}]});
29309 let result = to_json_schema(&val, false, false, false, true).unwrap();
29310 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29311 assert!(parsed["items"].is_array());
29312 assert_eq!(parsed["items"][0]["a"], 1);
29313 }
29314
29315 #[test]
29316 fn schema_with_terse_combines() {
29317 let val = serde_json::json!({"callers": [
29318 {"caller_name": "a", "caller_file": "x.rs"},
29319 {"caller_name": "b", "caller_file": "y.rs"}
29320 ]});
29321 let result = to_json_schema(&val, false, true, false, true).unwrap();
29322 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29323 assert!(parsed["_s"].is_object());
29324 let d = &parsed["d"];
29325 let crs = &d["crs"];
29326 assert!(crs["_c"].is_array());
29327 assert!(crs["_r"].is_array());
29328 let columns = crs["_c"]
29329 .as_array()
29330 .unwrap()
29331 .iter()
29332 .map(|value| value.as_str().unwrap())
29333 .collect::<Vec<_>>();
29334 let row = crs["_r"][0].as_array().unwrap();
29335 let name_index = columns.iter().position(|column| *column == "cn").unwrap();
29336 let file_index = columns.iter().position(|column| *column == "cf").unwrap();
29337 assert_eq!(row[name_index], "a");
29338 assert_eq!(row[file_index], "x.rs");
29339 }
29340
29341 #[test]
29342 fn schema_preserves_non_object_arrays() {
29343 let val = serde_json::json!({"tags": ["a", "b", "c"]});
29344 let result = to_json_schema(&val, false, false, false, true).unwrap();
29345 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
29346 assert_eq!(parsed["tags"], serde_json::json!(["a", "b", "c"]));
29347 }
29348
29349 #[test]
29350 fn cli_accepts_global_schema_flag() {
29351 let cli = parse_cli(["tsift", "--schema", "search", "test"]);
29352 assert!(cli.schema);
29353 assert!(matches!(cli.command, Some(Commands::Search { .. })));
29354 }
29355
29356 #[test]
29357 fn cli_accepts_global_envelope_flag() {
29358 let cli = parse_cli([
29359 "tsift",
29360 "--envelope",
29361 "context-pack",
29362 "tasks/software/tsift.md",
29363 ]);
29364 assert!(cli.envelope);
29365 assert!(matches!(cli.command, Some(Commands::ContextPack { .. })));
29366 }
29367
29368 #[test]
29369 fn cli_accepts_locks_command() {
29370 let cli = parse_cli(["tsift", "locks"]);
29371 assert!(matches!(cli.command, Some(Commands::Locks { .. })));
29372 }
29373
29374 #[test]
29375 fn cli_parses_memory_budget_guard_command() {
29376 let cli = parse_cli([
29377 "tsift",
29378 "memory",
29379 "budget-guard",
29380 "--file",
29381 "tool.log",
29382 "--budget-tokens",
29383 "1000",
29384 "--json",
29385 ]);
29386 match cli.command {
29387 Some(Commands::Memory {
29388 command:
29389 crate::cli::MemoryCommand::BudgetGuard {
29390 file,
29391 budget_tokens,
29392 json,
29393 ..
29394 },
29395 }) => {
29396 assert_eq!(file.as_deref(), Some(std::path::Path::new("tool.log")));
29397 assert_eq!(budget_tokens, 1000);
29398 assert!(json);
29399 }
29400 _ => panic!("expected memory budget-guard command"),
29401 }
29402 }
29403
29404 #[test]
29405 fn cli_parses_memory_capture_agent_doc_closeout_command() {
29406 let cli = parse_cli([
29407 "tsift",
29408 "memory",
29409 "capture-agent-doc-closeout",
29410 ".",
29411 "--session-path",
29412 "tasks/software/tsift.md",
29413 "--prompt-target",
29414 "do [#tsiftmemhooks]",
29415 "--response-summary",
29416 "wired closeout capture",
29417 "--commit-hash",
29418 "abc123",
29419 "--session-check-status",
29420 "clean",
29421 "--json",
29422 ]);
29423 match cli.command {
29424 Some(Commands::Memory {
29425 command:
29426 crate::cli::MemoryCommand::CaptureAgentDocCloseout {
29427 path,
29428 session_path,
29429 prompt_target,
29430 response_summary,
29431 commit_hash,
29432 session_check_status,
29433 json,
29434 },
29435 }) => {
29436 assert_eq!(path, std::path::PathBuf::from("."));
29437 assert_eq!(
29438 session_path,
29439 std::path::PathBuf::from("tasks/software/tsift.md")
29440 );
29441 assert_eq!(prompt_target, "do [#tsiftmemhooks]");
29442 assert_eq!(response_summary, "wired closeout capture");
29443 assert_eq!(commit_hash.as_deref(), Some("abc123"));
29444 assert_eq!(session_check_status, "clean");
29445 assert!(json);
29446 }
29447 _ => panic!("expected memory capture-agent-doc-closeout command"),
29448 }
29449 }
29450
29451 #[test]
29452 fn cli_parses_memory_project_graph_read_policy() {
29453 let cli = parse_cli([
29454 "tsift",
29455 "memory",
29456 "project-graph",
29457 ".",
29458 "--read-policy",
29459 "query-relevant",
29460 "--query",
29461 "semantic memory",
29462 "--limit",
29463 "7",
29464 "--json",
29465 ]);
29466 match cli.command {
29467 Some(Commands::Memory {
29468 command:
29469 crate::cli::MemoryCommand::ProjectGraph {
29470 read_policy,
29471 query,
29472 limit,
29473 json,
29474 ..
29475 },
29476 }) => {
29477 assert_eq!(
29478 read_policy,
29479 crate::cli::MemoryProjectReadPolicy::QueryRelevant
29480 );
29481 assert_eq!(query.as_deref(), Some("semantic memory"));
29482 assert_eq!(limit, 7);
29483 assert!(json);
29484 }
29485 _ => panic!("expected memory project-graph command"),
29486 }
29487 }
29488
29489 #[test]
29490 fn cli_locks_accepts_scope_flag() {
29491 let cli = parse_cli(["tsift", "locks", "--scope", "alpha"]);
29492 match cli.command {
29493 Some(Commands::Locks { scope, .. }) => {
29494 assert_eq!(scope.as_deref(), Some("alpha"));
29495 }
29496 _ => panic!("expected Locks command"),
29497 }
29498 }
29499
29500 #[test]
29501 fn cli_search_accepts_autoindex_flag() {
29502 let cli = parse_cli(["tsift", "search", "test", "--autoindex"]);
29503 match cli.command {
29504 Some(Commands::Search {
29505 autoindex,
29506 no_autoindex,
29507 ..
29508 }) => {
29509 assert!(autoindex);
29510 assert!(!no_autoindex);
29511 }
29512 _ => panic!("expected Search command"),
29513 }
29514 }
29515
29516 #[test]
29517 fn cli_search_accepts_exact_flag() {
29518 let cli = parse_cli(["tsift", "search", "test", "--exact"]);
29519 match cli.command {
29520 Some(Commands::Search {
29521 exact, strategy, ..
29522 }) => {
29523 assert!(exact);
29524 assert!(strategy.is_none());
29525 }
29526 _ => panic!("expected Search command"),
29527 }
29528 }
29529
29530 #[test]
29531 fn cli_parses_diff_digest_command() {
29532 let cli = parse_cli(["tsift", "diff-digest", "--json", "."]);
29533 match cli.command {
29534 Some(Commands::DiffDigest {
29535 json,
29536 path,
29537 cached,
29538 revision,
29539 max_parsed_files,
29540 }) => {
29541 assert!(json);
29542 assert_eq!(path, PathBuf::from("."));
29543 assert!(!cached);
29544 assert!(revision.is_none());
29545 assert_eq!(max_parsed_files, 25);
29546 }
29547 _ => panic!("expected DiffDigest command"),
29548 }
29549 }
29550
29551 #[test]
29552 fn cli_rejects_conflicting_diff_digest_modes() {
29553 match try_parse_cli([
29554 "tsift",
29555 "diff-digest",
29556 "--cached",
29557 "--revision",
29558 "HEAD",
29559 ".",
29560 ]) {
29561 Ok(_) => panic!("expected conflicting diff-digest modes to fail"),
29562 Err(err) => {
29563 assert!(err.to_string().contains("--cached"));
29564 assert!(err.to_string().contains("--revision"));
29565 }
29566 }
29567 }
29568
29569 #[test]
29570 fn cli_parses_test_digest_command() {
29571 let cli = parse_cli([
29572 "tsift",
29573 "test-digest",
29574 "--path",
29575 ".",
29576 "--input",
29577 "target/test.log",
29578 "--runner",
29579 "cargo",
29580 "--json",
29581 ]);
29582 match cli.command {
29583 Some(Commands::TestDigest {
29584 json,
29585 path,
29586 input,
29587 runner,
29588 }) => {
29589 assert!(json);
29590 assert_eq!(path, PathBuf::from("."));
29591 assert_eq!(input, Some(PathBuf::from("target/test.log")));
29592 assert_eq!(runner.as_deref(), Some("cargo"));
29593 }
29594 _ => panic!("expected TestDigest command"),
29595 }
29596 }
29597
29598 #[test]
29599 fn cli_parses_log_digest_command() {
29600 let cli = parse_cli([
29601 "tsift",
29602 "log-digest",
29603 "--path",
29604 ".",
29605 "--input",
29606 "target/build.log",
29607 "--json",
29608 ]);
29609 match cli.command {
29610 Some(Commands::LogDigest {
29611 json,
29612 path,
29613 input,
29614 fixture,
29615 fail_under,
29616 }) => {
29617 assert!(json);
29618 assert_eq!(path, PathBuf::from("."));
29619 assert_eq!(input, Some(PathBuf::from("target/build.log")));
29620 assert!(fixture.is_none());
29621 assert!(!fail_under);
29622 }
29623 _ => panic!("expected LogDigest command"),
29624 }
29625 }
29626
29627 #[test]
29628 fn cli_parses_metric_digest_command() {
29629 let cli = parse_cli([
29630 "tsift",
29631 "metric-digest",
29632 "--input",
29633 "target/runs.json",
29634 "--baseline",
29635 "target/prior.json",
29636 "--metric",
29637 "session_mae",
29638 "--lower-is-better",
29639 "session_mae",
29640 "--history",
29641 "4",
29642 "--top",
29643 "2",
29644 "--json",
29645 ]);
29646 match cli.command {
29647 Some(Commands::MetricDigest {
29648 input,
29649 baseline,
29650 metrics,
29651 lower_is_better,
29652 history,
29653 top,
29654 json,
29655 ..
29656 }) => {
29657 assert!(json);
29658 assert_eq!(input, Some(PathBuf::from("target/runs.json")));
29659 assert_eq!(baseline, Some(PathBuf::from("target/prior.json")));
29660 assert_eq!(metrics, vec!["session_mae"]);
29661 assert_eq!(lower_is_better, vec!["session_mae"]);
29662 assert_eq!(history, 4);
29663 assert_eq!(top, 2);
29664 }
29665 _ => panic!("expected MetricDigest command"),
29666 }
29667 }
29668
29669 #[test]
29670 fn cli_parses_dci_benchmark_command() {
29671 let cli = parse_cli([
29672 "tsift",
29673 "dci-benchmark",
29674 "--fixture",
29675 "fixtures/dci-search-benchmark.json",
29676 "--json",
29677 ]);
29678 match cli.command {
29679 Some(Commands::DciBenchmark { fixture, json }) => {
29680 assert!(json);
29681 assert_eq!(fixture, PathBuf::from("fixtures/dci-search-benchmark.json"));
29682 }
29683 _ => panic!("expected DciBenchmark command"),
29684 }
29685 }
29686
29687 #[test]
29688 fn cli_parses_session_digest_command() {
29689 let cli = parse_cli([
29690 "tsift",
29691 "session-digest",
29692 "--path",
29693 ".",
29694 "--input",
29695 "target/session.md",
29696 "--source",
29697 "markdown",
29698 "--json",
29699 ]);
29700 match cli.command {
29701 Some(Commands::SessionDigest {
29702 json,
29703 path,
29704 input,
29705 source,
29706 }) => {
29707 assert!(json);
29708 assert_eq!(path, PathBuf::from("."));
29709 assert_eq!(input, Some(PathBuf::from("target/session.md")));
29710 assert_eq!(source.as_deref(), Some("markdown"));
29711 }
29712 _ => panic!("expected SessionDigest command"),
29713 }
29714 }
29715
29716 #[test]
29717 fn cli_parses_session_cost_command() {
29718 let cli = parse_cli([
29719 "tsift",
29720 "session-cost",
29721 "--input",
29722 "target/session.jsonl",
29723 "--source",
29724 "codex-jsonl",
29725 "--json",
29726 ]);
29727 match cli.command {
29728 Some(Commands::SessionCost {
29729 json,
29730 input,
29731 fixture,
29732 fail_under,
29733 source,
29734 }) => {
29735 assert!(json);
29736 assert_eq!(input, Some(PathBuf::from("target/session.jsonl")));
29737 assert_eq!(fixture, None);
29738 assert!(!fail_under);
29739 assert_eq!(source.as_deref(), Some("codex-jsonl"));
29740 }
29741 _ => panic!("expected SessionCost command"),
29742 }
29743
29744 let cli = parse_cli([
29745 "tsift",
29746 "session-cost",
29747 "--fixture",
29748 "fixtures/real-session-prompt-cache-effectiveness.json",
29749 "--fail-under",
29750 "--json",
29751 ]);
29752 match cli.command {
29753 Some(Commands::SessionCost {
29754 json,
29755 input,
29756 fixture,
29757 fail_under,
29758 source,
29759 }) => {
29760 assert!(json);
29761 assert_eq!(input, None);
29762 assert_eq!(
29763 fixture,
29764 Some(PathBuf::from(
29765 "fixtures/real-session-prompt-cache-effectiveness.json"
29766 ))
29767 );
29768 assert!(fail_under);
29769 assert_eq!(source, None);
29770 }
29771 _ => panic!("expected SessionCost command"),
29772 }
29773 }
29774
29775 #[test]
29776 fn cli_parses_session_review_command() {
29777 let cli = parse_cli([
29778 "tsift",
29779 "session-review",
29780 "tasks/software/tsift.md",
29781 "--next-context",
29782 "--json",
29783 ]);
29784 match cli.command {
29785 Some(Commands::SessionReview {
29786 json,
29787 next_context,
29788 path,
29789 ..
29790 }) => {
29791 assert!(json);
29792 assert!(next_context);
29793 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29794 }
29795 _ => panic!("expected SessionReview command"),
29796 }
29797 }
29798
29799 #[test]
29800 fn cli_search_accepts_budget_flags() {
29801 let cli = parse_cli([
29802 "tsift",
29803 "search",
29804 "alpha_helper",
29805 "--max-items",
29806 "3",
29807 "--max-bytes",
29808 "96",
29809 ]);
29810 match cli.command {
29811 Some(Commands::Search {
29812 max_items,
29813 max_bytes,
29814 ..
29815 }) => {
29816 assert_eq!(max_items, Some(3));
29817 assert_eq!(max_bytes, Some(96));
29818 }
29819 _ => panic!("expected Search command"),
29820 }
29821 }
29822
29823 #[test]
29824 fn cli_search_accepts_budget_preset() {
29825 let cli = parse_cli(["tsift", "search", "alpha_helper", "--budget", "small"]);
29826 match cli.command {
29827 Some(Commands::Search { budget, .. }) => {
29828 assert_eq!(budget, Some(ResponseBudgetPreset::Small));
29829 }
29830 _ => panic!("expected Search command"),
29831 }
29832 }
29833
29834 #[test]
29835 fn cli_search_accepts_ast_facet_filters() {
29836 let cli = parse_cli([
29837 "tsift",
29838 "search",
29839 "setup",
29840 "--lang",
29841 "markdown",
29842 "--kind",
29843 "list_item",
29844 "--node-kind",
29845 "list_item",
29846 "--section",
29847 "Install",
29848 "--parent",
29849 "Run setup.",
29850 "--child",
29851 "Confirm setup.",
29852 "--fence-language",
29853 "rust",
29854 "--list-depth",
29855 "1",
29856 "--heading-level",
29857 "2",
29858 ]);
29859 match cli.command {
29860 Some(Commands::Search {
29861 lang,
29862 kind,
29863 node_kind,
29864 section,
29865 parent,
29866 child,
29867 fence_language,
29868 list_depth,
29869 heading_level,
29870 ..
29871 }) => {
29872 assert_eq!(lang, vec!["markdown"]);
29873 assert_eq!(kind, vec!["list_item"]);
29874 assert_eq!(node_kind, vec!["list_item"]);
29875 assert_eq!(section, vec!["Install"]);
29876 assert_eq!(parent, vec!["Run setup."]);
29877 assert_eq!(child, vec!["Confirm setup."]);
29878 assert_eq!(fence_language, vec!["rust"]);
29879 assert_eq!(list_depth, vec![1]);
29880 assert_eq!(heading_level, vec![2]);
29881 }
29882 _ => panic!("expected Search command"),
29883 }
29884 }
29885
29886 #[test]
29887 fn response_budget_presets_fill_defaults_and_preserve_explicit_caps() {
29888 let small = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), false);
29889 assert_eq!(small.preview_items(), 3);
29890 assert_eq!(small.preview_bytes(), 120);
29891 assert_eq!(small.follow_up_items(), 4);
29892
29893 let overridden =
29894 ResponseBudget::from_cli(Some(7), None, Some(ResponseBudgetPreset::Small), false);
29895 assert_eq!(overridden.preview_items(), 7);
29896 assert_eq!(overridden.preview_bytes(), 120);
29897 assert_eq!(overridden.follow_up_items(), 7);
29898
29899 let envelope_default = ResponseBudget::from_cli(None, None, None, true);
29900 assert!(envelope_default.is_active());
29901 }
29902
29903 #[test]
29904 fn cli_explain_accepts_budget_flags() {
29905 let cli = parse_cli([
29906 "tsift",
29907 "explain",
29908 "alpha_helper",
29909 "--max-items",
29910 "2",
29911 "--max-bytes",
29912 "80",
29913 ]);
29914 match cli.command {
29915 Some(Commands::Explain {
29916 max_items,
29917 max_bytes,
29918 ..
29919 }) => {
29920 assert_eq!(max_items, Some(2));
29921 assert_eq!(max_bytes, Some(80));
29922 }
29923 _ => panic!("expected Explain command"),
29924 }
29925 }
29926
29927 #[test]
29928 fn cli_session_review_accepts_budget_flags() {
29929 let cli = parse_cli([
29930 "tsift",
29931 "session-review",
29932 "tasks/software/tsift.md",
29933 "--max-items",
29934 "4",
29935 "--max-bytes",
29936 "120",
29937 ]);
29938 match cli.command {
29939 Some(Commands::SessionReview {
29940 max_items,
29941 max_bytes,
29942 ..
29943 }) => {
29944 assert_eq!(max_items, Some(4));
29945 assert_eq!(max_bytes, Some(120));
29946 }
29947 _ => panic!("expected SessionReview command"),
29948 }
29949 }
29950
29951 #[test]
29952 fn cli_parses_context_pack_command() {
29953 let cli = parse_cli([
29954 "tsift",
29955 "context-pack",
29956 "tasks/software/tsift.md",
29957 "--test-input",
29958 "target/test.log",
29959 "--runner",
29960 "cargo",
29961 "--log-input",
29962 "target/build.log",
29963 "--max-items",
29964 "3",
29965 "--max-bytes",
29966 "96",
29967 "--json",
29968 ]);
29969 match cli.command {
29970 Some(Commands::ContextPack {
29971 path,
29972 test_input,
29973 runner,
29974 log_input,
29975 json,
29976 max_items,
29977 max_bytes,
29978 budget,
29979 convex_snapshot,
29980 }) => {
29981 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
29982 assert_eq!(test_input, Some(PathBuf::from("target/test.log")));
29983 assert_eq!(runner.as_deref(), Some("cargo"));
29984 assert_eq!(log_input, Some(PathBuf::from("target/build.log")));
29985 assert!(json);
29986 assert_eq!(max_items, Some(3));
29987 assert_eq!(max_bytes, Some(96));
29988 assert!(budget.is_none());
29989 assert!(convex_snapshot.is_none());
29990 }
29991 _ => panic!("expected ContextPack command"),
29992 }
29993 }
29994
29995 #[test]
29996 fn cli_parses_token_savings_command() {
29997 let cli = parse_cli([
29998 "tsift",
29999 "token-savings",
30000 "--fixture",
30001 "fixtures/tsift-token-savings.json",
30002 "--fail-under",
30003 "--json",
30004 ]);
30005 match cli.command {
30006 Some(Commands::TokenSavings {
30007 fixture,
30008 fail_under,
30009 json,
30010 }) => {
30011 assert_eq!(fixture, PathBuf::from("fixtures/tsift-token-savings.json"));
30012 assert!(fail_under);
30013 assert!(json);
30014 }
30015 _ => panic!("expected TokenSavings command"),
30016 }
30017 }
30018
30019 #[test]
30020 fn token_savings_report_records_fixture_thresholds() {
30021 let raw_symbols = [
30022 "validate_user",
30023 "validateUser",
30024 "ValidateUser",
30025 "validate-user",
30026 "VALIDATE_USER",
30027 "Validate_User",
30028 "raw_symbol",
30029 "rawSymbol",
30030 "RawSymbol",
30031 "raw-symbol",
30032 "RAW_SYMBOL",
30033 "Raw_Symbol",
30034 ]
30035 .iter()
30036 .enumerate()
30037 .map(|(idx, identifier)| TokenSavingsRawSymbol {
30038 identifier: (*identifier).to_string(),
30039 file: format!("src/example_{idx}.rs"),
30040 line: (idx + 1) as u64,
30041 context: "function".to_string(),
30042 })
30043 .collect();
30044 let fixture = TokenSavingsFixture {
30045 schema_version: 1,
30046 description: "fixture".to_string(),
30047 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30048 cases: vec![TokenSavingsFixtureCase {
30049 name: "search-preview".to_string(),
30050 surface: "search".to_string(),
30051 minimum_savings_percent: 40.0,
30052 raw_symbols,
30053 tagpath_families: vec![
30054 TokenSavingsFamily {
30055 canonical: "validate_user".to_string(),
30056 count: 6,
30057 aliases: BTreeMap::new(),
30058 },
30059 TokenSavingsFamily {
30060 canonical: "raw_symbol".to_string(),
30061 count: 6,
30062 aliases: BTreeMap::new(),
30063 },
30064 ],
30065 context_pack_inputs: None,
30066 session_review_inputs: None,
30067 source_read_inputs: None,
30068 markdown_projection_inputs: None,
30069 }],
30070 };
30071
30072 let report = build_token_savings_report(&fixture).unwrap();
30073
30074 assert!(report.pass);
30075 assert_eq!(report.cases[0].raw_symbol_count, 12);
30076 assert_eq!(report.cases[0].family_count, 2);
30077 assert_eq!(report.cases[0].status, "pass");
30078 assert!(report.cases[0].byte_delta > 0);
30079 assert!(report.cases[0].raw_estimated_tokens > report.cases[0].envelope_estimated_tokens);
30080 assert!(report.cases[0].savings_percent >= 40.0);
30081 }
30082
30083 #[test]
30084 fn token_savings_source_read_inputs_preserve_required_anchors() {
30085 let fixture = TokenSavingsFixture {
30086 schema_version: 1,
30087 description: "fixture".to_string(),
30088 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30089 cases: vec![TokenSavingsFixtureCase {
30090 name: "source-read".to_string(),
30091 surface: "source-read".to_string(),
30092 minimum_savings_percent: 40.0,
30093 raw_symbols: Vec::new(),
30094 tagpath_families: Vec::new(),
30095 context_pack_inputs: None,
30096 session_review_inputs: None,
30097 source_read_inputs: Some(TokenSavingsSourceReadInputs {
30098 reads: vec![TokenSavingsSourceReadInput {
30099 command: "sed -n '40,160p' src/main.rs".to_string(),
30100 file: "src/main.rs".to_string(),
30101 raw_start: 40,
30102 raw_lines: 121,
30103 raw_excerpt: "line 40\n".repeat(121),
30104 envelope_start: 40,
30105 envelope_lines: 121,
30106 required_line_anchors: vec![40, 120, 160],
30107 }],
30108 }),
30109 markdown_projection_inputs: None,
30110 }],
30111 };
30112
30113 let report = build_token_savings_report(&fixture).unwrap();
30114
30115 assert!(report.pass);
30116 assert_eq!(report.cases[0].surface, "source-read");
30117 assert!(report.cases[0].savings_percent >= 40.0);
30118 }
30119
30120 #[test]
30121 fn token_savings_source_read_inputs_fail_when_anchor_is_hidden() {
30122 let fixture = TokenSavingsFixture {
30123 schema_version: 1,
30124 description: "fixture".to_string(),
30125 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30126 cases: vec![TokenSavingsFixtureCase {
30127 name: "source-read".to_string(),
30128 surface: "source-read".to_string(),
30129 minimum_savings_percent: 40.0,
30130 raw_symbols: Vec::new(),
30131 tagpath_families: Vec::new(),
30132 context_pack_inputs: None,
30133 session_review_inputs: None,
30134 source_read_inputs: Some(TokenSavingsSourceReadInputs {
30135 reads: vec![TokenSavingsSourceReadInput {
30136 command: "cat src/main.rs".to_string(),
30137 file: "src/main.rs".to_string(),
30138 raw_start: 1,
30139 raw_lines: 200,
30140 raw_excerpt: "line\n".repeat(200),
30141 envelope_start: 1,
30142 envelope_lines: 80,
30143 required_line_anchors: vec![120],
30144 }],
30145 }),
30146 markdown_projection_inputs: None,
30147 }],
30148 };
30149
30150 let err = match build_token_savings_report(&fixture) {
30151 Ok(_) => panic!("hidden anchor should fail the source-read fixture"),
30152 Err(err) => err,
30153 };
30154
30155 assert!(err.to_string().contains("hides required line anchor 120"));
30156 }
30157
30158 #[test]
30159 fn token_savings_markdown_projection_inputs_require_outline_and_selected_nodes() {
30160 let fixture = TokenSavingsFixture {
30161 schema_version: 1,
30162 description: "fixture".to_string(),
30163 token_estimate: "ceil(utf8_bytes / 4)".to_string(),
30164 cases: vec![TokenSavingsFixtureCase {
30165 name: "markdown-projection".to_string(),
30166 surface: "context-pack".to_string(),
30167 minimum_savings_percent: 40.0,
30168 raw_symbols: Vec::new(),
30169 tagpath_families: Vec::new(),
30170 context_pack_inputs: None,
30171 session_review_inputs: None,
30172 source_read_inputs: None,
30173 markdown_projection_inputs: Some(TokenSavingsMarkdownProjectionInputs {
30174 documents: vec![TokenSavingsMarkdownProjectionInput {
30175 command: "context-pack markdown body".to_string(),
30176 file: "tasks/software/tsift.md".to_string(),
30177 raw_markdown: "# Heading\n\n".repeat(120),
30178 outline_nodes: vec!["Heading".to_string(), "Details".to_string()],
30179 selected_nodes: vec!["mdast-selected".to_string()],
30180 expand:
30181 "tsift --envelope markdown-ast tasks/software/tsift.md --node mdast-selected --budget normal"
30182 .to_string(),
30183 }],
30184 }),
30185 }],
30186 };
30187
30188 let report = build_token_savings_report(&fixture).unwrap();
30189
30190 assert!(report.pass);
30191 assert_eq!(report.cases[0].surface, "context-pack");
30192 assert!(report.cases[0].savings_percent >= 40.0);
30193 }
30194
30195 #[test]
30196 fn markdown_ast_projection_cache_reuses_large_document_section_and_block_lookups() {
30197 let mut content = String::from("# Cache Root\n\n");
30198 for idx in 0..96 {
30199 content.push_str(&format!(
30200 "## Section {idx}\n\n- Item {idx}\n\n```rust\nfn sample_{idx}() {{}}\n```\n\n"
30201 ));
30202 }
30203
30204 let first = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30205 assert!(!first.cache_hit);
30206 assert!(first.nodes.len() > 200);
30207
30208 let sections = markdown_section_spans(&content).unwrap();
30209 let list_items = markdown_block_spans(&content, "list_item").unwrap();
30210 let code_blocks = markdown_block_spans(&content, "code_block").unwrap();
30211 let second = markdown_ast_projection("semantic-edit", content.as_bytes()).unwrap();
30212
30213 assert!(second.cache_hit);
30214 assert_eq!(second.nodes.len(), first.nodes.len());
30215 assert_eq!(sections.len(), 97);
30216 assert_eq!(list_items.len(), 96);
30217 assert_eq!(code_blocks.len(), 96);
30218 let first_code = first
30219 .nodes
30220 .iter()
30221 .find(|node| node.kind == "code_block")
30222 .expect("expected a Markdown code block");
30223 let first_code_node = markdown_ast_node(
30224 Path::new("/repo"),
30225 "semantic-edit",
30226 first_code,
30227 content.as_bytes(),
30228 &first.nodes,
30229 8,
30230 );
30231 assert_eq!(first_code_node.metadata.embedded_symbols.len(), 1);
30232 assert_eq!(
30233 first_code_node.metadata.embedded_symbols[0].name,
30234 "sample_0"
30235 );
30236 assert_eq!(
30237 first_code_node.metadata.embedded_symbols[0].language,
30238 "rust"
30239 );
30240 }
30241
30242 #[test]
30243 fn search_budget_report_truncates_symbol_preview_and_emits_stable_handle() {
30244 let response = empty_search_response(Path::new("/repo"), "lexical");
30245 let symbol_hits = vec![index::SymbolHit {
30246 name: "alpha_helper_with_a_long_name".to_string(),
30247 kind: "function".to_string(),
30248 language: "rust".to_string(),
30249 file: "/repo/src/lib.rs".to_string(),
30250 line: 12,
30251 end_line: None,
30252 node_kind: None,
30253 start_byte: None,
30254 end_byte: None,
30255 body_start_byte: None,
30256 body_end_byte: None,
30257 tags: None,
30258 score: 0.98,
30259 match_type: "exact_name".to_string(),
30260 tagpath_handle: None,
30261 }];
30262
30263 let report = build_relative_search_budget_report(
30264 "alpha_helper_with_a_long_name",
30265 "lexical",
30266 Path::new("/repo"),
30267 &response,
30268 &symbol_hits,
30269 ResponseBudget::new(Some(1), Some(12)),
30270 &SearchFacetFilters::default(),
30271 );
30272
30273 assert_eq!(report.symbols.len(), 1);
30274 assert!(report.symbols[0].handle.starts_with("sfam-"));
30275 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/hel..."));
30276 assert_eq!(report.symbols[0].name, "alpha_hel...");
30277 assert_eq!(report.symbols[0].file, "src/lib.rs");
30278 assert!(report.symbols[0].expand.contains("tsift search"));
30279 }
30280
30281 #[test]
30282 fn search_budget_report_promotes_ast_span_artifacts_for_symbols() {
30283 let dir = tempfile::tempdir().unwrap();
30284 let src_dir = dir.path().join("src");
30285 fs::create_dir_all(&src_dir).unwrap();
30286 let source = "fn alpha_helper() {\n beta();\n}\n";
30287 let file = src_dir.join("lib.rs");
30288 fs::write(&file, source).unwrap();
30289 let body_start = source.find("{\n").unwrap() + 1;
30290 let body_end = source.rfind("\n}").unwrap() + 1;
30291
30292 let response = empty_search_response(dir.path(), "lexical");
30293 let symbol_hits = vec![index::SymbolHit {
30294 name: "alpha_helper".to_string(),
30295 kind: "function".to_string(),
30296 language: "rust".to_string(),
30297 file: file.to_string_lossy().to_string(),
30298 line: 0,
30299 end_line: Some(2),
30300 node_kind: Some("function_item".to_string()),
30301 start_byte: Some(0),
30302 end_byte: Some(i64::try_from(source.len()).unwrap()),
30303 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30304 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30305 tags: Some("alpha,helper".to_string()),
30306 score: 0.98,
30307 match_type: "exact_name".to_string(),
30308 tagpath_handle: None,
30309 }];
30310
30311 let report = build_relative_search_budget_report(
30312 "alpha helper",
30313 "lexical",
30314 dir.path(),
30315 &response,
30316 &symbol_hits,
30317 ResponseBudget::new(Some(5), Some(96)),
30318 &SearchFacetFilters::default(),
30319 );
30320
30321 let symbol = &report.symbols[0];
30322 assert_eq!(symbol.language, "rust");
30323 assert_eq!(symbol.end_line, Some(2));
30324 let ast = symbol
30325 .ast
30326 .as_ref()
30327 .expect("search symbol preview should expose an AST span artifact");
30328 assert_eq!(ast.artifact_kind, "ast_span");
30329 assert!(ast.span.handle.starts_with("span-"));
30330 assert_eq!(ast.span.node_kind, "function_item");
30331 assert_eq!(ast.span.start_byte, 0);
30332 assert_eq!(ast.span.end_byte, source.len());
30333 assert_eq!(ast.span.body_start_byte, Some(body_start));
30334 assert_eq!(ast.span.body_end_byte, Some(body_end));
30335 assert!(ast.expand.source_window.contains("source-read"));
30336 assert!(
30337 ast.expand
30338 .source_body
30339 .as_ref()
30340 .unwrap()
30341 .contains("source-read")
30342 );
30343 assert!(ast.expand.symbol_read.contains("symbol-read"));
30344 assert!(ast.expand.markdown_ast.is_none());
30345 }
30346
30347 #[test]
30348 fn search_budget_report_links_markdown_spans_to_markdown_ast_expansion() {
30349 let dir = tempfile::tempdir().unwrap();
30350 let source = "# Guide\n\n## Install\n\n- Run setup.\n";
30351 let file = dir.path().join("README.md");
30352 fs::write(&file, source).unwrap();
30353 let heading_start = source.find("## Install").unwrap();
30354 let heading_end = source.len();
30355
30356 let response = empty_search_response(dir.path(), "lexical");
30357 let symbol_hits = vec![index::SymbolHit {
30358 name: "Install".to_string(),
30359 kind: "heading".to_string(),
30360 language: "markdown".to_string(),
30361 file: file.to_string_lossy().to_string(),
30362 line: 2,
30363 end_line: Some(4),
30364 node_kind: Some("atx_heading".to_string()),
30365 start_byte: Some(i64::try_from(heading_start).unwrap()),
30366 end_byte: Some(i64::try_from(heading_end).unwrap()),
30367 body_start_byte: Some(i64::try_from(source.find("- Run setup.").unwrap()).unwrap()),
30368 body_end_byte: Some(i64::try_from(heading_end).unwrap()),
30369 tags: Some("install".to_string()),
30370 score: 1.0,
30371 match_type: "exact_name".to_string(),
30372 tagpath_handle: None,
30373 }];
30374
30375 let report = build_relative_search_budget_report(
30376 "Install",
30377 "lexical",
30378 dir.path(),
30379 &response,
30380 &symbol_hits,
30381 ResponseBudget::new(Some(5), Some(96)),
30382 &SearchFacetFilters::default(),
30383 );
30384
30385 let ast = report.symbols[0]
30386 .ast
30387 .as_ref()
30388 .expect("Markdown search symbol should expose an AST span artifact");
30389 assert_eq!(ast.span.node_kind, "atx_heading");
30390 assert_eq!(ast.span.markdown.as_ref().unwrap().heading_level, Some(2));
30391 let markdown_ast = ast
30392 .expand
30393 .markdown_ast
30394 .as_ref()
30395 .expect("Markdown symbols should include markdown-ast expansion");
30396 assert!(markdown_ast.contains("markdown-ast"), "{markdown_ast}");
30397 assert!(markdown_ast.contains("--node"), "{markdown_ast}");
30398 assert!(markdown_ast.contains(&ast.span.handle), "{markdown_ast}");
30399 assert!(ast.expand.source_window.contains("source-read"));
30400 assert!(ast.expand.symbol_read.contains("symbol-read"));
30401 }
30402
30403 #[test]
30404 fn search_budget_report_exposes_markdown_embedded_code_symbols() {
30405 let dir = tempfile::tempdir().unwrap();
30406 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30407 let file = dir.path().join("README.md");
30408 fs::write(&file, source).unwrap();
30409 let fence_start = source.find("```rust").unwrap();
30410 let body_start = source.find("fn sample").unwrap();
30411 let body_end = body_start + "fn sample() {}\n".len();
30412
30413 let response = empty_search_response(dir.path(), "lexical");
30414 let symbol_hits = vec![index::SymbolHit {
30415 name: "rust".to_string(),
30416 kind: "code_block".to_string(),
30417 language: "markdown".to_string(),
30418 file: file.to_string_lossy().to_string(),
30419 line: 2,
30420 end_line: Some(4),
30421 node_kind: Some("fenced_code_block".to_string()),
30422 start_byte: Some(i64::try_from(fence_start).unwrap()),
30423 end_byte: Some(i64::try_from(source.len()).unwrap()),
30424 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30425 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30426 tags: Some("rust".to_string()),
30427 score: 1.0,
30428 match_type: "exact_name".to_string(),
30429 tagpath_handle: None,
30430 }];
30431
30432 let report = build_relative_search_budget_report(
30433 "rust",
30434 "lexical",
30435 dir.path(),
30436 &response,
30437 &symbol_hits,
30438 ResponseBudget::new(Some(5), Some(96)),
30439 &SearchFacetFilters::default(),
30440 );
30441
30442 let embedded = &report.symbols[0]
30443 .ast
30444 .as_ref()
30445 .unwrap()
30446 .span
30447 .markdown
30448 .as_ref()
30449 .unwrap()
30450 .embedded_symbols;
30451 assert_eq!(embedded.len(), 1);
30452 assert_eq!(embedded[0].name, "sample");
30453 assert_eq!(embedded[0].kind, "function");
30454 assert_eq!(embedded[0].language, "rust");
30455 assert_eq!(embedded[0].node_kind, "function_item");
30456 assert!(embedded[0].handle.starts_with("span-"));
30457 assert_eq!(embedded[0].start_byte, body_start);
30458 assert_eq!(embedded[0].start_line, 4);
30459 }
30460
30461 fn test_lexical_search_hit(
30462 path: &Path,
30463 rank: usize,
30464 score: f64,
30465 snippet: &str,
30466 ) -> sift::SearchHit {
30467 sift::SearchHit {
30468 artifact_id: format!("hit-{rank}"),
30469 artifact_kind: sift::ContextArtifactKind::File,
30470 budget: sift::ArtifactBudget::from_text(snippet, 1),
30471 confidence: sift::ScoreConfidence::High,
30472 freshness: sift::ArtifactFreshness {
30473 modified_unix_secs: None,
30474 observed_unix_secs: 0,
30475 },
30476 location: Some("line 1".to_string()),
30477 path: path.to_string_lossy().to_string(),
30478 provenance: sift::ArtifactProvenance {
30479 adapter: sift::AcquisitionAdapterKind::FileSystem,
30480 source: "test lexical hit".to_string(),
30481 synthetic: false,
30482 },
30483 rank,
30484 score,
30485 snippet: snippet.to_string(),
30486 }
30487 }
30488
30489 fn test_summary(symbol_name: &str, file_path: &str, summary: &str) -> summarize::Summary {
30490 summarize::Summary {
30491 id: 0,
30492 symbol_name: symbol_name.to_string(),
30493 file_path: file_path.to_string(),
30494 content_hash: "hash".to_string(),
30495 summary: summary.to_string(),
30496 entities: None,
30497 relationships: None,
30498 concept_labels: None,
30499 extracted_at: "2026-06-02T00:00:00Z".to_string(),
30500 model: "test".to_string(),
30501 tokens_input: None,
30502 tokens_output: None,
30503 }
30504 }
30505
30506 #[test]
30507 fn search_budget_ranked_preview_prioritizes_precise_ast_span_over_broad_file_hit() {
30508 let dir = tempfile::tempdir().unwrap();
30509 let src_dir = dir.path().join("src");
30510 fs::create_dir_all(&src_dir).unwrap();
30511 let source = "fn alpha_helper() {}\n";
30512 let file = src_dir.join("lib.rs");
30513 let broad_file = dir.path().join("README.md");
30514 fs::write(&file, source).unwrap();
30515 fs::write(
30516 &broad_file,
30517 "alpha helper alpha helper alpha helper in prose\n",
30518 )
30519 .unwrap();
30520
30521 let mut response = empty_search_response(dir.path(), "lexical");
30522 response.hits.push(test_lexical_search_hit(
30523 &broad_file,
30524 1,
30525 240.0,
30526 "alpha helper alpha helper alpha helper in prose",
30527 ));
30528 let symbol_hits = vec![index::SymbolHit {
30529 name: "alpha_helper".to_string(),
30530 kind: "function".to_string(),
30531 language: "rust".to_string(),
30532 file: file.to_string_lossy().to_string(),
30533 line: 0,
30534 end_line: Some(0),
30535 node_kind: Some("function_item".to_string()),
30536 start_byte: Some(0),
30537 end_byte: Some(i64::try_from(source.len()).unwrap()),
30538 body_start_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30539 body_end_byte: Some(i64::try_from(source.find("{}").unwrap() + 1).unwrap()),
30540 tags: Some("alpha,helper".to_string()),
30541 score: 0.8,
30542 match_type: "all_tags".to_string(),
30543 tagpath_handle: None,
30544 }];
30545
30546 let report = build_relative_search_budget_report(
30547 "alpha helper",
30548 "lexical",
30549 dir.path(),
30550 &response,
30551 &symbol_hits,
30552 ResponseBudget::new(Some(5), Some(128)),
30553 &SearchFacetFilters::default(),
30554 );
30555
30556 assert_eq!(report.ranked[0].source, "symbol_span");
30557 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30558 assert!(report.ranked[0].score > report.ranked[1].score);
30559 assert_eq!(report.ranked[1].source, "lexical_file");
30560 }
30561
30562 #[test]
30563 fn search_budget_exact_hit_expands_to_source_handle_and_containing_symbol() {
30564 let dir = tempfile::tempdir().unwrap();
30565 let src_dir = dir.path().join("src");
30566 fs::create_dir_all(&src_dir).unwrap();
30567 let source = "fn alpha_helper() {\n let needle = \"needle\";\n}\n\nfn other() {}\n";
30568 let file = src_dir.join("lib.rs");
30569 fs::write(&file, source).unwrap();
30570
30571 let mut response = empty_search_response(dir.path(), "exact");
30572 let mut hit = test_lexical_search_hit(&file, 1, 10.0, "let needle = \"needle\";");
30573 hit.location = Some("line 2".to_string());
30574 response.hits.push(hit);
30575
30576 let symbol_hits = vec![index::SymbolHit {
30577 name: "alpha_helper".to_string(),
30578 kind: "function".to_string(),
30579 language: "rust".to_string(),
30580 file: file.to_string_lossy().to_string(),
30581 line: 0,
30582 end_line: Some(2),
30583 node_kind: Some("function_item".to_string()),
30584 start_byte: Some(0),
30585 end_byte: Some(i64::try_from(source.find("\n\n").unwrap()).unwrap()),
30586 body_start_byte: Some(i64::try_from(source.find('{').unwrap() + 1).unwrap()),
30587 body_end_byte: Some(i64::try_from(source.find("\n}").unwrap()).unwrap()),
30588 tags: Some("alpha,helper".to_string()),
30589 score: 0.9,
30590 match_type: "all_tags".to_string(),
30591 tagpath_handle: None,
30592 }];
30593
30594 let report = build_relative_search_budget_report(
30595 "needle",
30596 "exact",
30597 dir.path(),
30598 &response,
30599 &symbol_hits,
30600 ResponseBudget::new(Some(5), Some(128)),
30601 &SearchFacetFilters::default(),
30602 );
30603
30604 let hit = &report.hits[0];
30605 assert_eq!(hit.line, Some(2));
30606 let source_handle = hit
30607 .source_handle
30608 .as_ref()
30609 .expect("exact hit should expose a bounded source_handle window");
30610 assert!(source_handle.handle.starts_with("xwin-"));
30611 assert_eq!(source_handle.kind, "source_handle");
30612 assert_eq!(source_handle.file, "src/lib.rs");
30613 assert_eq!(source_handle.start_line, 1);
30614 assert_eq!(source_handle.end_line, 3);
30615 assert!(source_handle.expand.contains("source-read"));
30616
30617 let containing_symbol = hit
30618 .containing_symbol
30619 .as_ref()
30620 .expect("exact hit should expose its containing symbol when indexed");
30621 assert_eq!(containing_symbol.name, "alpha_helper");
30622 assert_eq!(containing_symbol.kind, "function");
30623 assert_eq!(containing_symbol.line, 1);
30624 assert_eq!(containing_symbol.end_line, Some(3));
30625 assert!(containing_symbol.expand.contains("symbol-read"));
30626
30627 let lexical_rank = report
30628 .ranked
30629 .iter()
30630 .find(|item| item.source == "lexical_file")
30631 .expect("ranked preview should retain the lexical retrieval handle");
30632 assert!(
30633 lexical_rank
30634 .reasons
30635 .iter()
30636 .any(|reason| reason == "source_handle")
30637 );
30638 assert!(
30639 lexical_rank
30640 .reasons
30641 .iter()
30642 .any(|reason| reason == "containing_symbol")
30643 );
30644 }
30645
30646 #[test]
30647 fn search_budget_ranked_preview_prioritizes_source_definitions_before_tests() {
30648 let dir = tempfile::tempdir().unwrap();
30649 let src_dir = dir.path().join("src");
30650 let tests_dir = dir.path().join("tests");
30651 fs::create_dir_all(&src_dir).unwrap();
30652 fs::create_dir_all(&tests_dir).unwrap();
30653 let source_file = src_dir.join("lib.rs");
30654 let test_file = tests_dir.join("alpha_test.rs");
30655 fs::write(&source_file, "fn alpha_helper() {}\n").unwrap();
30656 fs::write(&test_file, "#[test]\nfn alpha_helper_test() {}\n").unwrap();
30657
30658 let response = empty_search_response(dir.path(), "lexical");
30659 let symbol_hits = vec![
30660 index::SymbolHit {
30661 name: "alpha_helper_test".to_string(),
30662 kind: "function".to_string(),
30663 language: "rust".to_string(),
30664 file: test_file.to_string_lossy().to_string(),
30665 line: 1,
30666 end_line: Some(1),
30667 node_kind: Some("function_item".to_string()),
30668 start_byte: Some(8),
30669 end_byte: Some(33),
30670 body_start_byte: Some(31),
30671 body_end_byte: Some(31),
30672 tags: Some("alpha,helper,test".to_string()),
30673 score: 1.0,
30674 match_type: "exact_name".to_string(),
30675 tagpath_handle: None,
30676 },
30677 index::SymbolHit {
30678 name: "alpha_helper".to_string(),
30679 kind: "function".to_string(),
30680 language: "rust".to_string(),
30681 file: source_file.to_string_lossy().to_string(),
30682 line: 0,
30683 end_line: Some(0),
30684 node_kind: Some("function_item".to_string()),
30685 start_byte: Some(0),
30686 end_byte: Some(20),
30687 body_start_byte: Some(18),
30688 body_end_byte: Some(18),
30689 tags: Some("alpha,helper".to_string()),
30690 score: 0.78,
30691 match_type: "all_tags".to_string(),
30692 tagpath_handle: None,
30693 },
30694 ];
30695
30696 let report = build_relative_search_budget_report(
30697 "alpha helper",
30698 "lexical",
30699 dir.path(),
30700 &response,
30701 &symbol_hits,
30702 ResponseBudget::new(Some(5), Some(128)),
30703 &SearchFacetFilters::default(),
30704 );
30705
30706 assert_eq!(report.ranked[0].name.as_deref(), Some("alpha_helper"));
30707 assert_eq!(report.ranked[0].path, "src/lib.rs");
30708 assert!(
30709 report.ranked[0]
30710 .reasons
30711 .iter()
30712 .any(|reason| reason == "definition_kind")
30713 );
30714 assert!(
30715 report.ranked[0]
30716 .reasons
30717 .iter()
30718 .any(|reason| reason == "source_path")
30719 );
30720 let test_rank = report
30721 .ranked
30722 .iter()
30723 .find(|item| item.name.as_deref() == Some("alpha_helper_test"))
30724 .expect("test symbol should still be present in the ranked preview");
30725 assert!(test_rank.reasons.iter().any(|reason| reason == "test_path"));
30726 }
30727
30728 #[test]
30729 fn search_budget_ranked_preview_includes_summary_and_graph_evidence() {
30730 let dir = tempfile::tempdir().unwrap();
30731 let source = "# Guide\n\n```rust\nfn sample() {}\n```\n";
30732 let file = dir.path().join("README.md");
30733 fs::write(&file, source).unwrap();
30734 let summary_db =
30735 summarize::SummaryDb::open(&dir.path().join(".tsift/summaries.db")).unwrap();
30736 summary_db
30737 .insert(&test_summary(
30738 "rust",
30739 "README.md",
30740 "Rust fence contains a sample function.",
30741 ))
30742 .unwrap();
30743
30744 let fence_start = source.find("```rust").unwrap();
30745 let body_start = source.find("fn sample").unwrap();
30746 let body_end = body_start + "fn sample() {}\n".len();
30747 let response = empty_search_response(dir.path(), "lexical");
30748 let symbol_hits = vec![index::SymbolHit {
30749 name: "rust".to_string(),
30750 kind: "code_block".to_string(),
30751 language: "markdown".to_string(),
30752 file: file.to_string_lossy().to_string(),
30753 line: 2,
30754 end_line: Some(4),
30755 node_kind: Some("fenced_code_block".to_string()),
30756 start_byte: Some(i64::try_from(fence_start).unwrap()),
30757 end_byte: Some(i64::try_from(source.len()).unwrap()),
30758 body_start_byte: Some(i64::try_from(body_start).unwrap()),
30759 body_end_byte: Some(i64::try_from(body_end).unwrap()),
30760 tags: Some("rust".to_string()),
30761 score: 1.0,
30762 match_type: "exact_name".to_string(),
30763 tagpath_handle: None,
30764 }];
30765
30766 let report = build_relative_search_budget_report(
30767 "rust",
30768 "lexical",
30769 dir.path(),
30770 &response,
30771 &symbol_hits,
30772 ResponseBudget::new(Some(5), Some(128)),
30773 &SearchFacetFilters::default(),
30774 );
30775
30776 let symbol = &report.symbols[0];
30777 assert_eq!(symbol.summary_refs, 1);
30778 assert_eq!(symbol.graph_neighbors, 1);
30779 assert!(
30780 report.ranked[0]
30781 .reasons
30782 .iter()
30783 .any(|reason| reason == "summary_refs:1")
30784 );
30785 assert!(
30786 report.ranked[0]
30787 .reasons
30788 .iter()
30789 .any(|reason| reason == "graph_neighbors:1")
30790 );
30791 }
30792
30793 fn markdown_search_facet_fixture() -> tempfile::TempDir {
30794 let dir = tempfile::tempdir().unwrap();
30795 let source = r#"# Guide
30796
30797## Install
30798
30799- Run setup.
30800 - Confirm setup.
30801
30802```rust
30803fn sample() {}
30804```
30805"#;
30806 fs::write(dir.path().join("README.md"), source).unwrap();
30807 let index_dir = dir.path().join(".tsift");
30808 fs::create_dir_all(&index_dir).unwrap();
30809 run_index_update(
30810 &index_dir.join("index.db"),
30811 dir.path(),
30812 "indexing markdown search facet fixture".to_string(),
30813 dir.path(),
30814 None,
30815 false,
30816 false,
30817 )
30818 .unwrap();
30819 dir
30820 }
30821
30822 fn markdown_search_facet_hits(root: &Path, query: &str) -> Vec<index::SymbolHit> {
30823 let db = index::IndexDb::open_read_only_resilient(&root.join(".tsift/index.db")).unwrap();
30824 db.symbol_search(query, 20).unwrap()
30825 }
30826
30827 #[test]
30828 fn search_facet_filters_match_scalar_symbol_fields() {
30829 let dir = tempfile::tempdir().unwrap();
30830 let hits = vec![
30831 index::SymbolHit {
30832 name: "alpha_helper".to_string(),
30833 kind: "function".to_string(),
30834 language: "rust".to_string(),
30835 file: dir.path().join("src/lib.rs").to_string_lossy().to_string(),
30836 line: 0,
30837 end_line: None,
30838 node_kind: Some("function_item".to_string()),
30839 start_byte: None,
30840 end_byte: None,
30841 body_start_byte: None,
30842 body_end_byte: None,
30843 tags: None,
30844 score: 1.0,
30845 match_type: "exact_name".to_string(),
30846 tagpath_handle: None,
30847 },
30848 index::SymbolHit {
30849 name: "Install".to_string(),
30850 kind: "heading".to_string(),
30851 language: "markdown".to_string(),
30852 file: dir.path().join("README.md").to_string_lossy().to_string(),
30853 line: 0,
30854 end_line: None,
30855 node_kind: Some("atx_heading".to_string()),
30856 start_byte: None,
30857 end_byte: None,
30858 body_start_byte: None,
30859 body_end_byte: None,
30860 tags: None,
30861 score: 0.9,
30862 match_type: "exact_name".to_string(),
30863 tagpath_handle: None,
30864 },
30865 ];
30866
30867 let filtered = apply_search_facet_filters(
30868 dir.path(),
30869 hits,
30870 &SearchFacetFilters {
30871 languages: vec!["rust".to_string()],
30872 kinds: vec!["function".to_string()],
30873 node_kinds: vec!["function_item".to_string()],
30874 ..SearchFacetFilters::default()
30875 },
30876 );
30877
30878 assert_eq!(filtered.len(), 1);
30879 assert_eq!(filtered[0].name, "alpha_helper");
30880 }
30881
30882 #[test]
30883 fn search_facet_filters_match_markdown_sections_and_block_metadata() {
30884 let dir = markdown_search_facet_fixture();
30885
30886 let nested_list = apply_search_facet_filters(
30887 dir.path(),
30888 markdown_search_facet_hits(dir.path(), "setup"),
30889 &SearchFacetFilters {
30890 sections: vec!["Install".to_string()],
30891 parents: vec!["Run setup.".to_string()],
30892 list_depths: vec![1],
30893 ..SearchFacetFilters::default()
30894 },
30895 );
30896 assert_eq!(nested_list.len(), 1);
30897 assert_eq!(nested_list[0].name, "Confirm setup.");
30898
30899 let parent_list = apply_search_facet_filters(
30900 dir.path(),
30901 markdown_search_facet_hits(dir.path(), "setup"),
30902 &SearchFacetFilters {
30903 children: vec!["Confirm setup.".to_string()],
30904 ..SearchFacetFilters::default()
30905 },
30906 );
30907 assert_eq!(parent_list.len(), 1);
30908 assert_eq!(parent_list[0].name, "Run setup.");
30909
30910 let heading = apply_search_facet_filters(
30911 dir.path(),
30912 markdown_search_facet_hits(dir.path(), "Install"),
30913 &SearchFacetFilters {
30914 heading_levels: vec![2],
30915 node_kinds: vec!["atx_heading".to_string()],
30916 ..SearchFacetFilters::default()
30917 },
30918 );
30919 assert_eq!(heading.len(), 1);
30920 assert_eq!(heading[0].name, "Install");
30921
30922 let fence = apply_search_facet_filters(
30923 dir.path(),
30924 markdown_search_facet_hits(dir.path(), "rust"),
30925 &SearchFacetFilters {
30926 fence_languages: vec!["rust".to_string()],
30927 kinds: vec!["code_block".to_string()],
30928 ..SearchFacetFilters::default()
30929 },
30930 );
30931 assert_eq!(fence.len(), 1);
30932 assert_eq!(fence[0].kind, "code_block");
30933
30934 let embedded_child = apply_search_facet_filters(
30935 dir.path(),
30936 markdown_search_facet_hits(dir.path(), "rust"),
30937 &SearchFacetFilters {
30938 children: vec!["sample".to_string()],
30939 kinds: vec!["code_block".to_string()],
30940 ..SearchFacetFilters::default()
30941 },
30942 );
30943 assert_eq!(embedded_child.len(), 1);
30944 assert_eq!(embedded_child[0].name, "rust");
30945 }
30946
30947 #[test]
30948 fn search_budget_report_groups_repeated_symbols_by_canonical_tag_family() {
30949 let response = empty_search_response(Path::new("/repo"), "lexical");
30950 let symbol_hits = vec![
30951 index::SymbolHit {
30952 name: "alpha_helper".to_string(),
30953 kind: "function".to_string(),
30954 language: "rust".to_string(),
30955 file: "/repo/src/lib.rs".to_string(),
30956 line: 12,
30957 end_line: None,
30958 node_kind: None,
30959 start_byte: None,
30960 end_byte: None,
30961 body_start_byte: None,
30962 body_end_byte: None,
30963 tags: Some("alpha,helper".to_string()),
30964 score: 0.98,
30965 match_type: "exact_name".to_string(),
30966 tagpath_handle: None,
30967 },
30968 index::SymbolHit {
30969 name: "alphaHelper".to_string(),
30970 kind: "method".to_string(),
30971 language: "rust".to_string(),
30972 file: "/repo/src/main.rs".to_string(),
30973 line: 34,
30974 end_line: None,
30975 node_kind: None,
30976 start_byte: None,
30977 end_byte: None,
30978 body_start_byte: None,
30979 body_end_byte: None,
30980 tags: Some("alpha,helper".to_string()),
30981 score: 0.93,
30982 match_type: "tag_overlap".to_string(),
30983 tagpath_handle: None,
30984 },
30985 index::SymbolHit {
30986 name: "alpha_helper".to_string(),
30987 kind: "function".to_string(),
30988 language: "rust".to_string(),
30989 file: "/repo/src/worker.rs".to_string(),
30990 line: 56,
30991 end_line: None,
30992 node_kind: None,
30993 start_byte: None,
30994 end_byte: None,
30995 body_start_byte: None,
30996 body_end_byte: None,
30997 tags: Some("alpha,helper".to_string()),
30998 score: 0.91,
30999 match_type: "tag_overlap".to_string(),
31000 tagpath_handle: None,
31001 },
31002 ];
31003
31004 let report = build_relative_search_budget_report(
31005 "alpha helper",
31006 "lexical",
31007 Path::new("/repo"),
31008 &response,
31009 &symbol_hits,
31010 ResponseBudget::new(Some(5), Some(48)),
31011 &SearchFacetFilters::default(),
31012 );
31013
31014 assert_eq!(report.symbol_total, 1);
31015 assert_eq!(report.raw_symbol_total, 3);
31016 assert_eq!(report.symbols.len(), 1);
31017 assert_eq!(report.symbols[0].tag_alias.as_deref(), Some("alpha/helper"));
31018 assert_eq!(report.symbols[0].match_count, 3);
31019 assert_eq!(report.symbols[0].surface_count, 2);
31020 assert_eq!(report.symbols[0].file_count, 3);
31021 assert_eq!(
31022 report.symbols[0].surface_examples,
31023 vec!["alpha_helper".to_string(), "alphaHelper".to_string()]
31024 );
31025 assert!(report.symbols[0].name.contains("(+1 variant)"));
31026 assert!(report.symbols[0].file.contains("(+2 files)"));
31027 assert!(report.symbols[0].expand.contains("tsift search"));
31028 assert!(report.symbols[0].expand.contains("alpha helper"));
31029 }
31030
31031 #[test]
31032 fn search_budget_report_carries_active_filters() {
31033 let response = empty_search_response(Path::new("/repo"), "lexical");
31034 let symbol_hits = vec![index::SymbolHit {
31035 name: "alpha_helper".to_string(),
31036 kind: "function".to_string(),
31037 language: "rust".to_string(),
31038 file: "/repo/src/lib.rs".to_string(),
31039 line: 12,
31040 end_line: None,
31041 node_kind: Some("function_item".to_string()),
31042 start_byte: None,
31043 end_byte: None,
31044 body_start_byte: None,
31045 body_end_byte: None,
31046 tags: Some("alpha,helper".to_string()),
31047 score: 0.98,
31048 match_type: "exact_name".to_string(),
31049 tagpath_handle: None,
31050 }];
31051 let filters = SearchFacetFilters {
31052 languages: vec!["rust".to_string()],
31053 kinds: vec!["function".to_string()],
31054 node_kinds: vec!["function_item".to_string()],
31055 ..SearchFacetFilters::default()
31056 };
31057
31058 let report = build_relative_search_budget_report(
31059 "alpha helper",
31060 "lexical",
31061 Path::new("/repo"),
31062 &response,
31063 &symbol_hits,
31064 ResponseBudget::new(Some(5), Some(48)),
31065 &filters,
31066 );
31067
31068 assert_eq!(report.filters, filters);
31069 assert_eq!(
31070 search_facet_filters_summary(&report.filters),
31071 "lang=rust kind=function node-kind=function_item"
31072 );
31073 }
31074
31075 #[test]
31076 fn search_budget_report_warns_on_broad_preview_and_lists_narrowing_commands() {
31077 let mut response = empty_search_response(Path::new("/repo"), "lexical");
31078 response.indexed_artifacts = 450;
31079 let symbol_hits = vec![
31080 index::SymbolHit {
31081 name: "alpha_helper".to_string(),
31082 kind: "function".to_string(),
31083 language: "rust".to_string(),
31084 file: "/repo/src/lib.rs".to_string(),
31085 line: 12,
31086 end_line: None,
31087 node_kind: None,
31088 start_byte: None,
31089 end_byte: None,
31090 body_start_byte: None,
31091 body_end_byte: None,
31092 tags: Some("alpha,helper".to_string()),
31093 score: 0.98,
31094 match_type: "exact_name".to_string(),
31095 tagpath_handle: None,
31096 },
31097 index::SymbolHit {
31098 name: "beta_helper".to_string(),
31099 kind: "function".to_string(),
31100 language: "rust".to_string(),
31101 file: "/repo/src/beta.rs".to_string(),
31102 line: 21,
31103 end_line: None,
31104 node_kind: None,
31105 start_byte: None,
31106 end_byte: None,
31107 body_start_byte: None,
31108 body_end_byte: None,
31109 tags: Some("beta,helper".to_string()),
31110 score: 0.92,
31111 match_type: "tag_overlap".to_string(),
31112 tagpath_handle: None,
31113 },
31114 ];
31115
31116 let report = build_relative_search_budget_report(
31117 "helper",
31118 "lexical",
31119 Path::new("/repo"),
31120 &response,
31121 &symbol_hits,
31122 ResponseBudget::new(Some(1), Some(64)),
31123 &SearchFacetFilters::default(),
31124 );
31125
31126 let guard = report
31127 .scale_guard
31128 .as_ref()
31129 .expect("broad previews should emit a scale guard");
31130 assert_eq!(guard.level, "high-hit");
31131 assert_eq!(guard.signals.indexed_artifacts, 450);
31132 assert_eq!(guard.signals.raw_symbol_matches, 2);
31133 assert!(
31134 guard
31135 .narrow_commands
31136 .iter()
31137 .any(|command| command.contains("--exact"))
31138 );
31139 assert!(
31140 guard
31141 .narrow_commands
31142 .iter()
31143 .any(|command| command.contains("alpha helper"))
31144 );
31145 assert!(
31146 guard
31147 .narrow_commands
31148 .last()
31149 .unwrap()
31150 .contains("workflow search")
31151 );
31152 }
31153
31154 #[test]
31155 fn explain_budget_report_limits_edges_and_members() {
31156 let symbols = vec![index::StoredSymbol {
31157 name: "alpha_helper".to_string(),
31158 kind: "function".to_string(),
31159 language: "rust".to_string(),
31160 signature: None,
31161 file: "src/lib.rs".to_string(),
31162 line: 10,
31163 end_line: None,
31164 node_kind: None,
31165 start_byte: None,
31166 end_byte: None,
31167 body_start_byte: None,
31168 body_end_byte: None,
31169 parent_module: None,
31170 visibility: None,
31171 tags: None,
31172 tagpath_handle: None,
31173 }];
31174 let callers = vec![
31175 index::StoredEdge {
31176 caller_file: "src/main.rs".to_string(),
31177 caller_name: "main".to_string(),
31178 caller_line: 1,
31179 callee_name: "alpha_helper".to_string(),
31180 call_site_line: 3,
31181 tagpath_handle: None,
31182 },
31183 index::StoredEdge {
31184 caller_file: "src/worker.rs".to_string(),
31185 caller_name: "worker".to_string(),
31186 caller_line: 5,
31187 callee_name: "alpha_helper".to_string(),
31188 call_site_line: 8,
31189 tagpath_handle: None,
31190 },
31191 ];
31192 let community = graph::Community {
31193 id: 1,
31194 members: vec![
31195 graph::CommunityMember::new("alpha_helper"),
31196 graph::CommunityMember::new("main"),
31197 graph::CommunityMember::new("worker"),
31198 ],
31199 modularity_contribution: 0.5,
31200 };
31201
31202 let report = build_explain_budget_report(
31203 "alpha_helper",
31204 Path::new("/repo"),
31205 &symbols,
31206 &callers,
31207 2,
31208 false,
31209 &[],
31210 0,
31211 false,
31212 Some(&community),
31213 ResponseBudget::new(Some(1), Some(24)),
31214 );
31215
31216 assert_eq!(report.definitions.len(), 1);
31217 assert_eq!(report.callers.len(), 1);
31218 assert!(report.truncated);
31219 assert_eq!(report.community.as_ref().unwrap().members.len(), 1);
31220 assert_eq!(
31221 report.definitions[0].tag_alias.as_deref(),
31222 Some("alpha/helper")
31223 );
31224 assert!(report.callers[0].handle.starts_with("ecall-"));
31225 assert_eq!(report.callers[0].tag_alias.as_deref(), Some("main"));
31226 }
31227
31228 #[test]
31229 fn session_review_next_context_budget_limits_lists() {
31230 let report = session_review::SessionReviewReport {
31231 root: "/repo".to_string(),
31232 target: "tasks/software/tsift.md".to_string(),
31233 target_kind: "file".to_string(),
31234 sessions_considered: 1,
31235 sessions_matched: 1,
31236 claude_sessions: 1,
31237 codex_sessions: 0,
31238 agent_doc_logs: 0,
31239 prompt_target_count: 2,
31240 command_groups: 0,
31241 file_groups: 2,
31242 symbol_groups: 1,
31243 failure_groups: 1,
31244 runtime_event_groups: 0,
31245 restart_churn_groups: 0,
31246 closeout_groups: 0,
31247 usage_samples: 1,
31248 prompt_tokens: 120,
31249 cached_input_tokens: 80,
31250 cache_creation_input_tokens: 0,
31251 output_tokens: 40,
31252 reasoning_output_tokens: 0,
31253 total_tokens: 240,
31254 cached_input_ratio: Some(40.0),
31255 largest_turn_total_tokens: 240,
31256 aggregate_cost: session_review::SessionReviewCostSummary {
31257 scope: "bounded_matched_sessions".to_string(),
31258 sessions: 1,
31259 usage_samples: 1,
31260 prompt_tokens: 120,
31261 cached_input_tokens: 80,
31262 cache_creation_input_tokens: 0,
31263 output_tokens: 40,
31264 reasoning_output_tokens: 0,
31265 total_tokens: 240,
31266 cached_input_ratio: Some(40.0),
31267 largest_turn_total_tokens: 240,
31268 },
31269 latest_session_cost: Some(session_review::SessionReviewCostSummary {
31270 scope: "latest_matched_session".to_string(),
31271 sessions: 1,
31272 usage_samples: 1,
31273 prompt_tokens: 120,
31274 cached_input_tokens: 80,
31275 cache_creation_input_tokens: 0,
31276 output_tokens: 40,
31277 reasoning_output_tokens: 0,
31278 total_tokens: 240,
31279 cached_input_ratio: Some(66.67),
31280 largest_turn_total_tokens: 240,
31281 }),
31282 prompt_cache_cross_run: None,
31283 prompt_cache_roi_scorecard: vec![],
31284 guardrails: vec![
31285 session_cost::SessionCostGuardrail {
31286 kind: "cache_resend".to_string(),
31287 severity: "warn".to_string(),
31288 message: "cached input ratio was high".to_string(),
31289 guidance: "compact or restart the session".to_string(),
31290 },
31291 session_cost::SessionCostGuardrail {
31292 kind: "prompt_budget".to_string(),
31293 severity: "warn".to_string(),
31294 message: "largest prompt turn reached 999999 tokens".to_string(),
31295 guidance: "compact the session before another large turn".to_string(),
31296 },
31297 session_cost::SessionCostGuardrail {
31298 kind: "restart_loop".to_string(),
31299 severity: "warn".to_string(),
31300 message: "restart churn detected".to_string(),
31301 guidance: "restart cleanly".to_string(),
31302 },
31303 session_cost::SessionCostGuardrail {
31304 kind: "noop_closeout".to_string(),
31305 severity: "warn".to_string(),
31306 message: "commit_already_current appeared 8 times".to_string(),
31307 guidance: "avoid reopening without new edits".to_string(),
31308 },
31309 ],
31310 loop_clusters: vec![session_cost::SessionCostLoopCluster {
31311 kind: "command_bundle".to_string(),
31312 label: "cargo test -> cargo build --release".to_string(),
31313 occurrences: 2,
31314 max_consecutive: 2,
31315 }],
31316 file_read_diagnostics: vec![session_cost::SessionCostFileReadDiagnostic {
31317 path: "src/lib.rs".to_string(),
31318 range: "12-40".to_string(),
31319 occurrences: 3,
31320 estimated_tokens: 1200,
31321 duplicate_estimated_tokens: 800,
31322 follow_up_commands: vec![
31323 "tsift source-read src/lib.rs --start 12 --lines 29 --budget normal"
31324 .to_string(),
31325 ],
31326 }],
31327 prompt_targets: vec![
31328 session_review::SessionReviewPromptTarget {
31329 text: "do one".to_string(),
31330 occurrences: 1,
31331 },
31332 session_review::SessionReviewPromptTarget {
31333 text: "do two".to_string(),
31334 occurrences: 1,
31335 },
31336 ],
31337 commands: vec![],
31338 touched_files: vec![],
31339 touched_symbols: vec![],
31340 failures: vec![],
31341 runtime_events: vec![],
31342 restart_churn: vec![],
31343 closeout: vec![],
31344 largest_turns: vec![],
31345 sessions: vec![session_review::SessionReviewSession {
31346 source: "claude_jsonl".to_string(),
31347 path: "/tmp/session.jsonl".to_string(),
31348 matched_by: vec!["path".to_string()],
31349 modified_unix_secs: None,
31350 prompt_target_count: 2,
31351 command_groups: 0,
31352 file_groups: 2,
31353 symbol_groups: 1,
31354 failure_groups: 1,
31355 runtime_event_groups: 0,
31356 restart_churn_groups: 0,
31357 closeout_groups: 0,
31358 usage_samples: 1,
31359 prompt_tokens: 120,
31360 cached_input_tokens: 80,
31361 cache_creation_input_tokens: 0,
31362 output_tokens: 40,
31363 reasoning_output_tokens: 0,
31364 total_tokens: 240,
31365 largest_turn_total_tokens: 240,
31366 }],
31367 next_context: session_review::SessionReviewNextContext {
31368 target: "tasks/software/tsift.md".to_string(),
31369 active_prompt_targets: vec!["do one".to_string(), "do two".to_string()],
31370 last_verification: session_review::SessionReviewVerificationState {
31371 status: "green".to_string(),
31372 detail: "cargo test".to_string(),
31373 },
31374 touched_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
31375 touched_symbols: vec!["alpha_helper".to_string(), "main".to_string()],
31376 unresolved_failures: vec![session_review::SessionReviewFailure {
31377 kind: "timeout".to_string(),
31378 message: "search timed out".to_string(),
31379 occurrences: 1,
31380 command: None,
31381 session_path: None,
31382 }],
31383 agent_doc_queue: Some(session_review::SessionReviewAgentDocQueueProfile {
31384 active_queue_prompt: Some(
31385 "[#one] do one with enough detail to truncate".to_string(),
31386 ),
31387 live_exchange_tail: vec!["do one".to_string(), "do two".to_string()],
31388 backlog_rows: vec!["[#one] do one".to_string(), "[#two] do two".to_string()],
31389 review_rows: vec![
31390 "[#review] review one".to_string(),
31391 "[#review2] review two".to_string(),
31392 ],
31393 prompt_presets: vec![
31394 "#spec-test-build-install-commit-push: update spec + tests"
31395 .to_string(),
31396 "#next-steps: collect follow-ups".to_string(),
31397 ],
31398 expansion_handles: vec![
31399 session_review::SessionReviewAgentDocExpansionHandle {
31400 handle: "adq-next-context".to_string(),
31401 label: "refresh next-context".to_string(),
31402 expand: "tsift --envelope session-review tasks/software/tsift.md --next-context --budget normal".to_string(),
31403 },
31404 session_review::SessionReviewAgentDocExpansionHandle {
31405 handle: "adq-context-pack".to_string(),
31406 label: "refresh context-pack".to_string(),
31407 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal".to_string(),
31408 },
31409 ],
31410 }),
31411 prompt_cache_health: None,
31412 next_digest_commands: vec![
31413 "tsift session-review --next-context tasks/software/tsift.md".to_string(),
31414 "tsift diff-digest .".to_string(),
31415 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log".to_string(),
31416 "tsift log-digest --path . < target/very-long-build-output-file-name-that-must-remain-executable.log".to_string(),
31417 ],
31418 },
31419 warnings: vec![],
31420 };
31421
31422 let budget_report = build_session_review_next_context_budget_report(
31423 &report,
31424 ResponseBudget::new(Some(1), Some(12)),
31425 None,
31426 );
31427
31428 assert!(budget_report.truncated);
31429 assert_eq!(budget_report.prompt_targets, vec!["do one"]);
31430 assert_eq!(budget_report.touched_files, vec!["src/lib.rs"]);
31431 assert!(
31432 budget_report.touched_symbol_refs[0]
31433 .handle
31434 .starts_with("ncsym-")
31435 );
31436 assert_eq!(
31437 budget_report.touched_symbol_refs[0].tag_alias.as_deref(),
31438 Some("alpha/helper")
31439 );
31440 assert!(
31441 budget_report.unresolved_failures[0]
31442 .handle
31443 .starts_with("snf-")
31444 );
31445 assert_eq!(budget_report.next_digest_commands.len(), 4);
31446 assert_eq!(
31447 budget_report.next_digest_commands[2],
31448 "tsift test-digest --path . < target/very-long-test-output-file-name-that-must-remain-executable.log"
31449 );
31450 let queue = budget_report
31451 .agent_doc_queue
31452 .as_ref()
31453 .expect("agent-doc queue budget profile should be present");
31454 assert_eq!(queue.active_queue_prompt.as_deref(), Some("[#one] do..."));
31455 assert_eq!(queue.backlog_rows, vec!["[#one] do..."]);
31456 assert_eq!(queue.review_row_total, 2);
31457 assert_eq!(queue.prompt_presets.len(), 1);
31458 assert_eq!(queue.expansion_handles.len(), 2);
31459 assert!(queue.truncated);
31460 assert_eq!(budget_report.next_token_actions.len(), 1);
31461 assert_eq!(budget_report.next_token_actions[0].kind, "prompt_budget");
31462
31463 let full_action_report = build_session_review_next_context_budget_report(
31464 &report,
31465 ResponseBudget::new(Some(6), Some(120)),
31466 None,
31467 );
31468 assert_eq!(
31469 full_action_report
31470 .next_token_actions
31471 .iter()
31472 .map(|action| action.kind.as_str())
31473 .collect::<Vec<_>>(),
31474 vec![
31475 "prompt_budget",
31476 "cache_resend",
31477 "repeated_raw_read",
31478 "repeated_command_bundle",
31479 "restart_loop",
31480 "noop_closeout"
31481 ]
31482 );
31483 assert_eq!(
31484 full_action_report.next_token_actions[0]
31485 .compact_command
31486 .as_deref(),
31487 Some("agent-doc compact \"tasks/software/tsift.md\" --commit")
31488 );
31489 assert_eq!(
31490 full_action_report.next_token_actions[0]
31491 .restart_command
31492 .as_deref(),
31493 Some("agent-doc start \"tasks/software/tsift.md\"")
31494 );
31495 assert!(
31496 full_action_report.next_token_actions[0]
31497 .digest_commands
31498 .iter()
31499 .any(|command| command
31500 == "tsift --envelope context-pack \"tasks/software/tsift.md\" --budget normal")
31501 );
31502 let raw_read_action = full_action_report
31503 .next_token_actions
31504 .iter()
31505 .find(|action| action.kind == "repeated_raw_read")
31506 .expect("raw read action");
31507 assert!(
31508 raw_read_action.rewrite_commands.iter().any(
31509 |command| command == "tsift rewrite --run \"sed -n 12,40p \\\"src/lib.rs\\\"\""
31510 ),
31511 "raw read rewrite commands: {:?}",
31512 raw_read_action.rewrite_commands
31513 );
31514 assert!(raw_read_action.rewrite_commands.iter().any(|command| command
31515 == "tsift --envelope source-read src/lib.rs --start 12 --lines 29 --budget normal"));
31516 let command_bundle_action = full_action_report
31517 .next_token_actions
31518 .iter()
31519 .find(|action| action.kind == "repeated_command_bundle")
31520 .expect("command bundle action");
31521 assert!(
31522 command_bundle_action
31523 .rewrite_commands
31524 .iter()
31525 .any(|command| command == "tsift rewrite --run \"cargo test\"")
31526 );
31527 assert!(
31528 command_bundle_action
31529 .rewrite_commands
31530 .iter()
31531 .any(|command| command == "tsift rewrite --run \"cargo build --release\"")
31532 );
31533 }
31534
31535 #[test]
31536 fn context_pack_diff_preview_limits_files_and_symbols() {
31537 let report = diff_digest::DiffDigestReport {
31538 root: "/repo".to_string(),
31539 mode: diff_digest::DiffDigestMode::WorkingTree,
31540 revision: None,
31541 files_changed: 2,
31542 files_with_current_summaries: 1,
31543 symbols_touched: 3,
31544 call_edges_added: 1,
31545 call_edges_removed: 0,
31546 files: vec![
31547 diff_digest::DiffDigestFile {
31548 path: "src/lib.rs".to_string(),
31549 status: diff_digest::DiffDigestFileStatus::Modified,
31550 touched_symbols: vec!["alpha_helper".to_string(), "beta_helper".to_string()],
31551 summary_state: diff_digest::DiffDigestSummaryState::Current,
31552 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
31553 symbol: "alpha_helper".to_string(),
31554 summary: "alpha helper handles the main alpha workflow".to_string(),
31555 }],
31556 added_call_edges: vec!["alpha->beta".to_string()],
31557 removed_call_edges: vec![],
31558 warnings: vec!["stale parse".to_string()],
31559 },
31560 diff_digest::DiffDigestFile {
31561 path: "src/main.rs".to_string(),
31562 status: diff_digest::DiffDigestFileStatus::Added,
31563 touched_symbols: vec!["main".to_string()],
31564 summary_state: diff_digest::DiffDigestSummaryState::Missing,
31565 current_summaries: vec![],
31566 added_call_edges: vec![],
31567 removed_call_edges: vec![],
31568 warnings: vec![],
31569 },
31570 ],
31571 };
31572
31573 let preview =
31574 build_context_pack_diff_preview(&report, ResponseBudget::new(Some(1), Some(11)), None);
31575
31576 assert!(preview.truncated);
31577 assert_eq!(preview.files.len(), 1);
31578 assert_eq!(preview.files[0].path, "src/lib.rs");
31579 assert_eq!(preview.files[0].touched_symbols, vec!["alpha_he..."]);
31580 assert!(
31581 preview.files[0].touched_symbol_refs[0]
31582 .handle
31583 .starts_with("cdsym-")
31584 );
31585 assert_eq!(
31586 preview.files[0].touched_symbol_refs[0].tag_alias.as_deref(),
31587 Some("alpha/he...")
31588 );
31589 assert!(
31590 preview.files[0].summary_refs[0]
31591 .handle
31592 .starts_with("cdsum-")
31593 );
31594 assert_eq!(
31595 preview.files[0].summary_refs[0].tag_alias.as_deref(),
31596 Some("alpha/he...")
31597 );
31598 assert_eq!(preview.files[0].summary_refs[0].summary, "alpha he...");
31599 assert_eq!(
31600 preview.files[0].summary_refs[0].expand,
31601 "tsift summarize --file \"src/lib.rs\""
31602 );
31603 assert_eq!(preview.files[0].warnings, vec!["stale parse"]);
31604 }
31605
31606 #[test]
31607 fn context_pack_status_reminders_include_stale_index_state() {
31608 let dir = setup_graph_index();
31609 std::thread::sleep(std::time::Duration::from_millis(50));
31610 std::fs::write(
31611 dir.path().join("main.rs"),
31612 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); Vec::new(); }\n",
31613 )
31614 .unwrap();
31615
31616 let reminders = context_pack_status_reminders(dir.path());
31617
31618 assert_eq!(reminders.len(), 1);
31619 assert!(reminders[0].contains("index stale"));
31620 assert!(reminders[0].contains("tsift index ."));
31621 }
31622
31623 #[test]
31630 fn build_context_pack_reuses_inspect_within_scope() {
31631 let dir = setup_graph_index();
31632 init_git_repo(dir.path());
31633 let _guard = index::InspectScopeGuard::new();
31634 let _ = build_context_pack_report(
31635 dir.path(),
31636 None,
31637 None,
31638 None,
31639 ResponseBudget::new(Some(2), Some(96)),
31640 )
31641 .unwrap();
31642 let (hits, misses) = index::inspect_scope_stats();
31643 assert!(
31644 hits >= 1,
31645 "expected at least one cached inspect within scope (hits={hits}, misses={misses})"
31646 );
31647 assert!(
31648 misses >= 1,
31649 "expected at least one initial inspect miss (hits={hits}, misses={misses})"
31650 );
31651 }
31652
31653 #[test]
31658 fn inspect_read_only_outside_scope_does_not_cache() {
31659 let dir = setup_graph_index();
31660 let db_path = dir.path().join(".tsift/index.db");
31661 let _first = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31662 let (hits, misses) = index::inspect_scope_stats();
31663 assert_eq!(
31664 (hits, misses),
31665 (0, 0),
31666 "no scope guard => no hits/misses recorded"
31667 );
31668 let _second = index::IndexDb::inspect_read_only(&db_path, dir.path(), false).unwrap();
31669 let (hits, _) = index::inspect_scope_stats();
31670 assert_eq!(hits, 0, "must not reuse inspection outside of any scope");
31671 }
31672
31673 #[test]
31674 fn context_pack_refreshes_stale_index_before_handoff() {
31675 let dir = setup_graph_index();
31676 init_git_repo(dir.path());
31677 std::thread::sleep(std::time::Duration::from_millis(50));
31678 std::fs::write(
31679 dir.path().join("main.rs"),
31680 "fn helper() { println!(\"updated\"); }\nfn main() { helper(); }\n",
31681 )
31682 .unwrap();
31683
31684 let report = build_context_pack_report(
31685 dir.path(),
31686 None,
31687 None,
31688 None,
31689 ResponseBudget::new(Some(2), Some(96)),
31690 )
31691 .unwrap();
31692
31693 assert!(
31694 report
31695 .status_reminders
31696 .iter()
31697 .any(|reminder| reminder.contains("index refreshed")
31698 && reminder.contains("context-pack handoff")),
31699 "expected context-pack refresh diagnostic, got {:?}",
31700 report.status_reminders
31701 );
31702 assert!(
31703 !report
31704 .status_reminders
31705 .iter()
31706 .any(|reminder| reminder.contains("index stale")),
31707 "stale reminder should be gone after refresh: {:?}",
31708 report.status_reminders
31709 );
31710
31711 let db = index::IndexDb::open_read_only(&dir.path().join(".tsift/index.db")).unwrap();
31712 let summary = db.compute_changes(dir.path()).unwrap();
31713 assert_eq!(summary.new + summary.modified + summary.deleted, 0);
31714 }
31715
31716 #[test]
31717 fn context_pack_materializes_source_handles_into_graph_store() {
31718 let dir = tempfile::tempdir().unwrap();
31719 let packet = ExplorationPacket {
31720 budget: exploration_budget_for_counts(2, 1),
31721 relationship_map: vec![ExplorationRelation {
31722 from: "file:main.rs".to_string(),
31723 relation: "touches_symbol".to_string(),
31724 to: "symbol:helper".to_string(),
31725 label: Some("modified diff".to_string()),
31726 }],
31727 source_windows: vec![ExplorationSourceWindow {
31728 handle: "xwin-test".to_string(),
31729 file: "main.rs".to_string(),
31730 start: 1,
31731 end: 32,
31732 reason: "changed file".to_string(),
31733 expand: "tsift --envelope source-read main.rs --path . --style window --start 1 --lines 32 --budget normal".to_string(),
31734 }],
31735 worker_context: vec![ExplorationWorkerContext {
31736 handle: "xwrk-test".to_string(),
31737 target: "tasks/software/tsift.md".to_string(),
31738 summary: "do #kgnv".to_string(),
31739 expand: "tsift --envelope context-pack tasks/software/tsift.md --budget normal"
31740 .to_string(),
31741 }],
31742 no_reread_guidance: "use windows".to_string(),
31743 };
31744
31745 let packet = materialize_context_pack_exploration_packet(dir.path(), packet).unwrap();
31746 assert_eq!(packet.source_windows[0].handle, "xwin-test");
31747
31748 let store = SqliteGraphStore::open(&dir.path().join(".tsift/graph.db")).unwrap();
31749 let source_handles = store.nodes_by_kind("source_handle").unwrap();
31750 assert_eq!(source_handles.len(), 1);
31751 assert_eq!(
31752 source_handles[0].properties.get("file"),
31753 Some(&"main.rs".to_string())
31754 );
31755 assert_eq!(
31756 store
31757 .outgoing_edges(&exploration_ref_id("file:main.rs"), Some("touches_symbol"))
31758 .unwrap()
31759 .len(),
31760 1
31761 );
31762 let worker_context = store.nodes_by_kind("worker_context").unwrap();
31763 assert_eq!(worker_context.len(), 1);
31764 assert_eq!(
31765 store
31766 .outgoing_edges("xwrk-test", Some("scopes_source"))
31767 .unwrap()
31768 .len(),
31769 1
31770 );
31771 }
31772
31773 #[test]
31774 fn context_pack_records_graph_orchestration_observability() {
31775 let dir = setup_traversal_project();
31776 init_git_repo(dir.path());
31777 let session = dir.path().join("tasks/software/tsift.md");
31778 refresh_traversal_graph_store(dir.path(), &session, None).unwrap();
31779
31780 let report = build_context_pack_report(
31781 &session,
31782 None,
31783 None,
31784 None,
31785 ResponseBudget::new(Some(4), Some(160)),
31786 )
31787 .unwrap();
31788
31789 assert_eq!(
31790 report.graph_orchestration.contract_version,
31791 CONTEXT_PACK_GRAPH_ORCHESTRATION_CONTRACT_VERSION
31792 );
31793 assert_eq!(
31794 report
31795 .graph_orchestration
31796 .projection_freshness
31797 .status
31798 .as_str(),
31799 "current"
31800 );
31801 assert!(!report.graph_orchestration.projection_hashes.is_empty());
31802 assert_eq!(report.graph_orchestration.readiness.status, "blocked");
31803 assert_eq!(
31804 report.graph_orchestration.readiness.reason,
31805 "summary_cache_empty"
31806 );
31807 assert!(report.graph_orchestration.readiness.fail_closed);
31808 assert!(
31809 report
31810 .graph_orchestration
31811 .readiness
31812 .next_commands
31813 .iter()
31814 .any(|command| command == "tsift summarize --extract ."),
31815 "{:?}",
31816 report.graph_orchestration.readiness.next_commands
31817 );
31818 assert!(
31819 report
31820 .graph_orchestration
31821 .evidence_packet_ids
31822 .iter()
31823 .all(|id| !id.starts_with("gevd-")),
31824 "evidence packet ids should be empty when readiness is blocked: {:?}",
31825 report.graph_orchestration.evidence_packet_ids
31826 );
31827 assert!(
31828 report
31829 .graph_orchestration
31830 .conflict_matrix_decisions
31831 .iter()
31832 .any(|decision| decision.contains("readiness blocked")),
31833 "conflict-matrix decisions should reference readiness block: {:?}",
31834 report.graph_orchestration.conflict_matrix_decisions
31835 );
31836 assert!(
31837 !report
31838 .graph_orchestration
31839 .follow_up_commands
31840 .iter()
31841 .any(|command| command.contains("conflict-matrix")),
31842 "conflict-matrix command should not appear when readiness is blocked: {:?}",
31843 report.graph_orchestration.follow_up_commands
31844 );
31845 assert!(
31846 report
31847 .graph_orchestration
31848 .follow_up_commands
31849 .iter()
31850 .any(|command| command == "tsift summarize --extract ."),
31851 "{:?}",
31852 report.graph_orchestration.follow_up_commands
31853 );
31854 assert!(
31855 !report
31856 .graph_orchestration
31857 .worker_ownership_blocks
31858 .is_empty()
31859 );
31860 }
31861
31862 #[test]
31863 fn convex_sync_report_chunks_upserts_and_tombstones() {
31864 let dir = setup_traversal_project();
31865 let source_graph = build_traversal_graph_source(dir.path(), dir.path(), None).unwrap();
31866 let projection = traversal_projection_from_graph(dir.path(), None, &source_graph).unwrap();
31867 let mut snapshot = projection.to_convex_rows();
31868 snapshot.nodes.push(ConvexNodeRow {
31869 external_id: "stale-node".to_string(),
31870 kind: "backlog".to_string(),
31871 label: "stale".to_string(),
31872 properties: BTreeMap::new(),
31873 provenance: Vec::new(),
31874 freshness: None,
31875 });
31876 snapshot.edges.clear();
31877 snapshot.edges.push(ConvexEdgeRow {
31878 edge_key: "stale-edge".to_string(),
31879 from_external_id: "stale-node".to_string(),
31880 to_external_id: "stale-node".to_string(),
31881 kind: "mentions".to_string(),
31882 properties: BTreeMap::new(),
31883 provenance: Vec::new(),
31884 freshness: None,
31885 });
31886 let snapshot_path = dir.path().join("convex-snapshot.json");
31887 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
31888
31889 let report = build_convex_sync_report(dir.path(), None, Some(&snapshot_path), 2).unwrap();
31890
31891 assert_eq!(report.freshness.status, "stale");
31892 assert!(report.freshness.fail_closed);
31893 assert_eq!(report.node_tombstones, vec!["stale-node".to_string()]);
31894 assert!(
31895 report.edge_upserts.len() > 1,
31896 "snapshot without edges should upsert local edges"
31897 );
31898 assert_eq!(report.edge_tombstones, vec!["stale-edge".to_string()]);
31899 assert_eq!(
31900 report.chunks.first().map(|chunk| chunk.operation.as_str()),
31901 Some("delete_edges"),
31902 "edge tombstones should be planned before node tombstones"
31903 );
31904 assert!(
31905 report
31906 .chunks
31907 .iter()
31908 .any(|chunk| chunk.operation == "upsert_edges" && chunk.count <= 2),
31909 "expected chunked edge upserts, got {:?}",
31910 report.chunks
31911 );
31912 }
31913
31914 #[test]
31915 fn convex_snapshot_validation_fails_closed_when_stale() {
31916 let dir = setup_traversal_project();
31917 build_traversal_graph(dir.path(), dir.path(), None).unwrap();
31918 let snapshot = ConvexProjectionRows::default();
31919 let snapshot_path = dir.path().join("empty-convex-snapshot.json");
31920 fs::write(&snapshot_path, serde_json::to_string(&snapshot).unwrap()).unwrap();
31921
31922 let err = verify_convex_projection_snapshot(dir.path(), None, &snapshot_path).unwrap_err();
31923 assert!(
31924 err.to_string()
31925 .contains("Convex graph projection is not current"),
31926 "{err}"
31927 );
31928 }
31929
31930 #[test]
31931 fn convex_sync_report_marks_live_apply_mode_without_network() {
31932 let dir = setup_traversal_project();
31933 let report =
31934 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
31935
31936 assert!(!report.dry_run);
31937 assert!(
31938 !report
31939 .diagnostics
31940 .iter()
31941 .any(|diagnostic| diagnostic.contains("dry-run only")),
31942 "apply-mode report should not claim dry-run diagnostics"
31943 );
31944 assert!(
31945 report
31946 .chunks
31947 .iter()
31948 .any(|chunk| chunk.operation == "upsert_nodes"),
31949 "live apply mode should still expose chunked idempotent operations"
31950 );
31951 }
31952
31953 #[test]
31954 fn convex_sync_apply_round_trips_with_http_backend() {
31955 use std::net::TcpListener;
31956 use std::sync::{Arc, Mutex};
31957
31958 let dir = setup_traversal_project();
31959 let report =
31960 build_convex_sync_report_with_snapshot(dir.path(), None, None, 100, false).unwrap();
31961 let expected_chunks = report.chunks.len();
31962 assert!(expected_chunks > 0);
31963
31964 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
31965 let endpoint = format!("http://{}", listener.local_addr().unwrap());
31966 let operations = Arc::new(Mutex::new(Vec::<String>::new()));
31967 let server_operations = Arc::clone(&operations);
31968 let server = std::thread::spawn(move || {
31969 for _ in 0..expected_chunks {
31970 let (mut stream, _) = listener.accept().unwrap();
31971 let mut reader = BufReader::new(stream.try_clone().unwrap());
31972 let mut request_line = String::new();
31973 reader.read_line(&mut request_line).unwrap();
31974 assert!(request_line.starts_with("POST "));
31975
31976 let mut content_length = 0usize;
31977 loop {
31978 let mut line = String::new();
31979 reader.read_line(&mut line).unwrap();
31980 if line == "\r\n" {
31981 break;
31982 }
31983 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
31984 content_length = value.trim().parse().unwrap();
31985 }
31986 }
31987
31988 let mut body = vec![0u8; content_length];
31989 reader.read_exact(&mut body).unwrap();
31990 let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
31991 server_operations
31992 .lock()
31993 .unwrap()
31994 .push(request["operation"].as_str().unwrap().to_string());
31995
31996 let response = br#"{"status":"ok","message":"accepted"}"#;
31997 write!(
31998 stream,
31999 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
32000 response.len()
32001 )
32002 .unwrap();
32003 stream.write_all(response).unwrap();
32004 }
32005 });
32006
32007 cmd_convex_sync(
32008 ConvexSyncOptions {
32009 path: dir.path(),
32010 scope: None,
32011 snapshot: None,
32012 chunk_size: 100,
32013 remote_snapshot: false,
32014 apply: true,
32015 endpoint: Some(&endpoint),
32016 auth_token_env: "TSIFT_TEST_CONVEX_AUTH_TOKEN",
32017 },
32018 OutputFormat {
32019 json_output: false,
32020 compact: true,
32021 pretty: false,
32022 terse: false,
32023 ultra_terse: false,
32024 schema: false,
32025 envelope: false,
32026 },
32027 )
32028 .unwrap();
32029 server.join().unwrap();
32030
32031 let operations = operations.lock().unwrap().clone();
32032 assert!(operations.contains(&"upsert_nodes".to_string()));
32033 assert!(operations.contains(&"upsert_edges".to_string()));
32034 }
32035
32036 #[test]
32037 fn context_pack_diff_preview_attaches_tag_ontology_refs() {
32038 let root = tempfile::tempdir().unwrap();
32039 fs::create_dir_all(root.path().join(".naming/tags")).unwrap();
32040 fs::write(
32041 root.path().join(".naming/tags/alpha.md"),
32042 "+++\ntag = \"alpha\"\ntitle = \"Alpha Domain\"\ndomain = \"fixture\"\n+++\n\nAlpha definition.\n",
32043 )
32044 .unwrap();
32045 let ontology = load_tag_ontology_preview_context(root.path()).unwrap();
32046 let report = diff_digest::DiffDigestReport {
32047 root: root.path().display().to_string(),
32048 mode: diff_digest::DiffDigestMode::WorkingTree,
32049 revision: None,
32050 files_changed: 1,
32051 files_with_current_summaries: 1,
32052 symbols_touched: 1,
32053 call_edges_added: 0,
32054 call_edges_removed: 0,
32055 files: vec![diff_digest::DiffDigestFile {
32056 path: "src/lib.rs".to_string(),
32057 status: diff_digest::DiffDigestFileStatus::Modified,
32058 touched_symbols: vec!["alpha_helper".to_string()],
32059 summary_state: diff_digest::DiffDigestSummaryState::Current,
32060 current_summaries: vec![diff_digest::DiffDigestSummarySnippet {
32061 symbol: "alpha_helper".to_string(),
32062 summary: "alpha helper summary".to_string(),
32063 }],
32064 added_call_edges: vec![],
32065 removed_call_edges: vec![],
32066 warnings: vec![],
32067 }],
32068 };
32069
32070 let preview = build_context_pack_diff_preview(
32071 &report,
32072 ResponseBudget::new(Some(1), Some(80)),
32073 Some(&ontology),
32074 );
32075
32076 let symbol_ref = &preview.files[0].touched_symbol_refs[0].ontology_refs[0];
32077 assert!(symbol_ref.handle.starts_with("tont-"));
32078 assert_eq!(symbol_ref.tag, "alpha");
32079 assert_eq!(symbol_ref.path, ".naming/tags/alpha.md");
32080 assert_eq!(symbol_ref.title.as_deref(), Some("Alpha Domain"));
32081 assert_eq!(symbol_ref.domain.as_deref(), Some("fixture"));
32082 assert_eq!(
32083 preview.files[0].summary_refs[0].ontology_refs[0].path,
32084 ".naming/tags/alpha.md"
32085 );
32086 }
32087
32088 #[test]
32089 fn context_pack_test_preview_limits_failure_groups() {
32090 let report = test_digest::TestDigestReport {
32091 root: "/repo".to_string(),
32092 runner: "cargo".to_string(),
32093 failures: 2,
32094 grouped_failures: 2,
32095 counts: test_digest::TestDigestCounts {
32096 passed: Some(8),
32097 failed: Some(2),
32098 skipped: Some(1),
32099 },
32100 failure_groups: vec![
32101 test_digest::TestDigestFailure {
32102 tests: vec!["suite::alpha_failure".to_string()],
32103 message: "assertion failed".to_string(),
32104 path: Some("src/lib.rs".to_string()),
32105 line: Some(42),
32106 column: None,
32107 occurrences: 1,
32108 summary_state: test_digest::TestDigestSummaryState::Current,
32109 current_summaries: vec![test_digest::TestDigestSummarySnippet {
32110 symbol: "alpha_failure".to_string(),
32111 summary: "failure summary for alpha test".to_string(),
32112 }],
32113 },
32114 test_digest::TestDigestFailure {
32115 tests: vec!["suite::beta_failure".to_string()],
32116 message: "panic".to_string(),
32117 path: Some("src/main.rs".to_string()),
32118 line: Some(7),
32119 column: None,
32120 occurrences: 1,
32121 summary_state: test_digest::TestDigestSummaryState::Missing,
32122 current_summaries: vec![],
32123 },
32124 ],
32125 warnings: vec!["warning text".to_string()],
32126 };
32127
32128 let preview =
32129 build_context_pack_test_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32130
32131 assert!(preview.truncated);
32132 assert_eq!(preview.failure_groups.len(), 1);
32133 assert_eq!(preview.failure_groups[0].tests, vec!["suite::alph..."]);
32134 assert_eq!(preview.failure_groups[0].message, "assertion f...");
32135 assert!(
32136 preview.failure_groups[0].summary_refs[0]
32137 .handle
32138 .starts_with("ctsum-")
32139 );
32140 assert_eq!(
32141 preview.failure_groups[0].summary_refs[0].expand,
32142 "tsift summarize --file \"src/lib.rs\""
32143 );
32144 assert_eq!(preview.warnings, vec!["warning text"]);
32145 }
32146
32147 #[test]
32148 fn maybe_attach_log_digest_raw_artifact_persists_bulky_logs() {
32149 let dir = tempfile::tempdir().unwrap();
32150 let root = dir.path();
32151
32152 let small_input = "Compiling serde v1.0.130\n";
32154 let mut small = log_digest::compute(root, small_input).unwrap();
32155 maybe_attach_log_digest_raw_artifact(root, &mut small, small_input).unwrap();
32156 assert!(small.raw_log_artifact.is_none());
32157 assert!(!root.join(".tsift/artifacts").exists());
32158
32159 let bulky_input = "x".repeat(log_digest::LOG_DIGEST_RAW_ARTIFACT_MIN_BYTES) + "\n";
32161 let mut bulky = log_digest::compute(root, &bulky_input).unwrap();
32162 maybe_attach_log_digest_raw_artifact(root, &mut bulky, &bulky_input).unwrap();
32163 let artifact = bulky
32164 .raw_log_artifact
32165 .expect("artifact attached for bulky log");
32166 assert!(artifact.handle.starts_with("logdg-"));
32167 assert_eq!(artifact.bytes, bulky_input.len());
32168 assert!(artifact.expand.contains("tsift log-digest"));
32169 assert!(artifact.expand.contains("--input"));
32170 let persisted = root.join(&artifact.path);
32171 assert!(persisted.exists(), "artifact file written to {persisted:?}");
32172 assert_eq!(std::fs::read_to_string(&persisted).unwrap(), bulky_input);
32173 }
32174
32175 #[test]
32176 fn context_pack_log_preview_limits_signals_and_refs() {
32177 let report = log_digest::LogDigestReport {
32178 root: "/repo".to_string(),
32179 total_lines: 12,
32180 non_empty_lines: 10,
32181 signal_groups: 2,
32182 error_signal_groups: 1,
32183 repeated_line_groups: 2,
32184 repeated_line_occurrences: 3,
32185 line_family_groups: 0,
32186 file_ref_groups: 2,
32187 symbol_ref_groups: 2,
32188 stack_groups: 1,
32189 signals: vec![
32190 log_digest::LogDigestSignal {
32191 severity: "error".to_string(),
32192 message: "src/lib.rs:42 boom".to_string(),
32193 path: Some("src/lib.rs".to_string()),
32194 line: Some(42),
32195 column: None,
32196 occurrences: 2,
32197 summary_state: log_digest::LogDigestSummaryState::Current,
32198 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32199 symbol: "alpha_helper".to_string(),
32200 summary: "alpha helper cached log summary".to_string(),
32201 }],
32202 },
32203 log_digest::LogDigestSignal {
32204 severity: "warn".to_string(),
32205 message: "slow path".to_string(),
32206 path: None,
32207 line: None,
32208 column: None,
32209 occurrences: 1,
32210 summary_state: log_digest::LogDigestSummaryState::Unavailable,
32211 current_summaries: vec![],
32212 },
32213 ],
32214 repeated_lines: vec![
32215 log_digest::LogDigestRepeatedLine {
32216 line: "retrying work item alpha".to_string(),
32217 occurrences: 3,
32218 },
32219 log_digest::LogDigestRepeatedLine {
32220 line: "retrying work item beta".to_string(),
32221 occurrences: 2,
32222 },
32223 ],
32224 line_families: vec![],
32225 file_refs: vec![
32226 log_digest::LogDigestFileRef {
32227 path: "src/lib.rs".to_string(),
32228 line: Some(42),
32229 column: None,
32230 occurrences: 2,
32231 summary_state: log_digest::LogDigestSummaryState::Current,
32232 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32233 symbol: "alpha_helper".to_string(),
32234 summary: "alpha helper cached file summary".to_string(),
32235 }],
32236 },
32237 log_digest::LogDigestFileRef {
32238 path: "src/main.rs".to_string(),
32239 line: Some(7),
32240 column: None,
32241 occurrences: 1,
32242 summary_state: log_digest::LogDigestSummaryState::Missing,
32243 current_summaries: vec![],
32244 },
32245 ],
32246 symbol_refs: vec![
32247 log_digest::LogDigestSymbolRef {
32248 symbol: "alpha_helper".to_string(),
32249 occurrences: 2,
32250 summary_state: log_digest::LogDigestSummaryState::Current,
32251 current_summaries: vec![log_digest::LogDigestSummarySnippet {
32252 symbol: "alpha_helper".to_string(),
32253 summary: "alpha helper cached symbol summary".to_string(),
32254 }],
32255 },
32256 log_digest::LogDigestSymbolRef {
32257 symbol: "beta_helper".to_string(),
32258 occurrences: 1,
32259 summary_state: log_digest::LogDigestSummaryState::Missing,
32260 current_summaries: vec![],
32261 },
32262 ],
32263 stack_traces: vec![log_digest::LogDigestStackGroup {
32264 frames: vec!["frame one".to_string()],
32265 occurrences: 1,
32266 }],
32267 raw_log_artifact: None,
32268 warnings: vec!["warning text".to_string()],
32269 };
32270
32271 let preview =
32272 build_context_pack_log_preview(&report, ResponseBudget::new(Some(1), Some(14)), None);
32273
32274 assert!(preview.truncated);
32275 assert_eq!(preview.signals.len(), 1);
32276 assert_eq!(preview.signals[0].message, "src/lib.rs:...");
32277 assert_eq!(preview.repeated_lines[0].line, "retrying wo...");
32278 assert_eq!(preview.file_refs.len(), 1);
32279 assert_eq!(preview.symbol_refs[0].symbol, "alpha_helper");
32280 assert!(
32281 preview.signals[0].summary_refs[0]
32282 .handle
32283 .starts_with("clsum-")
32284 );
32285 assert!(
32286 preview.file_refs[0].summary_refs[0]
32287 .handle
32288 .starts_with("clfsum-")
32289 );
32290 assert!(
32291 preview.symbol_refs[0].summary_refs[0]
32292 .handle
32293 .starts_with("clssum-")
32294 );
32295 assert_eq!(
32296 preview.symbol_refs[0].summary_refs[0].tag_alias.as_deref(),
32297 Some("alpha/helper")
32298 );
32299 assert_eq!(
32300 preview.symbol_refs[0].summary_refs[0].expand,
32301 "tsift summarize \"alpha_helper\""
32302 );
32303 assert_eq!(preview.warnings, vec!["warning text"]);
32304 }
32305
32306 #[test]
32307 fn cli_search_rejects_exact_with_strategy_flag() {
32308 let cli = try_parse_cli([
32309 "tsift",
32310 "search",
32311 "test",
32312 "--exact",
32313 "--strategy",
32314 "lexical",
32315 ]);
32316 assert!(cli.is_err());
32317 }
32318
32319 #[test]
32320 fn cli_search_autoindexes_by_default() {
32321 let cli = parse_cli(["tsift", "search", "test"]);
32322 match cli.command {
32323 Some(Commands::Search {
32324 autoindex,
32325 no_autoindex,
32326 ..
32327 }) => {
32328 assert!(!autoindex);
32329 assert!(!no_autoindex);
32330 assert!(autoindex || !no_autoindex);
32331 }
32332 _ => panic!("expected Search command"),
32333 }
32334 }
32335
32336 #[test]
32337 fn cli_local_model_status_accepts_json_and_no_probe() {
32338 let cli = parse_cli(["tsift", "local-model", "status", "--json", "--no-probe"]);
32339 match cli.command {
32340 Some(Commands::LocalModel {
32341 command: LocalModelCommand::Status { json, no_probe },
32342 }) => {
32343 assert!(json);
32344 assert!(no_probe);
32345 }
32346 _ => panic!("expected LocalModel status command"),
32347 }
32348 }
32349
32350 #[test]
32351 fn cli_local_model_unload_accepts_probe_and_strict_flags() {
32352 let cli = parse_cli([
32353 "tsift",
32354 "local-model",
32355 "unload",
32356 "--profile",
32357 "qwen3-32b-q4",
32358 "--pre-used-mib",
32359 "200",
32360 "--post-used-mib",
32361 "800",
32362 "--provider-pid",
32363 "42",
32364 "--strict",
32365 "--json",
32366 ]);
32367 match cli.command {
32368 Some(Commands::LocalModel {
32369 command:
32370 LocalModelCommand::Unload {
32371 profile,
32372 provider_pid,
32373 pre_used_mib,
32374 post_used_mib,
32375 strict,
32376 json,
32377 ..
32378 },
32379 }) => {
32380 assert_eq!(profile, "qwen3-32b-q4");
32381 assert_eq!(provider_pid, Some(42));
32382 assert_eq!(pre_used_mib, Some(200));
32383 assert_eq!(post_used_mib, Some(800));
32384 assert!(strict);
32385 assert!(json);
32386 }
32387 _ => panic!("expected LocalModel unload command"),
32388 }
32389 }
32390
32391 #[test]
32392 fn cli_local_model_swap_parses_flags() {
32393 let cli = parse_cli([
32394 "tsift",
32395 "local-model",
32396 "swap",
32397 "--from",
32398 "qwen3-32b-q4",
32399 "--to",
32400 "qwen3-embedding-0.6b",
32401 "--provider-pid",
32402 "42",
32403 "--pre-used-mib",
32404 "200",
32405 "--post-used-mib",
32406 "180",
32407 "--strict",
32408 "--json",
32409 ]);
32410 match cli.command {
32411 Some(Commands::LocalModel {
32412 command:
32413 LocalModelCommand::Swap {
32414 from,
32415 to,
32416 provider_pid,
32417 pre_used_mib,
32418 post_used_mib,
32419 strict,
32420 json,
32421 ..
32422 },
32423 }) => {
32424 assert_eq!(from, "qwen3-32b-q4");
32425 assert_eq!(to, "qwen3-embedding-0.6b");
32426 assert_eq!(provider_pid, Some(42));
32427 assert_eq!(pre_used_mib, Some(200));
32428 assert_eq!(post_used_mib, Some(180));
32429 assert!(strict);
32430 assert!(json);
32431 }
32432 _ => panic!("expected LocalModel swap command"),
32433 }
32434 }
32435
32436 #[test]
32437 fn cli_local_model_resolve_parses_flags() {
32438 use cli::ResolveRole;
32439 let cli = parse_cli([
32440 "tsift",
32441 "local-model",
32442 "resolve",
32443 "--profile",
32444 "hash",
32445 "--role",
32446 "embed",
32447 "--no-probe",
32448 "--json",
32449 ]);
32450 match cli.command {
32451 Some(Commands::LocalModel {
32452 command:
32453 LocalModelCommand::Resolve {
32454 profile,
32455 role,
32456 no_probe,
32457 json,
32458 },
32459 }) => {
32460 assert_eq!(profile.as_deref(), Some("hash"));
32461 assert_eq!(role, ResolveRole::Embed);
32462 assert!(no_probe);
32463 assert!(json);
32464 }
32465 _ => panic!("expected LocalModel resolve command"),
32466 }
32467 }
32468
32469 #[test]
32470 fn cli_semantic_command_accepts_profile_flag() {
32471 let cli = parse_cli([
32472 "tsift",
32473 "semantic",
32474 "auth",
32475 "--profile",
32476 "qwen3-embedding-0.6b",
32477 "--json",
32478 ]);
32479 match cli.command {
32480 Some(Commands::Semantic { profile, query, .. }) => {
32481 assert_eq!(query, "auth");
32482 assert_eq!(profile.as_deref(), Some("qwen3-embedding-0.6b"));
32483 }
32484 _ => panic!("expected Semantic command"),
32485 }
32486 }
32487
32488 #[test]
32489 fn cli_summarize_command_accepts_profile_flag() {
32490 let cli = parse_cli([
32491 "tsift",
32492 "summarize",
32493 "--extract",
32494 "src",
32495 "--profile",
32496 "hash",
32497 "--json",
32498 ]);
32499 match cli.command {
32500 Some(Commands::Summarize {
32501 extract,
32502 profile,
32503 json,
32504 ..
32505 }) => {
32506 assert_eq!(extract.as_deref(), Some(std::path::Path::new("src")));
32507 assert_eq!(profile.as_deref(), Some("hash"));
32508 assert!(json);
32509 }
32510 _ => panic!("expected Summarize command"),
32511 }
32512 }
32513
32514 #[test]
32515 fn cli_local_model_lease_acquire_parses_flags() {
32516 let cli = parse_cli([
32517 "tsift",
32518 "local-model",
32519 "lease",
32520 "acquire",
32521 "--profile",
32522 "qwen3-32b-q4",
32523 "--holder-pid",
32524 "4242",
32525 "--holder-command",
32526 "corky",
32527 "--idle-ttl-seconds",
32528 "120",
32529 "--vram-baseline-mib",
32530 "200",
32531 "--lease-file",
32532 "/tmp/tsift-lease.json",
32533 "--strict",
32534 "--json",
32535 ]);
32536 match cli.command {
32537 Some(Commands::LocalModel {
32538 command:
32539 LocalModelCommand::Lease {
32540 command:
32541 LeaseCommand::Acquire {
32542 profile,
32543 holder_pid,
32544 holder_command,
32545 idle_ttl_seconds,
32546 vram_baseline_mib,
32547 lease_file,
32548 strict,
32549 json,
32550 ..
32551 },
32552 },
32553 }) => {
32554 assert_eq!(profile, "qwen3-32b-q4");
32555 assert_eq!(holder_pid, Some(4242));
32556 assert_eq!(holder_command, "corky");
32557 assert_eq!(idle_ttl_seconds, 120);
32558 assert_eq!(vram_baseline_mib, Some(200));
32559 assert_eq!(lease_file, Some(PathBuf::from("/tmp/tsift-lease.json")));
32560 assert!(strict);
32561 assert!(json);
32562 }
32563 _ => panic!("expected LocalModel lease acquire command"),
32564 }
32565 }
32566
32567 #[test]
32568 fn cli_local_model_lease_release_parses_flags() {
32569 let cli = parse_cli([
32570 "tsift",
32571 "local-model",
32572 "lease",
32573 "release",
32574 "--profile",
32575 "qwen3-embedding-0.6b",
32576 "--holder-pid",
32577 "999",
32578 "--json",
32579 ]);
32580 match cli.command {
32581 Some(Commands::LocalModel {
32582 command:
32583 LocalModelCommand::Lease {
32584 command:
32585 LeaseCommand::Release {
32586 profile,
32587 holder_pid,
32588 json,
32589 ..
32590 },
32591 },
32592 }) => {
32593 assert_eq!(profile, "qwen3-embedding-0.6b");
32594 assert_eq!(holder_pid, Some(999));
32595 assert!(json);
32596 }
32597 _ => panic!("expected LocalModel lease release command"),
32598 }
32599 }
32600
32601 #[test]
32602 fn cli_local_model_lease_show_parses_flags() {
32603 let cli = parse_cli([
32604 "tsift",
32605 "local-model",
32606 "lease",
32607 "show",
32608 "--include-stale",
32609 "--json",
32610 ]);
32611 match cli.command {
32612 Some(Commands::LocalModel {
32613 command:
32614 LocalModelCommand::Lease {
32615 command:
32616 LeaseCommand::Show {
32617 include_stale,
32618 json,
32619 ..
32620 },
32621 },
32622 }) => {
32623 assert!(include_stale);
32624 assert!(json);
32625 }
32626 _ => panic!("expected LocalModel lease show command"),
32627 }
32628 }
32629
32630 #[test]
32631 fn cli_search_accepts_no_autoindex_flag() {
32632 let cli = parse_cli(["tsift", "search", "test", "--no-autoindex"]);
32633 match cli.command {
32634 Some(Commands::Search {
32635 autoindex,
32636 no_autoindex,
32637 ..
32638 }) => {
32639 assert!(!autoindex);
32640 assert!(no_autoindex);
32641 }
32642 _ => panic!("expected Search command"),
32643 }
32644 }
32645
32646 #[test]
32647 fn cli_search_rejects_conflicting_autoindex_flags() {
32648 let cli = try_parse_cli(["tsift", "search", "test", "--autoindex", "--no-autoindex"]);
32649 assert!(cli.is_err());
32650 }
32651
32652 #[test]
32655 fn cli_accepts_global_absolute_flag() {
32656 let cli = parse_cli(["tsift", "--absolute", "status"]);
32657 assert!(cli.absolute);
32658 assert!(matches!(cli.command, Some(Commands::Status { .. })));
32659 }
32660
32661 #[test]
32662 fn cli_accepts_global_tabular_flag() {
32663 let cli = parse_cli(["tsift", "--tabular", "search", "test"]);
32664 assert!(cli.tabular);
32665 assert!(matches!(cli.command, Some(Commands::Search { .. })));
32666 }
32667
32668 #[test]
32669 fn cli_tabular_with_graph() {
32670 let cli = parse_cli(["tsift", "--tabular", "graph", "main"]);
32671 assert!(cli.tabular);
32672 assert!(matches!(cli.command, Some(Commands::Graph { .. })));
32673 }
32674
32675 #[test]
32676 fn cli_tabular_with_communities() {
32677 let cli = parse_cli(["tsift", "--tabular", "communities"]);
32678 assert!(cli.tabular);
32679 assert!(matches!(cli.command, Some(Commands::Communities { .. })));
32680 }
32681
32682 #[test]
32683 fn cli_tabular_with_explain() {
32684 let cli = parse_cli(["tsift", "--tabular", "explain", "main"]);
32685 assert!(cli.tabular);
32686 assert!(matches!(cli.command, Some(Commands::Explain { .. })));
32687 }
32688
32689 #[test]
32690 fn cli_traverse_accepts_path_target_and_html_format() {
32691 let cli = parse_cli([
32692 "tsift", "traverse", "#kgnv", "--to", "main", "--path", ".", "--format", "html",
32693 ]);
32694 match cli.command {
32695 Some(Commands::Traverse {
32696 node,
32697 to,
32698 path,
32699 format,
32700 ..
32701 }) => {
32702 assert_eq!(node.as_deref(), Some("#kgnv"));
32703 assert_eq!(to.as_deref(), Some("main"));
32704 assert_eq!(path, PathBuf::from("."));
32705 assert_eq!(format, TraverseFormat::Html);
32706 }
32707 _ => panic!("expected Traverse command"),
32708 }
32709 }
32710
32711 #[test]
32712 fn cli_parses_semantic_related_command() {
32713 let cli = parse_cli([
32714 "tsift",
32715 "semantic",
32716 "graph navigation",
32717 "--path",
32718 ".",
32719 "--kind",
32720 "all",
32721 "--limit",
32722 "3",
32723 "--json",
32724 ]);
32725 match cli.command {
32726 Some(Commands::Semantic {
32727 query,
32728 path,
32729 kind,
32730 limit,
32731 json,
32732 ..
32733 }) => {
32734 assert_eq!(query, "graph navigation");
32735 assert_eq!(path, PathBuf::from("."));
32736 assert_eq!(kind, SemanticRelatedKind::All);
32737 assert_eq!(limit, 3);
32738 assert!(json);
32739 }
32740 _ => panic!("expected Semantic command"),
32741 }
32742 }
32743
32744 #[test]
32745 fn cli_parses_convex_sync_command() {
32746 let cli = parse_cli([
32747 "tsift",
32748 "convex-sync",
32749 ".",
32750 "--snapshot",
32751 "rows.json",
32752 "--chunk-size",
32753 "25",
32754 "--json",
32755 ]);
32756 match cli.command {
32757 Some(Commands::ConvexSync {
32758 path,
32759 snapshot,
32760 chunk_size,
32761 json,
32762 ..
32763 }) => {
32764 assert_eq!(path, PathBuf::from("."));
32765 assert_eq!(snapshot, Some(PathBuf::from("rows.json")));
32766 assert_eq!(chunk_size, 25);
32767 assert!(json);
32768 }
32769 _ => panic!("expected ConvexSync command"),
32770 }
32771 }
32772
32773 #[test]
32774 fn cli_parses_convex_sync_live_flags() {
32775 let cli = parse_cli([
32776 "tsift",
32777 "convex-sync",
32778 ".",
32779 "--remote-snapshot",
32780 "--apply",
32781 "--endpoint",
32782 "https://example.test/convex-graph",
32783 "--auth-token-env",
32784 "TSIFT_TEST_TOKEN",
32785 ]);
32786 match cli.command {
32787 Some(Commands::ConvexSync {
32788 remote_snapshot,
32789 apply,
32790 endpoint,
32791 auth_token_env,
32792 ..
32793 }) => {
32794 assert!(remote_snapshot);
32795 assert!(apply);
32796 assert_eq!(
32797 endpoint.as_deref(),
32798 Some("https://example.test/convex-graph")
32799 );
32800 assert_eq!(auth_token_env, "TSIFT_TEST_TOKEN");
32801 }
32802 _ => panic!("expected ConvexSync command"),
32803 }
32804 }
32805
32806 #[test]
32807 fn cli_parses_graph_db_query() {
32808 let cli = parse_cli([
32809 "tsift",
32810 "graph-db",
32811 "--backend",
32812 "convex-snapshot",
32813 "--convex-snapshot",
32814 "rows.json",
32815 "--json",
32816 "neighborhood",
32817 "gbak-kgnv",
32818 "--depth",
32819 "2",
32820 "--edge-kind",
32821 "mentions",
32822 "--property",
32823 "path=tasks/software/tsift.md",
32824 "--cursor",
32825 "gbak-old",
32826 "--limit",
32827 "10",
32828 ]);
32829 match cli.command {
32830 Some(Commands::GraphDb {
32831 backend,
32832 convex_snapshot,
32833 json,
32834 query,
32835 ..
32836 }) => {
32837 assert_eq!(backend, GraphDbBackend::ConvexSnapshot);
32838 assert_eq!(convex_snapshot, Some(PathBuf::from("rows.json")));
32839 assert!(json);
32840 match query {
32841 GraphDbQuery::Neighborhood {
32842 id,
32843 depth,
32844 edge_kind,
32845 cursor,
32846 limit,
32847 property_filters,
32848 } => {
32849 assert_eq!(id, "gbak-kgnv");
32850 assert_eq!(depth, 2);
32851 assert_eq!(edge_kind.as_deref(), Some("mentions"));
32852 assert_eq!(cursor.as_deref(), Some("gbak-old"));
32853 assert_eq!(limit, Some(10));
32854 assert_eq!(
32855 property_filters,
32856 vec!["path=tasks/software/tsift.md".to_string()]
32857 );
32858 }
32859 _ => panic!("expected graph-db neighborhood query"),
32860 }
32861 }
32862 _ => panic!("expected GraphDb command"),
32863 }
32864 }
32865
32866 #[test]
32867 fn cli_parses_graph_db_backend_eval_surrealdb_candidate() {
32868 let cli = parse_cli([
32869 "tsift",
32870 "graph-db",
32871 "--json",
32872 "backend-eval",
32873 "--candidate",
32874 "surrealdb",
32875 "--target",
32876 "gval",
32877 "--full-projection",
32878 ]);
32879 match cli.command {
32880 Some(Commands::GraphDb { json, query, .. }) => {
32881 assert!(json);
32882 match query {
32883 GraphDbQuery::BackendEval {
32884 candidates,
32885 targets,
32886 full_projection,
32887 } => {
32888 assert_eq!(candidates, vec!["surrealdb".to_string()]);
32889 assert_eq!(targets, vec!["gval".to_string()]);
32890 assert!(full_projection);
32891 }
32892 _ => panic!("expected graph-db backend-eval query"),
32893 }
32894 }
32895 _ => panic!("expected GraphDb command"),
32896 }
32897 }
32898
32899 #[test]
32900 fn cli_parses_graph_db_tokensave_backend() {
32901 let cli = parse_cli([
32902 "tsift",
32903 "graph-db",
32904 "--backend",
32905 "tokensave",
32906 "--json",
32907 "node",
32908 "fn:main",
32909 ]);
32910 match cli.command {
32911 Some(Commands::GraphDb {
32912 backend,
32913 json,
32914 query,
32915 ..
32916 }) => {
32917 assert_eq!(backend, GraphDbBackend::Tokensave);
32918 assert!(json);
32919 match query {
32920 GraphDbQuery::Node { id } => assert_eq!(id, "fn:main"),
32921 _ => panic!("expected graph-db node query"),
32922 }
32923 }
32924 _ => panic!("expected GraphDb command"),
32925 }
32926 }
32927
32928 #[test]
32929 fn cli_parses_analyze_command() {
32930 let cli = parse_cli([
32931 "tsift", "analyze", ".", "--scope", "core", "--entry", "main", "--entry", "run",
32932 "--limit", "7", "--json",
32933 ]);
32934 match cli.command {
32935 Some(Commands::Analyze {
32936 path,
32937 scope,
32938 entry_points,
32939 limit,
32940 json,
32941 }) => {
32942 assert_eq!(path, PathBuf::from("."));
32943 assert_eq!(scope.as_deref(), Some("core"));
32944 assert_eq!(entry_points, vec!["main".to_string(), "run".to_string()]);
32945 assert_eq!(limit, 7);
32946 assert!(json);
32947 }
32948 _ => panic!("expected Analyze command"),
32949 }
32950 }
32951
32952 #[test]
32953 fn cli_parses_graph_db_related_query() {
32954 let cli = parse_cli([
32955 "tsift",
32956 "graph-db",
32957 "--json",
32958 "related",
32959 "voice avatar memory retrieval",
32960 "--kind",
32961 "all",
32962 "--depth",
32963 "3",
32964 "--seed-limit",
32965 "4",
32966 "--limit",
32967 "12",
32968 ]);
32969 match cli.command {
32970 Some(Commands::GraphDb { json, query, .. }) => {
32971 assert!(json);
32972 match query {
32973 GraphDbQuery::Related {
32974 query,
32975 kind,
32976 depth,
32977 seed_limit,
32978 limit,
32979 } => {
32980 assert_eq!(query, "voice avatar memory retrieval");
32981 assert_eq!(kind, SemanticRelatedKind::All);
32982 assert_eq!(depth, 3);
32983 assert_eq!(seed_limit, 4);
32984 assert_eq!(limit, 12);
32985 }
32986 _ => panic!("expected graph-db related query"),
32987 }
32988 }
32989 _ => panic!("expected GraphDb command"),
32990 }
32991 }
32992
32993 #[test]
32994 fn cli_parses_graph_db_compact_query() {
32995 let cli = parse_cli([
32996 "tsift",
32997 "graph-db",
32998 "--path",
32999 ".",
33000 "compact",
33001 "--apply",
33002 "--prune-tombstones",
33003 "--confirmed-convex-reconciled",
33004 ]);
33005 match cli.command {
33006 Some(Commands::GraphDb { query, .. }) => match query {
33007 GraphDbQuery::Compact {
33008 apply,
33009 prune_tombstones,
33010 confirmed_convex_reconciled,
33011 } => {
33012 assert!(apply);
33013 assert!(prune_tombstones);
33014 assert!(confirmed_convex_reconciled);
33015 }
33016 _ => panic!("expected graph-db compact query"),
33017 },
33018 _ => panic!("expected GraphDb command"),
33019 }
33020 }
33021
33022 #[test]
33023 fn cli_parses_graph_db_snapshot_queries() {
33024 let export_cli = parse_cli([
33025 "tsift",
33026 "graph-db",
33027 "--json",
33028 "snapshot-export",
33029 "graph.db.gz",
33030 "--force",
33031 ]);
33032 match export_cli.command {
33033 Some(Commands::GraphDb { json, query, .. }) => {
33034 assert!(json);
33035 match query {
33036 GraphDbQuery::SnapshotExport { output, force } => {
33037 assert_eq!(output, PathBuf::from("graph.db.gz"));
33038 assert!(force);
33039 }
33040 _ => panic!("expected graph-db snapshot-export query"),
33041 }
33042 }
33043 _ => panic!("expected GraphDb command"),
33044 }
33045
33046 let import_cli = parse_cli([
33047 "tsift",
33048 "graph-db",
33049 "snapshot-import",
33050 "graph.db.gz",
33051 "--replace",
33052 ]);
33053 match import_cli.command {
33054 Some(Commands::GraphDb { query, .. }) => match query {
33055 GraphDbQuery::SnapshotImport { artifact, replace } => {
33056 assert_eq!(artifact, PathBuf::from("graph.db.gz"));
33057 assert!(replace);
33058 }
33059 _ => panic!("expected graph-db snapshot-import query"),
33060 },
33061 _ => panic!("expected GraphDb command"),
33062 }
33063 }
33064
33065 #[test]
33066 fn cli_parses_impact_command() {
33067 let cli = parse_cli(["tsift", "impact", ".", "--cached", "--limit", "5"]);
33068 match cli.command {
33069 Some(Commands::Impact {
33070 path,
33071 cached,
33072 limit,
33073 ..
33074 }) => {
33075 assert_eq!(path, PathBuf::from("."));
33076 assert!(cached);
33077 assert_eq!(limit, 5);
33078 }
33079 _ => panic!("expected Impact command"),
33080 }
33081 }
33082
33083 #[test]
33084 fn cli_parses_conflict_matrix_command() {
33085 let cli = parse_cli([
33086 "tsift",
33087 "conflict-matrix",
33088 "--path",
33089 "tasks/software/tsift.md",
33090 "--depth",
33091 "4",
33092 "--limit",
33093 "12",
33094 "--impact-limit",
33095 "6",
33096 "--json",
33097 "pwcm",
33098 "#g6kf",
33099 ]);
33100 match cli.command {
33101 Some(Commands::ConflictMatrix {
33102 targets,
33103 path,
33104 depth,
33105 limit,
33106 impact_limit,
33107 json,
33108 ..
33109 }) => {
33110 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33111 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33112 assert_eq!(depth, 4);
33113 assert_eq!(limit, 12);
33114 assert_eq!(impact_limit, 6);
33115 assert!(json);
33116 }
33117 _ => panic!("expected ConflictMatrix command"),
33118 }
33119 }
33120
33121 #[test]
33122 fn cli_parses_dispatch_trace_command() {
33123 let cli = parse_cli([
33124 "tsift",
33125 "dispatch-trace",
33126 "--path",
33127 "tasks/software/tsift.md",
33128 "--format",
33129 "html",
33130 "--depth",
33131 "4",
33132 "pwcm",
33133 "#g6kf",
33134 ]);
33135 match cli.command {
33136 Some(Commands::DispatchTrace {
33137 targets,
33138 path,
33139 format,
33140 depth,
33141 ..
33142 }) => {
33143 assert_eq!(targets, vec!["pwcm".to_string(), "#g6kf".to_string()]);
33144 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33145 assert_eq!(format, DispatchTraceFormat::Html);
33146 assert_eq!(depth, 4);
33147 }
33148 _ => panic!("expected DispatchTrace command"),
33149 }
33150 }
33151
33152 #[test]
33153 fn cli_parses_dependency_dag_command() {
33154 let cli = parse_cli([
33155 "tsift",
33156 "dependency-dag",
33157 "--path",
33158 "tasks/software/tsift.md",
33159 "--depth",
33160 "5",
33161 "--limit",
33162 "20",
33163 "--json",
33164 "alpha",
33165 "#beta",
33166 ]);
33167 match cli.command {
33168 Some(Commands::DependencyDag {
33169 targets,
33170 path,
33171 depth,
33172 limit,
33173 json,
33174 ..
33175 }) => {
33176 assert_eq!(targets, vec!["alpha".to_string(), "#beta".to_string()]);
33177 assert_eq!(path, PathBuf::from("tasks/software/tsift.md"));
33178 assert_eq!(depth, 5);
33179 assert_eq!(limit, 20);
33180 assert!(json);
33181 }
33182 _ => panic!("expected DependencyDag command"),
33183 }
33184 }
33185
33186 #[test]
33187 fn relativize_strips_root_prefix() {
33188 let root = std::path::Path::new("/home/user/project");
33189 assert_eq!(
33190 relativize("/home/user/project/src/main.rs", root),
33191 "src/main.rs"
33192 );
33193 }
33194
33195 #[test]
33196 fn relativize_leaves_non_matching_path() {
33197 let root = std::path::Path::new("/home/user/project");
33198 assert_eq!(
33199 relativize("/other/path/file.rs", root),
33200 "/other/path/file.rs"
33201 );
33202 }
33203
33204 #[test]
33205 fn relativize_leaves_already_relative() {
33206 let root = std::path::Path::new("/home/user/project");
33207 assert_eq!(relativize("src/main.rs", root), "src/main.rs");
33208 }
33209
33210 #[test]
33211 fn relativize_pathbuf_strips_prefix() {
33212 let root = std::path::Path::new("/home/user/project");
33213 let path = std::path::Path::new("/home/user/project/src/lib.rs");
33214 assert_eq!(relativize_pathbuf(path, root), PathBuf::from("src/lib.rs"));
33215 }
33216
33217 #[test]
33218 fn relativize_edges_strips_caller_file() {
33219 let root = std::path::Path::new("/tmp/proj");
33220 let mut edges = vec![index::StoredEdge {
33221 caller_file: "/tmp/proj/src/main.rs".to_string(),
33222 caller_name: "main".to_string(),
33223 caller_line: 1,
33224 callee_name: "helper".to_string(),
33225 call_site_line: 5,
33226 tagpath_handle: None,
33227 }];
33228 relativize_edges(&mut edges, root);
33229 assert_eq!(edges[0].caller_file, "src/main.rs");
33230 }
33231
33232 #[test]
33233 fn relativize_json_paths_strips_known_keys() {
33234 let root = std::path::Path::new("/tmp/proj");
33235 let mut val = serde_json::json!({
33236 "file": "/tmp/proj/src/main.rs",
33237 "path": "/tmp/proj/test.rs",
33238 "name": "/tmp/proj/not-a-path",
33239 "hits": [{"path": "/tmp/proj/nested.rs", "score": 1.0}]
33240 });
33241 relativize_json_paths(&mut val, root);
33242 assert_eq!(val["file"], "src/main.rs");
33243 assert_eq!(val["path"], "test.rs");
33244 assert_eq!(val["name"], "/tmp/proj/not-a-path");
33245 assert_eq!(val["hits"][0]["path"], "nested.rs");
33246 }
33247
33248 #[test]
33251 fn cli_graph_accepts_limit_flag() {
33252 let cli = parse_cli(["tsift", "graph", "main", "--limit", "5"]);
33253 match cli.command {
33254 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 5),
33255 _ => panic!("expected Graph command"),
33256 }
33257 }
33258
33259 #[test]
33260 fn cli_graph_default_limit_is_20() {
33261 let cli = parse_cli(["tsift", "graph", "main"]);
33262 match cli.command {
33263 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 20),
33264 _ => panic!("expected Graph command"),
33265 }
33266 }
33267
33268 #[test]
33269 fn cli_communities_accepts_limit_flag() {
33270 let cli = parse_cli(["tsift", "communities", "--limit", "3"]);
33271 match cli.command {
33272 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 3),
33273 _ => panic!("expected Communities command"),
33274 }
33275 }
33276
33277 #[test]
33278 fn cli_communities_default_limit_is_10() {
33279 let cli = parse_cli(["tsift", "communities"]);
33280 match cli.command {
33281 Some(Commands::Communities { limit, .. }) => assert_eq!(limit, 10),
33282 _ => panic!("expected Communities command"),
33283 }
33284 }
33285
33286 #[test]
33287 fn cli_explain_accepts_limit_flag() {
33288 let cli = parse_cli(["tsift", "explain", "main", "--limit", "7"]);
33289 match cli.command {
33290 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 7),
33291 _ => panic!("expected Explain command"),
33292 }
33293 }
33294
33295 #[test]
33296 fn cli_explain_default_limit_is_15() {
33297 let cli = parse_cli(["tsift", "explain", "main"]);
33298 match cli.command {
33299 Some(Commands::Explain { limit, .. }) => assert_eq!(limit, 15),
33300 _ => panic!("expected Explain command"),
33301 }
33302 }
33303
33304 #[test]
33305 fn cli_limit_zero_means_unlimited() {
33306 let cli = parse_cli(["tsift", "graph", "main", "--limit", "0"]);
33307 match cli.command {
33308 Some(Commands::Graph { limit, .. }) => assert_eq!(limit, 0),
33309 _ => panic!("expected Graph command"),
33310 }
33311 }
33312
33313 #[test]
33314 fn graph_cmd_limit_runs_ok() {
33315 let dir = setup_graph_index();
33316 let result = cmd_graph(
33317 "main",
33318 dir.path(),
33319 false,
33320 false,
33321 None,
33322 1,
33323 false,
33324 false,
33325 false,
33326 false,
33327 false,
33328 false,
33329 false,
33330 TagpathSearchOpts::default(),
33331 );
33332 assert!(result.is_ok());
33333 }
33334
33335 #[test]
33336 fn graph_cmd_unlimited_runs_ok() {
33337 let dir = setup_graph_index();
33338 let result = cmd_graph(
33339 "main",
33340 dir.path(),
33341 false,
33342 false,
33343 None,
33344 0,
33345 false,
33346 false,
33347 false,
33348 false,
33349 false,
33350 false,
33351 false,
33352 TagpathSearchOpts::default(),
33353 );
33354 assert!(result.is_ok());
33355 }
33356
33357 #[test]
33358 fn graph_cmd_tabular_runs_ok() {
33359 let dir = setup_graph_index();
33360 let result = cmd_graph(
33361 "main",
33362 dir.path(),
33363 false,
33364 false,
33365 None,
33366 20,
33367 false,
33368 false,
33369 false,
33370 false,
33371 false,
33372 true,
33373 false,
33374 TagpathSearchOpts::default(),
33375 );
33376 assert!(result.is_ok());
33377 }
33378
33379 #[test]
33380 fn communities_cmd_tabular_runs_ok() {
33381 let dir = setup_graph_index();
33382 let result = cmd_communities(
33383 dir.path(),
33384 None,
33385 1,
33386 10,
33387 false,
33388 false,
33389 false,
33390 false,
33391 true,
33392 false,
33393 TagpathSearchOpts::default(),
33394 );
33395 assert!(result.is_ok());
33396 }
33397
33398 #[test]
33399 fn explain_cmd_tabular_runs_ok() {
33400 let dir = setup_graph_index();
33401 let result = cmd_explain(
33402 "main",
33403 dir.path(),
33404 None,
33405 15,
33406 false,
33407 false,
33408 false,
33409 false,
33410 false,
33411 true,
33412 false,
33413 false,
33414 );
33415 assert!(result.is_ok());
33416 }
33417
33418 #[test]
33419 fn traversal_excludes_agent_doc_runtime_paths_from_source_watermark() {
33420 let cases = [
33425 ".agent-doc",
33426 ".agent-doc/snapshots/abc.md",
33427 ".agent-doc/baselines/abc.md",
33428 ".agent-doc/archives/2026.md",
33429 ".agent-doc/runtime/run.jsonl",
33430 "src/foo/.agent-doc",
33431 "src/foo/.agent-doc/snapshots/x.md",
33432 "./.agent-doc/snapshots/x.md",
33433 ];
33434 for path in cases {
33435 assert!(
33436 traversal_relative_path_is_generated_artifact(path),
33437 "expected `{path}` to be excluded from source watermark"
33438 );
33439 }
33440 for path in [
33442 "src/main.rs",
33443 "tests/perf_gate.rs",
33444 "fixtures/x.json",
33445 "agent-doc/src/lib.rs", "src/.agent-doc-helper.rs",
33447 ] {
33448 assert!(
33449 !traversal_relative_path_is_generated_artifact(path),
33450 "expected `{path}` to be included in source watermark"
33451 );
33452 }
33453 }
33454
33455 #[test]
33456 fn traversal_excludes_tsift_and_target_runtime_paths_from_source_watermark() {
33457 let cases = [
33465 ".tsift",
33466 ".tsift/index.db",
33467 ".tsift/indexes/foo/index.db",
33468 ".tsift/conflict-matrix-cache/inputs/abc.json",
33469 ".tsift/summaries.db",
33470 "src/foo/.tsift",
33471 "src/foo/.tsift/graph.db",
33472 "./.tsift/index.db",
33473 "target",
33474 "target/debug/build/x",
33475 "target/release/tsift",
33476 "src/foo/target/debug/x",
33477 "./target/release/x",
33478 ];
33479 for path in cases {
33480 assert!(
33481 traversal_relative_path_is_generated_artifact(path),
33482 "expected `{path}` to be excluded from source watermark"
33483 );
33484 }
33485 for path in [
33487 "src/ctx-core-dev/lib/a__target/CHANGELOG.md",
33488 "src/ctx-core-dev/lib/a__target/A__Target/index.d.ts",
33489 "src/tsift-extras/lib.rs",
33490 "tsift/README.md",
33491 "src/targeting.rs",
33492 "src/.tsiftrc",
33493 "src/agent-doc-helper.rs",
33494 ] {
33495 assert!(
33496 !traversal_relative_path_is_generated_artifact(path),
33497 "expected `{path}` to be included in source watermark"
33498 );
33499 }
33500 }
33501
33502 #[test]
33503 fn traversal_source_watermark_is_stable_across_invocations_on_quiescent_root() {
33504 let dir = tempfile::tempdir().unwrap();
33513 let root = dir.path();
33514 std::fs::create_dir_all(root.join("src")).unwrap();
33515 std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
33516 let hint = root.join("README.md");
33517 std::fs::write(&hint, "# stable\n").unwrap();
33518 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33520 std::fs::write(root.join(".tsift/index.db"), b"placeholder").unwrap();
33521 std::fs::create_dir_all(root.join("target/debug")).unwrap();
33522 std::fs::write(root.join("target/debug/marker"), b"placeholder").unwrap();
33523
33524 let first = traversal_source_watermark(root, &hint, None, true)
33525 .expect("first watermark call must succeed")
33526 .expect("first watermark must produce a hash for hinted markdown");
33527 let second = traversal_source_watermark(root, &hint, None, true)
33528 .expect("second watermark call must succeed")
33529 .expect("second watermark must produce a hash for hinted markdown");
33530 assert_eq!(
33531 first, second,
33532 "watermark must be identical across back-to-back invocations on a quiescent root"
33533 );
33534
33535 std::fs::write(root.join(".tsift/index.db"), b"changed").unwrap();
33537 std::fs::write(root.join("target/debug/marker"), b"changed").unwrap();
33538 let third = traversal_source_watermark(root, &hint, None, true)
33539 .expect("third watermark call must succeed")
33540 .expect("third watermark must produce a hash for hinted markdown");
33541 assert_eq!(
33542 first, third,
33543 "watermark must ignore mutations under .tsift/ and target/"
33544 );
33545
33546 std::thread::sleep(std::time::Duration::from_millis(20));
33551 std::fs::write(&hint, "# stable edited with longer content\n").unwrap();
33552 let fourth = traversal_source_watermark(root, &hint, None, true)
33553 .expect("fourth watermark call must succeed")
33554 .expect("fourth watermark must produce a hash for hinted markdown");
33555 assert_ne!(
33556 first, fourth,
33557 "watermark must invalidate when the hinted markdown file changes"
33558 );
33559 }
33560
33561 #[test]
33562 fn traversal_source_watermark_uses_summary_rows_not_summaries_db_metadata() {
33563 let dir = tempfile::tempdir().unwrap();
33567 let root = dir.path();
33568 std::fs::write(root.join("README.md"), "# stable\n").unwrap();
33569 let summaries_db_path = root.join(".tsift/summaries.db");
33570 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33571 let mut summary = summarize::Summary {
33572 id: 0,
33573 symbol_name: "main".to_string(),
33574 file_path: "src/main.rs".to_string(),
33575 content_hash: "hash-main".to_string(),
33576 summary: "main wires the CLI".to_string(),
33577 entities: Some(vec![summarize::Entity {
33578 name: "Cli".to_string(),
33579 kind: "type".to_string(),
33580 description: "Command-line interface".to_string(),
33581 }]),
33582 relationships: None,
33583 concept_labels: Some(vec!["cli".to_string()]),
33584 extracted_at: "1700000000".to_string(),
33585 model: "test-model".to_string(),
33586 tokens_input: Some(10),
33587 tokens_output: Some(5),
33588 };
33589 summary_db.insert(&summary).unwrap();
33590 drop(summary_db);
33591
33592 let hint = root.join("README.md");
33593 let first = traversal_source_watermark(root, &hint, None, true)
33594 .expect("first watermark call must succeed")
33595 .expect("first watermark must produce a hash");
33596
33597 std::thread::sleep(std::time::Duration::from_millis(20));
33598 let conn = Connection::open(&summaries_db_path).unwrap();
33599 conn.pragma_update(None, "user_version", 1).unwrap();
33600 conn.pragma_update(None, "user_version", 0).unwrap();
33601 drop(conn);
33602
33603 let second = traversal_source_watermark(root, &hint, None, true)
33604 .expect("second watermark call must succeed")
33605 .expect("second watermark must produce a hash");
33606 assert_eq!(
33607 first, second,
33608 "metadata-only summaries.db churn must not invalidate the source watermark"
33609 );
33610
33611 summary.entities = Some(vec![summarize::Entity {
33612 name: "GraphCache".to_string(),
33613 kind: "type".to_string(),
33614 description: "Stable full-projection cache input".to_string(),
33615 }]);
33616 let summary_db = summarize::SummaryDb::open(&summaries_db_path).unwrap();
33617 summary_db.delete_by_file("src/main.rs").unwrap();
33618 summary_db.insert(&summary).unwrap();
33619 drop(summary_db);
33620
33621 let third = traversal_source_watermark(root, &hint, None, true)
33622 .expect("third watermark call must succeed")
33623 .expect("third watermark must produce a hash");
33624 assert_ne!(
33625 first, third,
33626 "semantic summary row changes must invalidate the source watermark"
33627 );
33628 }
33629
33630 #[test]
33631 fn full_projection_source_watermark_ignores_source_mtime_when_index_rows_unchanged() {
33632 let dir = tempfile::tempdir().unwrap();
33636 let root = dir.path();
33637 std::fs::create_dir_all(root.join("src")).unwrap();
33638 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33639 let source = root.join("src/lib.rs");
33640 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33641 std::fs::write(&source, source_body).unwrap();
33642 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33643 db.rebuild(root).unwrap();
33644 drop(db);
33645
33646 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33647 .unwrap()
33648 .value;
33649 std::thread::sleep(std::time::Duration::from_millis(20));
33650 std::fs::write(&source, source_body).unwrap();
33651 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33652 db.apply_changes(root).unwrap();
33653 drop(db);
33654
33655 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33656 .unwrap()
33657 .value;
33658 assert_eq!(
33659 first, second,
33660 "mtime-only source index churn must not invalidate the full-projection cache"
33661 );
33662 }
33663
33664 #[test]
33665 fn full_projection_source_watermark_ignores_session_markdown_churn() {
33666 let dir = tempfile::tempdir().unwrap();
33671 let root = dir.path();
33672 std::fs::create_dir_all(root.join("src")).unwrap();
33673 std::fs::create_dir_all(root.join("tasks/software")).unwrap();
33674 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33675 std::fs::write(root.join("src/lib.rs"), "pub fn alpha() {}\n").unwrap();
33676 let task_doc = root.join("tasks/software/tsift.md");
33677 std::fs::write(
33678 &task_doc,
33679 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Initial item\n",
33680 )
33681 .unwrap();
33682 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33683 db.rebuild(root).unwrap();
33684 drop(db);
33685
33686 let first = graph_db_backend_eval_full_projection_source_watermark(root, None)
33687 .unwrap()
33688 .value;
33689 std::fs::write(
33690 &task_doc,
33691 "---\nagent_doc_session: tsift-v0.1\n---\n\n## Backlog\n\n- [ ] [#one] Edited item\n",
33692 )
33693 .unwrap();
33694 let second = graph_db_backend_eval_full_projection_source_watermark(root, None)
33695 .unwrap()
33696 .value;
33697 assert_eq!(
33698 first, second,
33699 "session markdown churn must not invalidate the full-projection code/summary cache"
33700 );
33701 }
33702
33703 #[test]
33704 fn full_projection_cache_hit_skips_provider_neutral_rebuild_after_mtime_churn() {
33705 let dir = tempfile::tempdir().unwrap();
33709 let root = dir.path();
33710 std::fs::create_dir_all(root.join("src")).unwrap();
33711 std::fs::create_dir_all(root.join(".tsift")).unwrap();
33712 let source = root.join("src/lib.rs");
33713 let source_body = "pub fn alpha() { beta(); }\npub fn beta() {}\n";
33714 std::fs::write(&source, source_body).unwrap();
33715 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33716 db.rebuild(root).unwrap();
33717 drop(db);
33718
33719 let (_projection, _warnings, _phases, first_stats) =
33720 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33721 assert!(
33722 !first_stats.hit,
33723 "the first full-projection run should populate the cache"
33724 );
33725
33726 std::thread::sleep(std::time::Duration::from_millis(20));
33727 std::fs::write(&source, source_body).unwrap();
33728 let db = index::IndexDb::open(&root.join(".tsift/index.db")).unwrap();
33729 db.apply_changes(root).unwrap();
33730 drop(db);
33731
33732 let (_projection, _warnings, phases, second_stats) =
33733 graph_db_backend_eval_full_projection_with_profile(root, None).unwrap();
33734 assert!(second_stats.hit, "mtime-only churn should still cache-hit");
33735 let source_graph_build = phases
33736 .iter()
33737 .find(|phase| phase.name == "full_projection.source_graph_build")
33738 .expect("cache hit must report source_graph_build");
33739 let projection_rows = phases
33740 .iter()
33741 .find(|phase| phase.name == "full_projection.projection_rows")
33742 .expect("cache hit must report projection_rows");
33743 assert_eq!(source_graph_build.duration_micros, 0);
33744 assert_eq!(projection_rows.duration_micros, 0);
33745 }
33746
33747 #[test]
33748 fn build_token_capped_preview_within_cap() {
33749 let lines: Vec<&str> = vec!["fn foo() {", " 1 + 2", "}"];
33750 let capped = build_token_capped_preview(&lines, 1, 3, 160, 1000);
33751 assert!(!capped.was_capped);
33752 assert_eq!(capped.preview.len(), 3);
33753 assert_eq!(capped.capped_end, 3);
33754 }
33755
33756 #[test]
33757 fn build_token_capped_preview_truncates_long_body() {
33758 let owned: Vec<String> = (0..200)
33759 .map(|i| format!(" let line_{i} = {i};"))
33760 .collect();
33761 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33762 let capped = build_token_capped_preview(&lines, 1, 200, 160, 100);
33763 assert!(capped.was_capped);
33764 assert!(capped.preview.len() < 200);
33765 assert!(capped.capped_end < 200);
33766 assert!(!capped.preview.is_empty());
33767 }
33768
33769 #[test]
33770 fn build_token_capped_preview_respects_start_offset() {
33771 let owned: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
33772 let lines: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
33773 let capped = build_token_capped_preview(&lines, 50, 100, 160, 50);
33774 assert!(capped.was_capped);
33775 assert!(capped.capped_end >= 50);
33776 assert!(capped.capped_end < 100);
33777 assert_eq!(capped.preview[0].line, 50);
33778 }
33779
33780 #[test]
33781 fn response_budget_body_token_cap_defaults() {
33782 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Normal), true);
33783 assert_eq!(budget.body_token_cap(), 1500);
33784
33785 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Small), true);
33786 assert_eq!(budget.body_token_cap(), 500);
33787
33788 let budget = ResponseBudget::from_cli(None, None, Some(ResponseBudgetPreset::Deep), true);
33789 assert_eq!(budget.body_token_cap(), 3000);
33790 }
33791
33792 #[test]
33793 fn build_token_capped_preview_empty_input() {
33794 let lines: Vec<&str> = vec![];
33795 let capped = build_token_capped_preview(&lines, 1, 0, 160, 1000);
33796 assert!(!capped.was_capped);
33797 assert!(capped.preview.is_empty());
33798 }
33799
33800 #[test]
33801 fn build_token_capped_preview_single_long_line_fits() {
33802 let lines: Vec<&str> = vec!["short"];
33803 let capped = build_token_capped_preview(&lines, 1, 1, 160, 100);
33804 assert!(!capped.was_capped);
33805 assert_eq!(capped.preview.len(), 1);
33806 assert_eq!(capped.capped_end, 1);
33807 }
33808
33809 #[test]
33810 fn edge_index_replaces_from_id_to_id_with_positions() {
33811 let input = serde_json::json!({
33812 "nodes": [
33813 {"id": "symbol:src/lib.rs:foo"},
33814 {"id": "symbol:src/lib.rs:bar"},
33815 {"id": "symbol:src/lib.rs:baz"}
33816 ],
33817 "edges": [
33818 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:src/lib.rs:bar", "k": "calls"},
33819 {"from_id": "symbol:src/lib.rs:bar", "to_id": "symbol:src/lib.rs:baz", "k": "calls"}
33820 ]
33821 });
33822 let result = edge_index_transform(input);
33823 let edges = result.get("edges").unwrap().as_array().unwrap();
33824 assert_eq!(edges.len(), 2);
33825 assert_eq!(edges[0]["from"], 0);
33826 assert_eq!(edges[0]["to"], 1);
33827 assert_eq!(edges[1]["from"], 1);
33828 assert_eq!(edges[1]["to"], 2);
33829 assert!(edges[0].get("from_id").is_none());
33830 assert!(edges[0].get("to_id").is_none());
33831 }
33832
33833 #[test]
33834 fn edge_index_preserves_unresolved_ids_as_strings() {
33835 let input = serde_json::json!({
33836 "nodes": [{"id": "symbol:src/lib.rs:foo"}],
33837 "edges": [
33838 {"from_id": "symbol:src/lib.rs:foo", "to_id": "symbol:other.rs:missing", "k": "ref"}
33839 ]
33840 });
33841 let result = edge_index_transform(input);
33842 let edge = &result["edges"][0];
33843 assert_eq!(edge["from"], 0);
33844 assert_eq!(edge["to_id"], "symbol:other.rs:missing");
33845 }
33846
33847 #[test]
33848 fn edge_index_noop_without_nodes_and_edges() {
33849 let input = serde_json::json!({"report": {"entries": [{"from_id": "a", "to_id": "b"}]}});
33850 let result = edge_index_transform(input);
33851 assert_eq!(result["report"]["entries"][0]["from_id"], "a");
33852 }
33853}
33854
33855#[derive(Serialize)]
33858struct TableInfo {
33859 name: String,
33860 columns: Vec<ColumnInfo>,
33861 row_count: i64,
33862}
33863
33864#[derive(Serialize)]
33865struct ColumnInfo {
33866 name: String,
33867 #[serde(rename = "type")]
33868 col_type: String,
33869 notnull: bool,
33870 pk: bool,
33871 #[serde(skip_serializing_if = "Option::is_none")]
33872 default_value: Option<String>,
33873}
33874
33875pub(crate) fn open_db(path: &std::path::Path) -> Result<Connection> {
33877 let conn = Connection::open_with_flags(
33878 path,
33879 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
33880 )
33881 .with_context(|| format!("opening database: {}", path.display()))?;
33882 Ok(conn)
33883}
33884
33885pub(crate) fn schema_overview(conn: &Connection) -> Result<Vec<TableInfo>> {
33887 let mut stmt = conn.prepare(
33888 "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
33889 )?;
33890 let table_names: Vec<String> = stmt
33891 .query_map([], |row| row.get(0))?
33892 .collect::<std::result::Result<Vec<_>, _>>()?;
33893
33894 let mut tables = Vec::new();
33895 for tbl in table_names {
33896 let columns = table_columns(conn, &tbl)?;
33897 let row_count: i64 =
33898 conn.query_row(&format!("SELECT COUNT(*) FROM \"{}\"", tbl), [], |row| {
33899 row.get(0)
33900 })?;
33901 tables.push(TableInfo {
33902 name: tbl,
33903 columns,
33904 row_count,
33905 });
33906 }
33907 Ok(tables)
33908}
33909
33910pub(crate) fn table_columns(conn: &Connection, table: &str) -> Result<Vec<ColumnInfo>> {
33912 let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?;
33913 let cols = stmt
33914 .query_map([], |row| {
33915 Ok(ColumnInfo {
33916 name: row.get(1)?,
33917 col_type: row.get::<_, String>(2).unwrap_or_default(),
33918 notnull: row.get::<_, bool>(3).unwrap_or(false),
33919 pk: row.get::<_, i32>(5).unwrap_or(0) > 0,
33920 default_value: row.get(4)?,
33921 })
33922 })?
33923 .collect::<std::result::Result<Vec<_>, _>>()?;
33924 Ok(cols)
33925}
33926
33927pub(crate) fn execute_query(
33929 conn: &Connection,
33930 sql: &str,
33931) -> Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
33932 let mut stmt = conn.prepare(sql).context("preparing SQL query")?;
33933 let col_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
33934 let col_count = col_names.len();
33935
33936 let mut rows = Vec::new();
33937 let mut query_rows = stmt.query([])?;
33938 while let Some(row) = query_rows.next()? {
33939 let mut vals = Vec::with_capacity(col_count);
33940 for i in 0..col_count {
33941 let val = match row.get_ref(i)? {
33942 rusqlite::types::ValueRef::Null => serde_json::Value::Null,
33943 rusqlite::types::ValueRef::Integer(n) => serde_json::json!(n),
33944 rusqlite::types::ValueRef::Real(f) => serde_json::json!(f),
33945 rusqlite::types::ValueRef::Text(s) => {
33946 serde_json::Value::String(String::from_utf8_lossy(s).into_owned())
33947 }
33948 rusqlite::types::ValueRef::Blob(b) => {
33949 serde_json::Value::String(format!("<blob {} bytes>", b.len()))
33950 }
33951 };
33952 vals.push(val);
33953 }
33954 rows.push(vals);
33955 }
33956 Ok((col_names, rows))
33957}
33958
33959#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33960enum DigestRunnerKind {
33961 Test,
33962 Log,
33963}
33964
33965impl DigestRunnerKind {
33966 fn parse(raw: &str) -> Result<Self> {
33967 match raw.trim().to_ascii_lowercase().as_str() {
33968 "test" => Ok(Self::Test),
33969 "log" => Ok(Self::Log),
33970 other => bail!("unsupported digest runner kind `{other}`; expected test or log"),
33971 }
33972 }
33973
33974 fn as_str(self) -> &'static str {
33975 match self {
33976 Self::Test => "test",
33977 Self::Log => "log",
33978 }
33979 }
33980}
33981
33982pub(crate) fn shell_split(s: &str) -> Vec<&str> {
33984 let mut parts = Vec::new();
33985 let mut i = 0;
33986 let bytes = s.as_bytes();
33987 while i < bytes.len() {
33988 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
33990 i += 1;
33991 }
33992 if i >= bytes.len() {
33993 break;
33994 }
33995 let start = i;
33996 if bytes[i] == b'"' || bytes[i] == b'\'' {
33997 let quote = bytes[i];
33998 i += 1;
33999 while i < bytes.len() && bytes[i] != quote {
34000 i += 1;
34001 }
34002 if i < bytes.len() {
34003 i += 1; }
34005 } else {
34006 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
34007 i += 1;
34008 }
34009 }
34010 parts.push(&s[start..i]);
34011 }
34012 parts
34013}
34014
34015pub(crate) fn shell_quote(s: &str) -> String {
34017 let unquoted =
34019 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
34020 &s[1..s.len() - 1]
34021 } else {
34022 s
34023 };
34024
34025 if unquoted
34026 .chars()
34027 .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
34028 {
34029 format!("\"{}\"", unquoted)
34030 } else {
34031 format!(
34032 "\"{}\"",
34033 unquoted.replace('\\', "\\\\").replace('"', "\\\"")
34034 )
34035 }
34036}
34037
34038fn empty_search_coverage() -> sift::SearchCoverageSnapshot {
34039 sift::SearchCoverageSnapshot {
34040 mode: sift::SearchCoverageMode::Sealed,
34041 total_sector_count: 0,
34042 mounted_sector_count: 0,
34043 reused_sector_count: 0,
34044 dirty_sector_count: 0,
34045 completed_dirty_sector_count: 0,
34046 rebuilding_sector_count: 0,
34047 resumed_sector_count: 0,
34048 active_rebuild: None,
34049 }
34050}
34051
34052fn aggregate_search_coverage(responses: &[sift::SearchResponse]) -> sift::SearchCoverageSnapshot {
34053 let total_sector_count = responses
34054 .iter()
34055 .map(|response| response.coverage.total_sector_count)
34056 .sum();
34057 let mounted_sector_count = responses
34058 .iter()
34059 .map(|response| response.coverage.mounted_sector_count)
34060 .sum();
34061 let reused_sector_count = responses
34062 .iter()
34063 .map(|response| response.coverage.reused_sector_count)
34064 .sum();
34065 let dirty_sector_count = responses
34066 .iter()
34067 .map(|response| response.coverage.dirty_sector_count)
34068 .sum();
34069 let completed_dirty_sector_count = responses
34070 .iter()
34071 .map(|response| response.coverage.completed_dirty_sector_count)
34072 .sum();
34073 let rebuilding_sector_count = responses
34074 .iter()
34075 .map(|response| response.coverage.rebuilding_sector_count)
34076 .sum();
34077 let resumed_sector_count = responses
34078 .iter()
34079 .map(|response| response.coverage.resumed_sector_count)
34080 .sum();
34081
34082 let mode = if dirty_sector_count == 0 && rebuilding_sector_count == 0 {
34083 sift::SearchCoverageMode::Sealed
34084 } else if completed_dirty_sector_count > 0
34085 || rebuilding_sector_count > 0
34086 || resumed_sector_count > 0
34087 {
34088 sift::SearchCoverageMode::Converging
34089 } else {
34090 sift::SearchCoverageMode::Frontier
34091 };
34092
34093 sift::SearchCoverageSnapshot {
34094 mode,
34095 total_sector_count,
34096 mounted_sector_count,
34097 reused_sector_count,
34098 dirty_sector_count,
34099 completed_dirty_sector_count,
34100 rebuilding_sector_count,
34101 resumed_sector_count,
34102 active_rebuild: responses
34103 .iter()
34104 .find_map(|response| response.coverage.active_rebuild.clone()),
34105 }
34106}
34107
34108fn empty_search_response(root: &Path, strategy: &str) -> sift::SearchResponse {
34109 sift::SearchResponse {
34110 strategy: strategy.to_string(),
34111 root: root.display().to_string(),
34112 indexed_artifacts: 0,
34113 skipped_artifacts: 0,
34114 coverage: empty_search_coverage(),
34115 hits: Vec::new(),
34116 }
34117}
34118
34119fn absolutize_search_hit_paths(response: &mut sift::SearchResponse, search_root: &Path) {
34120 for hit in &mut response.hits {
34121 let path = Path::new(&hit.path);
34122 if path.is_relative() {
34123 hit.path = search_root.join(path).display().to_string();
34124 }
34125 }
34126}
34127
34128fn merge_search_responses(
34129 root: &Path,
34130 strategy: &str,
34131 limit: usize,
34132 responses: Vec<sift::SearchResponse>,
34133) -> sift::SearchResponse {
34134 let indexed_artifacts = responses
34135 .iter()
34136 .map(|response| response.indexed_artifacts)
34137 .sum();
34138 let skipped_artifacts = responses
34139 .iter()
34140 .map(|response| response.skipped_artifacts)
34141 .sum();
34142 let coverage = if responses.is_empty() {
34143 empty_search_coverage()
34144 } else {
34145 aggregate_search_coverage(&responses)
34146 };
34147 let mut hits: Vec<sift::SearchHit> = responses
34148 .into_iter()
34149 .flat_map(|response| response.hits)
34150 .collect();
34151 hits.sort_by(|left, right| {
34152 right
34153 .score
34154 .partial_cmp(&left.score)
34155 .unwrap_or(Ordering::Equal)
34156 .then_with(|| left.path.cmp(&right.path))
34157 .then_with(|| left.location.cmp(&right.location))
34158 });
34159 hits.truncate(limit);
34160 for (rank, hit) in hits.iter_mut().enumerate() {
34161 hit.rank = rank + 1;
34162 }
34163
34164 sift::SearchResponse {
34165 strategy: strategy.to_string(),
34166 root: root.display().to_string(),
34167 indexed_artifacts,
34168 skipped_artifacts,
34169 coverage,
34170 hits,
34171 }
34172}
34173
34174pub(crate) fn federated_sift_search(
34175 root: &Path,
34176 cache_dir: &Path,
34177 query: &str,
34178 limit: usize,
34179 timeout_secs: u64,
34180 strategy: &str,
34181 fts_index_fresh: Option<bool>,
34182) -> Result<sift::SearchResponse> {
34183 let targets = resolve_search_index_targets(root, root, None, true)?;
34184 if targets.is_empty() {
34185 if config::Config::submodule_dirs(root)?.is_empty() {
34186 return run_search_with_timeout(
34187 root,
34188 cache_dir,
34189 query,
34190 limit,
34191 timeout_secs,
34192 strategy,
34193 &[],
34194 fts_index_fresh,
34195 );
34196 }
34197 return Ok(empty_search_response(root, strategy));
34198 }
34199
34200 let mut responses = Vec::with_capacity(targets.len());
34201 for target in &targets {
34202 let mut response = run_search_with_timeout(
34203 &target.source_root,
34204 cache_dir,
34205 query,
34206 limit,
34207 timeout_secs,
34208 strategy,
34209 std::slice::from_ref(target),
34210 fts_index_fresh,
34211 )?;
34212 absolutize_search_hit_paths(&mut response, &target.source_root);
34213 response.root = root.display().to_string();
34214 responses.push(response);
34215 }
34216
34217 Ok(merge_search_responses(root, strategy, limit, responses))
34218}
34219
34220pub(crate) fn federated_symbol_search(
34228 root: &std::path::Path,
34229 query: &str,
34230 limit: usize,
34231 tagpath_opts: &TagpathSearchOpts,
34232) -> Result<(Vec<index::SymbolHit>, TagpathAnnotationDiagnostic)> {
34233 let cfg = config::Config::load(root)?;
34234 let submodules = config::Config::submodule_dirs(root)?;
34235 let mut all_hits: Vec<index::SymbolHit> = Vec::new();
34236 let mut combined = TagpathAnnotationDiagnostic::default();
34237 for scope in &submodules {
34238 if !cfg.federation_for_scope(scope) {
34239 continue;
34240 }
34241 let db_path = cfg.db_path_for(root, &scope.id);
34242 if !db_path.exists() {
34243 continue;
34244 }
34245 let db = index::IndexDb::open_read_only(&db_path)?;
34246 let mut hits = db.symbol_search(query, limit)?;
34247 let diag = annotate_hits_with_tagpath(&mut hits, &scope.source_root, tagpath_opts)?;
34248 combined.loaded |= diag.loaded;
34249 if diag.stale && !combined.stale {
34250 combined.stale = true;
34251 combined.reason = diag.reason;
34252 }
34253 all_hits.append(&mut hits);
34254 }
34255 all_hits.sort_by(|a, b| {
34256 b.score
34257 .partial_cmp(&a.score)
34258 .unwrap_or(std::cmp::Ordering::Equal)
34259 });
34260 all_hits.truncate(limit);
34261 Ok((all_hits, combined))
34262}
34263
34264#[derive(Debug, Deserialize)]
34265#[serde(tag = "type", rename_all = "lowercase")]
34266enum RipgrepJsonEvent {
34267 Match {
34268 data: RipgrepMatchData,
34269 },
34270 #[serde(other)]
34271 Other,
34272}
34273
34274#[derive(Debug, Deserialize)]
34275struct RipgrepMatchData {
34276 path: RipgrepTextField,
34277 lines: RipgrepTextField,
34278 line_number: Option<usize>,
34279}
34280
34281#[derive(Debug, Deserialize)]
34282struct RipgrepTextField {
34283 text: Option<String>,
34284}
34285
34286pub(crate) fn federated_exact_search(
34287 root: &Path,
34288 query: &str,
34289 limit: usize,
34290 timeout_secs: u64,
34291) -> Result<sift::SearchResponse> {
34292 let cfg = config::Config::load(root)?;
34293 let mut responses = Vec::new();
34294 for scope in config::Config::submodule_dirs(root)? {
34295 if !cfg.federation_for_scope(&scope) {
34296 continue;
34297 }
34298 let mut response =
34299 run_exact_search_with_timeout(&scope.source_root, query, limit, timeout_secs)?;
34300 absolutize_search_hit_paths(&mut response, &scope.source_root);
34301 response.root = root.display().to_string();
34302 responses.push(response);
34303 }
34304
34305 Ok(merge_search_responses(root, "exact", limit, responses))
34306}
34307
34308pub(crate) fn run_sift_search(
34309 search_path: &Path,
34310 cache_dir: &Path,
34311 query: &str,
34312 limit: usize,
34313 strategy: &str,
34314 fts_index_fresh: Option<bool>,
34323) -> Result<sift::SearchResponse> {
34324 if !fts_search_forced_off() {
34341 let db_path = search_path.join(".tsift/index.db");
34342 let use_fts = match fts_index_fresh {
34343 Some(fresh) => fresh && db_path.exists(),
34344 None => db_path.exists() && index_db_is_fresh_for_fts(&db_path, search_path),
34345 };
34346 if use_fts {
34347 return sift::fts_search(&db_path, search_path, query, limit)
34348 .context("index.db FTS5 search failed");
34349 }
34350 }
34351
34352 let engine = Sift::builder().with_cache_dir(cache_dir).build();
34353 let options = SearchOptions::default()
34354 .with_limit(limit)
34355 .with_strategy(strategy.to_string());
34356 let input = SearchInput::new(search_path, query).with_options(options);
34357 engine.search(input).context("sift search failed")
34358}
34359
34360fn index_db_is_fresh_for_fts(db_path: &Path, search_path: &Path) -> bool {
34369 match index::IndexDb::inspect_read_only(db_path, search_path, false) {
34370 Ok(inspection) => {
34371 inspection.summary.new + inspection.summary.modified + inspection.summary.deleted == 0
34372 }
34373 Err(_) => false,
34374 }
34375}
34376
34377fn fts_search_forced_off() -> bool {
34382 std::env::var("TSIFT_FTS_SEARCH")
34383 .map(|value| fts_flag_value_disabled(&value))
34384 .unwrap_or(false)
34385}
34386
34387fn fts_flag_value_disabled(value: &str) -> bool {
34390 matches!(
34391 value.trim().to_ascii_lowercase().as_str(),
34392 "0" | "false" | "no" | "off"
34393 )
34394}
34395
34396fn exact_search_timeout_message(timeout_secs: u64) -> String {
34397 format!(
34398 "tsift search timed out after {}s (strategy: exact). \
34399 Re-run with `--timeout 0` to disable the timeout or narrow `--path` / `--scope`.",
34400 timeout_secs
34401 )
34402}
34403
34404fn exact_search_command(search_path: &Path, query: &str) -> Command {
34405 let mut command = Command::new("rg");
34406 command
34407 .arg("--json")
34408 .arg("--fixed-strings")
34409 .arg("--line-number")
34410 .arg("--hidden")
34411 .arg("--")
34412 .arg(query)
34413 .arg(search_path);
34414 command
34415}
34416
34417fn exact_search_file_timestamp(path: &Path) -> sift::ArtifactFreshness {
34418 let observed_unix_secs = SystemTime::now()
34419 .duration_since(UNIX_EPOCH)
34420 .unwrap_or_default()
34421 .as_secs() as i64;
34422 let modified_unix_secs = fs::metadata(path)
34423 .ok()
34424 .and_then(|metadata| metadata.modified().ok())
34425 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
34426 .map(|duration| duration.as_secs() as i64);
34427 sift::ArtifactFreshness {
34428 observed_unix_secs,
34429 modified_unix_secs,
34430 }
34431}
34432
34433fn parse_exact_search_output(
34434 search_path: &Path,
34435 limit: usize,
34436 raw: &str,
34437) -> Result<sift::SearchResponse> {
34438 if limit == 0 {
34439 return Ok(sift::SearchResponse {
34440 strategy: "exact".to_string(),
34441 root: search_path.display().to_string(),
34442 indexed_artifacts: 0,
34443 skipped_artifacts: 0,
34444 coverage: empty_search_coverage(),
34445 hits: Vec::new(),
34446 });
34447 }
34448
34449 let mut hits = Vec::new();
34450 for line in raw.lines() {
34451 let event: RipgrepJsonEvent =
34452 serde_json::from_str(line).context("parsing ripgrep exact-search output")?;
34453 let RipgrepJsonEvent::Match { data } = event else {
34454 continue;
34455 };
34456 let Some(path_text) = data.path.text else {
34457 continue;
34458 };
34459 let Some(lines_text) = data.lines.text else {
34460 continue;
34461 };
34462 let path = PathBuf::from(path_text);
34463 let snippet = lines_text.trim_end_matches(['\r', '\n']).to_string();
34464 let rank = hits.len() + 1;
34465 hits.push(sift::SearchHit {
34466 artifact_id: format!(
34467 "exact:{}:{}:{}",
34468 path.display(),
34469 data.line_number.unwrap_or(0),
34470 rank
34471 ),
34472 artifact_kind: sift::ContextArtifactKind::File,
34473 path: path.display().to_string(),
34474 rank,
34475 score: (limit.saturating_sub(rank).saturating_add(1)) as f64,
34476 confidence: sift::ScoreConfidence::High,
34477 location: data.line_number.map(|line| format!("line {}", line)),
34478 snippet: snippet.clone(),
34479 provenance: sift::ArtifactProvenance {
34480 adapter: sift::AcquisitionAdapterKind::FileSystem,
34481 source: "ripgrep -F".to_string(),
34482 synthetic: false,
34483 },
34484 freshness: exact_search_file_timestamp(&path),
34485 budget: sift::ArtifactBudget::from_text(&snippet, 1),
34486 });
34487 if hits.len() >= limit {
34488 break;
34489 }
34490 }
34491
34492 Ok(sift::SearchResponse {
34493 strategy: "exact".to_string(),
34494 root: search_path.display().to_string(),
34495 indexed_artifacts: hits.len(),
34496 skipped_artifacts: 0,
34497 coverage: empty_search_coverage(),
34498 hits,
34499 })
34500}
34501
34502fn exact_search_response_from_process(
34503 search_path: &Path,
34504 limit: usize,
34505 status: std::process::ExitStatus,
34506 stdout: &[u8],
34507 stderr: &[u8],
34508) -> Result<sift::SearchResponse> {
34509 if !status.success() && status.code() != Some(1) {
34510 let message = String::from_utf8_lossy(stderr);
34511 let trimmed = message.trim();
34512 if trimmed.is_empty() {
34513 bail!("ripgrep exact search exited with status {}", status);
34514 }
34515 bail!("{}", trimmed);
34516 }
34517
34518 let raw = String::from_utf8(stdout.to_vec()).context("decoding ripgrep exact-search output")?;
34519 parse_exact_search_output(search_path, limit, &raw)
34520}
34521
34522fn run_exact_search(search_path: &Path, query: &str, limit: usize) -> Result<sift::SearchResponse> {
34523 let output = exact_search_command(search_path, query)
34524 .output()
34525 .context("running exact search with ripgrep")?;
34526 exact_search_response_from_process(
34527 search_path,
34528 limit,
34529 output.status,
34530 &output.stdout,
34531 &output.stderr,
34532 )
34533}
34534
34535pub(crate) fn run_exact_search_with_timeout(
34536 search_path: &Path,
34537 query: &str,
34538 limit: usize,
34539 timeout_secs: u64,
34540) -> Result<sift::SearchResponse> {
34541 if timeout_secs == 0 {
34542 return run_exact_search(search_path, query, limit);
34543 }
34544
34545 let mut child = exact_search_command(search_path, query)
34546 .stdin(Stdio::null())
34547 .stdout(Stdio::piped())
34548 .stderr(Stdio::piped())
34549 .spawn()
34550 .context("spawning timed exact search worker")?;
34551
34552 let timeout = Duration::from_secs(timeout_secs);
34553 let status = wait_for_child_exit(&mut child, timeout)
34554 .context("waiting for timed exact search worker")?;
34555 if status.is_none() {
34556 let _ = child.kill();
34557 let _ = child.wait();
34558 bail!("{}", exact_search_timeout_message(timeout_secs));
34559 }
34560
34561 let status = status.unwrap();
34562 let stdout = read_child_stdout(&mut child)?;
34563 let stderr = read_child_stderr(&mut child)?;
34564 exact_search_response_from_process(
34565 search_path,
34566 limit,
34567 status,
34568 stdout.as_bytes(),
34569 stderr.as_bytes(),
34570 )
34571}
34572
34573#[allow(clippy::too_many_arguments)]
34574pub(crate) fn run_search_with_timeout(
34575 search_path: &Path,
34576 cache_dir: &Path,
34577 query: &str,
34578 limit: usize,
34579 timeout_secs: u64,
34580 strategy: &str,
34581 search_targets: &[SearchIndexTarget],
34582 fts_index_fresh: Option<bool>,
34585) -> Result<sift::SearchResponse> {
34586 if timeout_secs == 0 {
34587 return run_sift_search(search_path, cache_dir, query, limit, strategy, fts_index_fresh);
34588 }
34589
34590 let output_path = next_search_worker_output_path();
34591 let mut command = Command::new(
34592 std::env::current_exe().context("resolving tsift executable for timed search")?,
34593 );
34594 command
34595 .arg("__search-worker")
34596 .arg("--path")
34597 .arg(search_path)
34598 .arg("--cache-dir")
34599 .arg(cache_dir)
34600 .arg("--query")
34601 .arg(query)
34602 .arg("--limit")
34603 .arg(limit.to_string())
34604 .arg("--strategy")
34605 .arg(strategy)
34606 .arg("--output")
34607 .arg(&output_path);
34608 if let Some(fresh) = fts_index_fresh {
34609 command.arg("--fts-index-fresh").arg(fresh.to_string());
34610 }
34611 let mut child = command
34612 .stdin(Stdio::null())
34613 .stdout(Stdio::null())
34614 .stderr(Stdio::piped())
34615 .spawn()
34616 .context("spawning timed sift search worker")?;
34617
34618 let timeout = Duration::from_secs(timeout_secs);
34619 let status =
34620 wait_for_child_exit(&mut child, timeout).context("waiting for timed sift search worker")?;
34621 if status.is_none() {
34622 let _ = child.kill();
34623 let _ = child.wait();
34624 let _ = fs::remove_file(&output_path);
34625 bail!(
34626 "{}",
34627 search_timeout_message(timeout_secs, strategy, search_targets)?
34628 );
34629 }
34630
34631 let status = status.unwrap();
34632 let stderr = read_child_stderr(&mut child)?;
34633 if !status.success() {
34634 let _ = fs::remove_file(&output_path);
34635 let message = stderr.trim();
34636 if message.is_empty() {
34637 bail!("sift search worker exited with status {}", status);
34638 }
34639 bail!("{}", message);
34640 }
34641
34642 let raw = fs::read_to_string(&output_path)
34643 .with_context(|| format!("reading search worker output: {}", output_path.display()))?;
34644 let _ = fs::remove_file(&output_path);
34645 serde_json::from_str(&raw).context("parsing search worker output")
34646}
34647
34648fn next_search_worker_output_path() -> PathBuf {
34649 let stamp = SystemTime::now()
34650 .duration_since(UNIX_EPOCH)
34651 .unwrap_or_default()
34652 .as_nanos();
34653 std::env::temp_dir().join(format!(
34654 "tsift-search-{}-{}.json",
34655 std::process::id(),
34656 stamp
34657 ))
34658}
34659
34660fn wait_for_child_exit(
34661 child: &mut std::process::Child,
34662 timeout: Duration,
34663) -> Result<Option<std::process::ExitStatus>> {
34664 let started = Instant::now();
34665 loop {
34666 if let Some(status) = child.try_wait()? {
34667 return Ok(Some(status));
34668 }
34669 if started.elapsed() >= timeout {
34670 return Ok(None);
34671 }
34672 let remaining = timeout.saturating_sub(started.elapsed());
34673 std::thread::sleep(remaining.min(Duration::from_millis(10)));
34674 }
34675}
34676
34677fn read_child_stderr(child: &mut std::process::Child) -> Result<String> {
34678 let mut stderr = String::new();
34679 if let Some(mut pipe) = child.stderr.take() {
34680 pipe.read_to_string(&mut stderr)
34681 .context("reading search worker stderr")?;
34682 }
34683 Ok(stderr)
34684}
34685
34686fn read_child_stdout(child: &mut std::process::Child) -> Result<String> {
34687 let mut stdout = String::new();
34688 if let Some(mut pipe) = child.stdout.take() {
34689 pipe.read_to_string(&mut stdout)
34690 .context("reading search worker stdout")?;
34691 }
34692 Ok(stdout)
34693}
34694
34695pub(crate) fn maybe_apply_search_worker_test_hooks() -> Result<()> {
34696 if let Ok(path) = std::env::var("TSIFT_TEST_SEARCH_WORKER_PID_FILE") {
34697 fs::write(&path, std::process::id().to_string())
34698 .with_context(|| format!("writing search worker pid file: {path}"))?;
34699 }
34700 if let Ok(ms) = std::env::var("TSIFT_TEST_SEARCH_WORKER_SLEEP_MS") {
34701 let delay_ms = ms
34702 .parse::<u64>()
34703 .with_context(|| format!("parsing TSIFT_TEST_SEARCH_WORKER_SLEEP_MS={ms}"))?;
34704 std::thread::sleep(Duration::from_millis(delay_ms));
34705 }
34706 Ok(())
34707}
34708
34709#[cfg(test)]
34710thread_local! {
34711 static SEARCH_POST_PRECHECK_LOCK_HOOK: RefCell<Option<SearchPostPrecheckLockHook>> = const { RefCell::new(None) };
34712}
34713
34714#[cfg(test)]
34715enum SearchPostPrecheckLockMode {
34716 RollbackJournal,
34717 Wal,
34718}
34719
34720#[cfg(test)]
34721struct SearchPostPrecheckLockHook {
34722 db_path: PathBuf,
34723 mode: SearchPostPrecheckLockMode,
34724}
34725
34726#[cfg(test)]
34727struct SearchPostPrecheckLockGuard;
34728
34729#[cfg(test)]
34730impl Drop for SearchPostPrecheckLockGuard {
34731 fn drop(&mut self) {
34732 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34733 hook.borrow_mut().take();
34734 });
34735 }
34736}
34737
34738#[cfg(test)]
34739fn install_search_post_precheck_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34740 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::RollbackJournal)
34741}
34742
34743#[cfg(test)]
34744fn install_search_post_precheck_wal_lock(db_path: PathBuf) -> SearchPostPrecheckLockGuard {
34745 install_search_post_precheck_lock_hook(db_path, SearchPostPrecheckLockMode::Wal)
34746}
34747
34748#[cfg(test)]
34749fn install_search_post_precheck_lock_hook(
34750 db_path: PathBuf,
34751 mode: SearchPostPrecheckLockMode,
34752) -> SearchPostPrecheckLockGuard {
34753 SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| {
34754 assert!(
34755 hook.borrow().is_none(),
34756 "search post-precheck lock hook already installed"
34757 );
34758 *hook.borrow_mut() = Some(SearchPostPrecheckLockHook { db_path, mode });
34759 });
34760 SearchPostPrecheckLockGuard
34761}
34762
34763#[cfg(test)]
34764pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34765 let Some(hook) = SEARCH_POST_PRECHECK_LOCK_HOOK.with(|hook| hook.borrow_mut().take()) else {
34766 return Ok(());
34767 };
34768 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
34769 std::thread::spawn(move || {
34770 let conn = Connection::open(&hook.db_path).expect("opening db for search lock hook");
34771 match hook.mode {
34772 SearchPostPrecheckLockMode::RollbackJournal => {
34773 conn.execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;")
34774 .expect("acquiring rollback-journal hook lock");
34775 fs::write(substrate::rollback_journal_path(&hook.db_path), "locked")
34776 .expect("writing rollback journal marker");
34777 }
34778 SearchPostPrecheckLockMode::Wal => {
34779 conn.execute_batch(
34780 "PRAGMA journal_mode=WAL;
34781 PRAGMA wal_autocheckpoint=0;
34782 CREATE TABLE IF NOT EXISTS search_wal_lock_probe (id INTEGER PRIMARY KEY);
34783 INSERT INTO search_wal_lock_probe DEFAULT VALUES;
34784 PRAGMA locking_mode=EXCLUSIVE;
34785 BEGIN EXCLUSIVE;",
34786 )
34787 .expect("acquiring WAL hook lock");
34788 assert!(substrate::wal_sidecar_path(&hook.db_path).exists());
34789 }
34790 }
34791 ready_tx.send(()).expect("signaling search lock hook");
34792 std::thread::sleep(Duration::from_millis(200));
34793 drop(conn);
34794 let _ = fs::remove_file(substrate::rollback_journal_path(&hook.db_path));
34795 });
34796 ready_rx
34797 .recv_timeout(Duration::from_secs(1))
34798 .context("waiting for search post-precheck lock hook")?;
34799 Ok(())
34800}
34801
34802#[cfg(not(test))]
34803pub(crate) fn maybe_apply_search_post_precheck_test_hooks() -> Result<()> {
34804 Ok(())
34805}